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.
package/README.md CHANGED
@@ -1,28 +1,36 @@
1
1
  # leos-agent
2
2
 
3
- Leo's portable agent operating policy, version **10.2.0**, installable on Claude
3
+ Leo's portable agent operating policy, version **10.7.0**, installable on Claude
4
4
  Code, Codex, Cursor, Hermes, Pi, and OpenCode through each harness's own plugin
5
5
  system.
6
6
 
7
7
  The policy it carries is short: **the main thread is an orchestrator.**
8
- Investigation, brainstorming, debugging, execution, testing, and mechanical work
9
- all run in briefed subagents, so the main thread never fills up with the files,
10
- retries, and logs that produced an answer — only the answer. Work runs at one of two
11
- named tiers: **standard**, the model you are already using, for thinking and
12
- judging; **economical**, the cheapest sufficient profile where the harness can
13
- select one. Narrow search, reading, testing, and mechanical work use the runner
14
- profile; well-specified implementation uses the executor profile.
8
+ Investigation, brainstorming, debugging, and mechanical work all run in briefed
9
+ subagents, so the main thread never fills up with the files, retries, and logs
10
+ that produced an answer — only the answer. A single command you can filter at
11
+ the shell stays inline. Work runs at one of two named tiers: **standard**, the
12
+ model you are already using, for thinking and judging; **economical**, two
13
+ named agent profiles — `leo-runner` for narrow search, reading, testing, and
14
+ mechanical work, `leo-executor` for well-specified implementation shipped as
15
+ first-class agent definitions on both Claude Code (`agents/`, Haiku and Sonnet)
16
+ and Codex (installed TOML profiles), so the cheaper model is baked into the
17
+ agent type rather than chosen per dispatch. Every other harness inherits unless
18
+ a machine-local [routing config](#per-machine-model-routing) names models for it.
15
19
 
16
20
  ## What it ships
17
21
 
18
- Beyond the preferences payload: a setup diagnostic, a session handoff pair, and
19
- three GitHub skills. The GitHub ones need `gh`, authenticated.
22
+ Beyond the preferences payload: the two economical-tier agent definitions
23
+ (`agents/` for Claude Code, `payload/codex-agents/` for Codex), a setup
24
+ diagnostic, a routing tuner, a session handoff pair, and three GitHub skills.
25
+ The GitHub ones need `gh`, authenticated.
20
26
 
21
27
  | Skill | What it does | Where |
22
28
  |---|---|---|
23
29
  | `review-pr` | Reviews a pull request and stages inline comments as a **pending** review — visible only to you until you submit or discard on GitHub. Never submits. Resolves the originating ticket (Linear, Jira, GitHub issue) from the PR's title, body, or branch when one is named, and adds a spec lens that checks the diff against it. | every skill-loading harness |
24
- | `watch-review` | Arms a watcher that streams new direct review requests into the session, one notification per pull request, for `review-pr` to handle. Polling is a shell script (`scripts/watch_review.py`), not a model loop: an idle tick is one `gh` call and zero tokens. | **Claude Code only** — built on its Monitor tool |
30
+ | `watch-review` | Arms a watcher that streams direct review requests into the session for `review-pr` to handle, and re-streams one when its head moves. Never surfaces a pull request someone else has approved. Polling is a shell script (`scripts/watch_review.py`), not a model loop: an idle tick is one `gh` call and zero tokens. | **Claude Code only** — built on its Monitor tool |
25
31
  | `doctor` | Diagnoses this harness's setup, read-only: whether the `<leos-agent>` block is injected and current, what else is loaded into every session (global instruction file, memories, settings, skills), and whether a local checkout passes `scripts/check.py`. Run it with `/doctor`. | every skill-loading harness |
32
+ | `tune-routing` | Picks the concrete models behind `leo-runner` and `leo-executor` on this machine, writes them to `~/.leos-agent-local/routing.json`, re-renders the install, and proves the choice with one live dispatch — model strings are never checked against a known-model list, so a typo surfaces at dispatch time and nowhere earlier. Run it with `/tune-routing`. | every skill-loading harness |
33
+ | `review-usage` | Reads many sessions across every harness on this machine — a time window, not one session — and reports where the tokens went and how well the policy actually held: routing compliance, guard blocks and whether they were re-dispatched, over- and under-delegation, cache health. The scan is a script, not a prompt, so it costs a second rather than a model's worth of transcript reading. Run it with `/review-usage`. | every skill-loading harness |
26
34
  | `handoff` | Writes this session's context — goal, what landed, what is next, key files, decisions, gotchas — to a markdown document under `~/.leos-agent-local/handoffs/`, so a later session can pick the work up. Pointers, not contents: it names files rather than pasting them. Run it with `/handoff`. | every skill-loading harness |
27
35
  | `handon` | Loads a handoff written earlier — in this harness or a different one — and resumes from it, reporting any drift first when the directory, branch, or HEAD has moved since. Loading never consumes a handoff. Run it with `/handon <name>`. | every skill-loading harness |
28
36
  | `attach-pr` | Attaches the current desktop session to an existing pull request so the app shows its PR card. Creates nothing and pushes nothing. | **Claude Code only** — it drives that app's card |
@@ -31,14 +39,144 @@ The Claude-only pair live in `skills-claude/` and `commands-claude/`, listed in
31
39
  `.claude-plugin/plugin.json` and nowhere else. Hermes receives the preferences
32
40
  payload but no skills — it has no skill loader.
33
41
 
34
- The watcher records each reviewed pull request under `~/.leos-agent-local/`
35
- (override with `$LEOS_AGENT_LOCAL_PATH`), so it never surfaces the same one
36
- twice; `watch_review.py forget <n>` puts one back in play.
42
+ The watcher records the **head commit** it reviewed each pull request at, under
43
+ `~/.leos-agent-local/` (override with `$LEOS_AGENT_LOCAL_PATH`), so a pull
44
+ request comes back when someone pushes to it and stays quiet otherwise;
45
+ `watch_review.py forget <n>` puts one back in play at its current head. Two
46
+ gates keep a continuous watch from being expensive: a pull request another user
47
+ has already approved is never surfaced at all, and a new head must hold still
48
+ for `--settle` seconds (default 120) before it is emitted, so a burst of pushes
49
+ costs one review rather than one per commit.
50
+
51
+ Handoffs live in the same place, at
52
+ `~/.leos-agent-local/handoffs/<name>.md` — a fixed path that needs no plugin
53
+ root, so `/handon` reads one with a single `cat` rather than going looking for
54
+ it. Nothing there is ever pruned automatically: `handoff.py list [--all]` shows
55
+ what exists and `handoff.py rm <name>` is the only way one goes away. The
56
+ directory is deliberately outside the plugin, so upgrading or reinstalling can
57
+ never take state with it.
58
+
59
+ ## Per-machine model routing
60
+
61
+ The economical tier only ever had teeth on Claude Code and Codex, because those
62
+ are the two harnesses whose model names the payload could hardcode. Everywhere
63
+ else, every fan-out ran at the current model — the most expensive shape the
64
+ policy has. Which models a harness offers varies by machine and by what an IT
65
+ department allows, so the mapping is machine-local config rather than something
66
+ the plugin can ship:
67
+
68
+ ```jsonc
69
+ // ~/.leos-agent-local/routing.json (override the directory with $LEOS_AGENT_LOCAL_PATH)
70
+ {
71
+ "cursor": {"runner": "grok-code-fast-1", "executor": "claude-sonnet-4.6"},
72
+ "opencode": {"runner": "anthropic/claude-haiku-4-5"},
73
+ "codex": {"runner": {"model": "gpt-5.6-luna", "effort": "low"}}
74
+ }
75
+ ```
76
+
77
+ Keys are harness names; each holds `runner` and/or `executor`, independently —
78
+ configuring only `runner` is the common case, since it is the fan-out that
79
+ costs. A bare string is shorthand for `{"model": ...}`. Model strings are
80
+ free-form and never checked against a known-model list: whatever the harness
81
+ accepts goes in verbatim. A misspelled *key*, though, is a hard error, because a
82
+ typo that silently left a harness on the expensive model is the one failure this
83
+ is here to prevent.
84
+
85
+ **Nothing reads it at run time.** `leo-install.py` renders the result into the
86
+ `<leos-agent>` block it already writes, so a session pays nothing to know its own
87
+ routing — no config read, no extra turn. It costs *less* than before: each
88
+ machine now carries only its own harness's dispatch line instead of all of them,
89
+ which took the installed payload from 4497 bytes to 4315–4344 depending on the
90
+ harness. `scripts/measure_context.py` prints the per-harness figure and fails if
91
+ an unconfigured harness ever grows past the old one.
92
+
93
+ Edit the file — by hand, or with `routing.py set --harness <h> --runner
94
+ <model>`, which [`/tune-routing`](#what-it-ships) drives end to end — then
95
+ re-run the installer to re-render; `leo-install.py <harness> --check` reports
96
+ "out of date" until you do, and `/doctor` surfaces it. Installing is
97
+ idempotent: same config, same version, same bytes, so a second run reports
98
+ `unchanged`.
99
+
100
+ **The config is yours, never the installer's.** `leo-install.py` only ever reads
101
+ it, and never creates, migrates, rewrites, or removes it, including under
102
+ `--uninstall`; it lives outside the plugin so an upgrade cannot take it. The one
103
+ thing that writes it is `routing.py set` / `unset`, run because you asked: it
104
+ creates the file if it is missing, replaces the one role you named, validates
105
+ the whole document before writing, and leaves every other harness's entry —
106
+ including the bare-string shorthand — exactly as you wrote it. `routing.py show`
107
+ prints what is configured; with no file at all, every harness uses its shipped
108
+ default and behaviour is exactly what it was before this existed.
109
+
110
+ Delivery differs by harness only in the last mile: Claude Code gets a `model:`
111
+ override alongside `subagent_type:` (the plugin-owned `agents/*.md` are never
112
+ rewritten), Codex gets the models substituted into its installed profile TOMLs,
113
+ Cursor gets its own `~/.cursor/rules/leos-agent-routing.mdc` because its rules
114
+ come straight out of the plugin directory, and the rest get the rendered line in
115
+ their global instruction file.
116
+
117
+ ## The dispatch guard
118
+
119
+ The payload has always said that a subagent dispatch must name a model. Prose
120
+ alone did not hold: a forgotten dispatch inherits the parent's expensive model
121
+ and pays a cold cache write per child, which is the single most expensive shape
122
+ this policy has. From 10.7.0 that half of the rule is enforced by a hook instead,
123
+ and the prose it replaced came out of the always-loaded payload — enforcement in
124
+ code costs **zero** context per turn, so the guard paid for itself in bytes
125
+ before saving a cent.
126
+
127
+ `scripts/dispatch_guard.py` runs before a subagent dispatch and refuses exactly
128
+ one thing: an agent selected with a brief, **no model named**, on a harness that
129
+ can name one. Three ways to comply, all of them one word:
130
+
131
+ | Instead of | Use | For |
132
+ |---|---|---|
133
+ | a generic agent, no model | `subagent_type: "leo-runner"` | reading, search, tests, logs, codemods, fan-out |
134
+ | a generic agent, no model | `subagent_type: "leo-executor"` | an approved plan, a well-specified change |
135
+ | a generic agent, no model | `model: "<name>"` | investigation and debugging — naming it *is* the stated reason |
136
+
137
+ It never picks a model for you. It cannot force cheap work onto an expensive
138
+ problem, so it cannot cause a quality regression — only an explicit choice. It
139
+ is also deliberately narrow: a false block costs one re-dispatch, while a caught
140
+ inherited fan-out saves the cold prefix of every child, so the margin only holds
141
+ while the rule refuses to make judgment calls.
142
+
143
+ Detection is by **argument shape**, not tool name — only Claude Code's dispatch
144
+ tool is verified, so an unanticipated one degrades to a no-op rather than a
145
+ broken harness. MCP tools are never guarded. Anything that goes wrong inside the
146
+ guard allows the call and records `decision: "error"`, kept distinct from a
147
+ decision to allow, because a guard that dies quietly is worse than no guard.
148
+
149
+ ```
150
+ LEOS_AGENT_DISPATCH_GUARD=on block (default)
151
+ warn record, never block
152
+ off disabled entirely
153
+ verbose also put the over-delegation notice in front of the model
154
+ LEOS_AGENT_DISPATCH_LOG_PROMPTS=1 debug only: keep 200 chars of brief text in the log
155
+ ```
37
156
 
38
- Handoffs live in the same place, under `handoffs/`. Nothing there is ever pruned
39
- automatically: `handoff.py list [--all]` shows what exists and `handoff.py rm
40
- <name>` is the only way one goes away. The directory is deliberately outside
41
- the plugin, so upgrading or reinstalling can never take state with it.
157
+ **Coverage is honest, not uniform.** Claude Code is verified. Codex, Cursor,
158
+ Hermes and OpenCode are wired with the same policy but their dispatch argument
159
+ shapes are unconfirmed; there the guard is best-effort and no-ops rather than
160
+ misfires. Pi has no hook surface and gets nothing. **Codex hash-pins hooks**, so
161
+ upgrading to 10.7.0 — and every later edit to the guard — needs re-approval
162
+ through `/hooks` there. Until you do, Codex silently enforces nothing; zero Codex
163
+ rows in the report is the symptom.
164
+
165
+ ### What it records
166
+
167
+ `~/.leos-agent-local/dispatch.jsonl`, one line per dispatch, mode `0600`,
168
+ rotated at 1 MiB with one generation kept — bounded at 2 MiB forever.
169
+
170
+ It stores **no prompt text and no paths.** Prompts, sessions and working
171
+ directories are truncated SHA-256. The prompt hash is what makes the report
172
+ meaningful: a blocked brief whose hash comes back naming a tier is a block that
173
+ worked, and one that never returns was abandoned work rather than a saving.
174
+ Delete it whenever you like — nothing depends on its history.
175
+
176
+ ```bash
177
+ python3 scripts/dispatch_log.py report
178
+ python3 scripts/usage_scan.py --since 7d
179
+ ```
42
180
 
43
181
  ## How it works
44
182
 
@@ -49,7 +187,7 @@ gets it through its global instruction file, written by
49
187
  [`scripts/leo-install.py`](scripts/leo-install.py) into a marker block:
50
188
 
51
189
  ```
52
- <leos-agent version="10.2.0">
190
+ <leos-agent version="10.7.0">
53
191
  ...the payload...
54
192
  </leos-agent>
55
193
  ```
@@ -67,7 +205,7 @@ exactly the content it exists to protect.
67
205
 
68
206
  | Harness | Global file the installer writes |
69
207
  |---|---|
70
- | Claude Code | `~/.claude/CLAUDE.md` |
208
+ | Claude Code | `~/.claude/CLAUDE.md` (the `leo-runner` / `leo-executor` agents need no installer step — the plugin's `agents/` directory delivers them) |
71
209
  | Codex | `~/.codex/AGENTS.md` (plus `~/.codex/agents/leo-runner.toml` and `leo-executor.toml`) |
72
210
  | Cursor | none — the plugin's always-apply rule delivers it |
73
211
  | Hermes | `~/.hermes/SOUL.md` (edited only if it already exists) |
@@ -118,7 +256,7 @@ that it is already installed and changes nothing.
118
256
  Run the installer's uninstall first, while the script is still on disk:
119
257
 
120
258
  ```bash
121
- python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.2.0/scripts/leo-install.py claude --uninstall
259
+ python3 ~/.claude/plugins/cache/leos-agent/leos-agent/10.7.0/scripts/leo-install.py claude --uninstall
122
260
  ```
123
261
 
124
262
  ```bash
@@ -149,7 +287,7 @@ Then run the `install` skill in a Codex session (`$leos-agent`, then `install`),
149
287
  run the script directly:
150
288
 
151
289
  ```bash
152
- python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.2.0/scripts/leo-install.py codex
290
+ python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.0/scripts/leo-install.py codex
153
291
  ```
154
292
 
155
293
  This writes `~/.codex/AGENTS.md` and installs two economical agents:
@@ -174,7 +312,7 @@ threads only. Re-adding an already-installed plugin is idempotent.
174
312
  **Uninstall**
175
313
 
176
314
  ```bash
177
- python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.2.0/scripts/leo-install.py codex --uninstall
315
+ python3 ~/.codex/plugins/cache/leos-agent/leos-agent/10.7.0/scripts/leo-install.py codex --uninstall
178
316
  ```
179
317
 
180
318
  ```bash
@@ -290,8 +428,9 @@ Remove the `leos-agent` entry from `plugins.enabled`, and delete the clone if
290
428
  you made one. Your own `SOUL.md` content is left intact — only the block goes.
291
429
 
292
430
  **Note on model routing:** Hermes applies a single `delegation.model` to every
293
- child of a `delegate_task` call, so it cannot vary the model per spawn. The
294
- policy therefore routes all Hermes work to the current model.
431
+ child of a `delegate_task` call, so it cannot vary the model per spawn. A
432
+ routing config still renders a stanza for it, and the stanza says to inherit and
433
+ say so where a per-spawn model is not available.
295
434
 
296
435
  ---
297
436
 
@@ -322,7 +461,7 @@ Pinned refs are reconciled, never silently advanced — to move to a new tag,
322
461
  install it explicitly:
323
462
 
324
463
  ```bash
325
- pi install git:github.com/foxhatleo/leos-agent@v10.2.0
464
+ pi install git:github.com/foxhatleo/leos-agent@v10.7.0
326
465
  ```
327
466
 
328
467
  Re-run `/skill:install` afterwards.
@@ -361,7 +500,10 @@ python3 ~/.cache/opencode/packages/leos-agent@latest/node_modules/leos-agent/scr
361
500
 
362
501
  That writes `~/.config/opencode/AGENTS.md` and copies the skills and commands into
363
502
  `~/.config/opencode/skills/` and `~/.config/opencode/commands/`. From then on
364
- `/leo-install` works inside OpenCode.
503
+ `/leo-install` works inside OpenCode. The copies are installed with the plugin
504
+ root already resolved to an absolute path — OpenCode sets no resolution env var,
505
+ and the copies live apart from the scripts they invoke — so re-run the installer
506
+ after clearing or moving the package cache to point them at the new location.
365
507
 
366
508
  **Upgrade**
367
509
 
@@ -462,8 +604,10 @@ is the worked example: the main thread loads a 3.2 KB contract, the reviewer
462
604
  subagent reads `reference/procedure.md`, and the lens sub-subagents read
463
605
  `reference/lenses.md` that the reviewer itself never loads. Before the split the
464
606
  main thread and the reviewer each loaded the same 21 KB file, and every turn
465
- after that re-read it. Split a file out only when some run genuinely does not
466
- read it; moving prose around costs the same tokens.
607
+ after that re-read it. `tune-routing` does the same with its per-harness model
608
+ discovery, in `reference/harnesses.md`, which only a run that actually tunes
609
+ ever loads. Split a file out only when some run genuinely does not read it;
610
+ moving prose around costs the same tokens.
467
611
 
468
612
  **Invocation split.** A skill is either *user-invoked* — reached by typing its
469
613
  slash command, and carrying `disable-model-invocation: true` — or *deliberately
@@ -537,7 +681,7 @@ claude plugin uninstall leos-agent@leos-agent && claude plugin install leos-agen
537
681
  ```
538
682
 
539
683
  or replace the cachebuster suffix in the Codex manifest with one in the form
540
- `10.2.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
684
+ `10.7.0+codex.local-YYYYMMDD-HHMMSS` and re-add. Either way, plugin changes only
541
685
  reach a **new** session or thread.
542
686
 
543
687
  `--check` exits non-zero when a file is out of date, and `--force` replaces a
@@ -0,0 +1,93 @@
1
+ # Hooks
2
+
3
+ Both files carry exactly one hook: the dispatch guard, which refuses a subagent
4
+ dispatch that names no model. See [the README](../README.md#the-dispatch-guard).
5
+
6
+ **v10 shipped these empty on purpose** — policy was enforced through the payload
7
+ and the skills, not by intercepting tool calls. 10.7.0 reversed that for one
8
+ narrow rule, and the reason is worth keeping: the always-loaded budget in
9
+ `scripts/measure_context.py` had 56 bytes of headroom, so the prose could not be
10
+ strengthened, while a hook costs nothing per turn. Moving the mechanical half of
11
+ the routing rule into code let the prose that restated it come *out* of the
12
+ payload. The doctrine still holds for anything a machine cannot check: judgment
13
+ stays in `rules/preferences.md`, where a model can read it.
14
+
15
+ **Hook scripts live in `scripts/`, not here.** `hooks/` was absent from
16
+ `package.json`'s `files` until 10.7.0, so a script placed here reached nobody who
17
+ installed from npm — and it would have failed silently, since the guard fails
18
+ open. `scripts/check.py` now asserts every hook `command` resolves to a file
19
+ inside a shipped directory. These JSON files are pointers.
20
+
21
+ **There are two files because the harnesses disagree on the format.** Claude
22
+ Code and Codex use PascalCase event names and no version key; Cursor uses
23
+ camelCase names wrapped in `{"version": 1, ...}`. A single file cannot satisfy
24
+ both, so each manifest points at its own.
25
+
26
+ | File | Read by | Wired via |
27
+ |---|---|---|
28
+ | `hooks.json` | Claude Code, Codex | auto-discovery — **neither manifest may name it** |
29
+ | `hooks-cursor.json` | Cursor | `.cursor-plugin/plugin.json`, which overrides Cursor's own auto-discovery |
30
+
31
+ Both Claude Code and Codex load `hooks/hooks.json` on their own. Declaring it in
32
+ the manifest as well is a duplicate: Codex's validator rejects the key outright,
33
+ and Claude Code fails the entire plugin at load time with `Duplicate hooks file
34
+ detected` — which `claude plugin validate` does **not** catch, so only a real
35
+ install reveals it. `scripts/check.py` guards both cases.
36
+
37
+ Cursor is the exception, and only because its file has a different name: naming
38
+ it explicitly overrides Cursor's auto-discovery, which is what keeps Cursor from
39
+ trying to read the PascalCase `hooks.json` it cannot parse.
40
+
41
+ Hermes and OpenCode already carry the guard through their own mechanisms, and
42
+ both call into `scripts/dispatch_guard.py` so that one policy has one
43
+ implementation. Hermes hooks are Python callbacks registered from
44
+ `register(ctx)` in `__init__.py` (`pre_tool_call`, `post_tool_call`,
45
+ `on_session_start`, and so on), not JSON. OpenCode's are JavaScript hooks
46
+ returned from the plugin factory in `index.js`. Pi's are extension event
47
+ handlers. All three would be written in code rather than added here.
48
+
49
+ ## Adding one
50
+
51
+ Claude Code and Codex (`hooks.json`):
52
+
53
+ ```json
54
+ {
55
+ "hooks": {
56
+ "PreToolUse": [
57
+ {
58
+ "matcher": "Bash",
59
+ "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/my-check.py\"",
63
+ "timeout": 10
64
+ }
65
+ ]
66
+ }
67
+ ]
68
+ }
69
+ }
70
+ ```
71
+
72
+ Codex exposes the same directory as `${PLUGIN_ROOT}` and accepts
73
+ `${CLAUDE_PLUGIN_ROOT}` as an alias, so one command string serves both. A hook
74
+ script reads the event JSON on stdin and writes its decision to stdout; exit
75
+ code 2 blocks the call, with stderr as the reason.
76
+
77
+ Cursor (`hooks-cursor.json`) uses the same idea with its own names:
78
+
79
+ ```json
80
+ {
81
+ "version": 1,
82
+ "hooks": {
83
+ "preToolUse": [
84
+ { "command": "python3 ./scripts/my-check.py", "timeout": 10 }
85
+ ]
86
+ }
87
+ }
88
+ ```
89
+
90
+ After editing either file, re-run `python3 scripts/check.py` — it validates
91
+ that both still parse and that Cursor's keeps `version: 1`. Codex additionally
92
+ hash-pins hooks for trust, so a changed hook must be re-approved through
93
+ `/hooks` there.
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "preToolUse": [
5
+ {
6
+ "command": "python3 ./scripts/dispatch_guard.py",
7
+ "timeout": 10
8
+ }
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "[Tt]ask|[Aa]gent|[Ss]ubagent|[Dd]ispatch|[Dd]elegate|[Ss]pawn",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/dispatch_guard.py\"",
10
+ "timeout": 10
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
package/index.js CHANGED
@@ -1,12 +1,118 @@
1
1
  /**
2
2
  * OpenCode plugin entry point for leos-agent.
3
3
  *
4
- * Deliberately does nothing at runtime. OpenCode's plugin API registers tools
5
- * and hooks, not skills or commands, and the payload is installed to disk on
6
- * demand rather than written at session start. This export exists so that
7
- * `opencode plugin leos-agent -g` resolves and loads cleanly; the real work
8
- * happens in scripts/leo-install.py, which the installed `install` skill runs.
4
+ * One hook: refuse a subagent dispatch that names no model, so a fan-out cannot
5
+ * silently inherit the parent's expensive model. The decision itself lives in
6
+ * scripts/dispatch_guard.py -- five harnesses share exactly one policy, and only
7
+ * the event shape differs per harness.
8
+ *
9
+ * OpenCode has no per-tool matcher, so the prefilter is here in JS: without it
10
+ * every Read, Grep and Bash would pay a python3 spawn, which would be a worse
11
+ * regression than the one being fixed. The keys below are duplicated from
12
+ * dispatch_guard.py and pinned equal by tests/test_dispatch_guard.py.
13
+ *
14
+ * Everything else in this plugin is installed to disk on demand by
15
+ * scripts/leo-install.py, which the installed `install` skill runs.
9
16
  */
10
- export const LeosAgent = async () => ({});
17
+ import { spawn } from 'node:child_process';
18
+ import { appendFileSync } from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ const AGENT_KEYS = ['subagent_type', 'subagentType', 'agent_type', 'agentType', 'agent', 'subagent', 'profile'];
23
+ const PROMPT_KEYS = ['prompt', 'brief', 'instructions', 'task', 'message', 'input'];
24
+ const GUARD_TIMEOUT_MS = 10_000;
25
+
26
+ const dataRoot = () => process.env.LEOS_AGENT_LOCAL_PATH || join(homedir(), '.leos-agent-local');
27
+
28
+ /** A breadcrumb that cannot be written must not itself break the session. */
29
+ function breadcrumb(message) {
30
+ try {
31
+ appendFileSync(join(dataRoot(), 'dispatch-guard.log'), `${new Date().toISOString()} ${message}\n`, { mode: 0o600 });
32
+ } catch {
33
+ /* nothing left to do; the guard has already failed open */
34
+ }
35
+ }
36
+
37
+ const hasKey = (args, keys) => keys.some((k) => typeof args[k] === 'string' && args[k].trim());
38
+
39
+ /**
40
+ * Run the shared guard. Resolves to a block reason, or null to allow.
41
+ * Every infrastructure failure -- missing python3, spawn error, timeout, a
42
+ * wedged interpreter -- resolves null: the harm guarded against here is money,
43
+ * and a guard that fails closed would wedge every dispatch in every session.
44
+ */
45
+ function runGuard(root, event) {
46
+ return new Promise((resolve) => {
47
+ let child;
48
+ try {
49
+ child = spawn('python3', [join(root, 'scripts', 'dispatch_guard.py')], {
50
+ stdio: ['pipe', 'ignore', 'pipe'],
51
+ env: { ...process.env, LEOS_AGENT_HARNESS: 'opencode' },
52
+ });
53
+ } catch (err) {
54
+ breadcrumb(`spawn failed: ${err && err.message}`);
55
+ return resolve(null);
56
+ }
57
+
58
+ let stderr = '';
59
+ let settled = false;
60
+ const finish = (value) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ clearTimeout(timer);
64
+ resolve(value);
65
+ };
66
+ const timer = setTimeout(() => {
67
+ breadcrumb('guard timed out; allowing');
68
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
69
+ finish(null);
70
+ }, GUARD_TIMEOUT_MS);
71
+
72
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
73
+ // EPIPE on either pipe means the child exited early. That is an allow, not
74
+ // an unhandled error event that would take the session down with it.
75
+ child.stderr.on('error', () => {});
76
+ child.stdin.on('error', () => {});
77
+ child.on('error', (err) => {
78
+ breadcrumb(`guard error: ${err && err.message}`);
79
+ finish(null);
80
+ });
81
+ child.on('close', (code) => finish(code === 2 ? (stderr.trim() || '[leo routing] blocked') : null));
82
+
83
+ try {
84
+ child.stdin.end(JSON.stringify(event));
85
+ } catch (err) {
86
+ breadcrumb(`stdin write failed: ${err && err.message}`);
87
+ finish(null);
88
+ }
89
+ });
90
+ }
91
+
92
+ export const LeosAgent = async (ctx) => {
93
+ // The factory's ctx is the only place the session directory is available;
94
+ // tool.execute.before receives just {tool, sessionID, callID}.
95
+ const directory = (ctx && (ctx.directory || ctx.worktree)) || process.cwd();
96
+ const root = process.env.LEOS_AGENT_ROOT || process.env.PLUGIN_ROOT || new URL('.', import.meta.url).pathname;
97
+
98
+ return {
99
+ 'tool.execute.before': async (input, output) => {
100
+ const args = output && output.args;
101
+ if (!args || typeof args !== 'object') return;
102
+ const tool = String((input && input.tool) || '');
103
+ if (tool.startsWith('mcp__')) return;
104
+ if (!hasKey(args, AGENT_KEYS) || !hasKey(args, PROMPT_KEYS)) return;
105
+
106
+ const reason = await runGuard(root, {
107
+ tool_name: tool,
108
+ tool_input: args,
109
+ session_id: (input && input.sessionID) || '',
110
+ cwd: directory,
111
+ });
112
+ // OpenCode has no deny return value; throwing is how a hook refuses.
113
+ if (reason) throw new Error(reason);
114
+ },
115
+ };
116
+ };
11
117
 
12
118
  export default LeosAgent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leos-agent",
3
- "version": "10.2.0",
3
+ "version": "10.7.0",
4
4
  "description": "Leo's portable agent operating policy: orchestrator main thread, subagent-first execution, cost-tiered model routing.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "commands-claude/",
30
30
  "commands/",
31
+ "hooks/",
31
32
  "index.js",
32
33
  "payload/",
33
34
  "rules/",