leos-agent 6.3.0 → 7.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 +14 -7
- package/adapters/cursor/agents/executor.md +1 -1
- package/adapters/cursor/agents/implementer.md +2 -2
- package/adapters/cursor/agents/review-lens.md +22 -0
- package/adapters/cursor/agents/reviewer.md +2 -2
- package/adapters/opencode/agents.json +43 -4
- package/adapters/opencode/plugin.js +325 -37
- package/config/MCP_PINS.md +17 -0
- package/config/models.json +276 -8
- package/hooks/bash-guard.py +51 -9
- package/package.json +3 -6
- package/roles/executor.md +1 -1
- package/roles/implementer.md +2 -2
- package/roles/review-lens.md +20 -0
- package/roles/reviewer.md +2 -2
- package/scripts/doctor.py +267 -31
- package/scripts/ghreview.py +7 -3
- package/scripts/jsonc_bridge.cjs +23 -0
- package/scripts/memory.py +74 -35
- package/scripts/render_adapters.py +57 -22
- package/scripts/resolve_attach_target.py +45 -13
- package/scripts/setup.py +1594 -2
- package/skills/brainstorming/SKILL.md +3 -1
- package/skills/debugging/SKILL.md +4 -2
- package/skills/delegation/SKILL.md +10 -8
- package/skills/doctor/SKILL.md +33 -14
- package/skills/executing-plans/SKILL.md +2 -1
- package/skills/finishing-a-branch/SKILL.md +4 -2
- package/skills/freshness/SKILL.md +23 -10
- package/skills/memory/SKILL.md +12 -2
- package/skills/resolve-ticket/SKILL.md +15 -9
- package/skills/review-pr/SKILL.md +26 -16
- package/skills/setup/SKILL.md +123 -9
- package/skills/setup/agents/openai.yaml +5 -0
- package/skills/test-first/SKILL.md +3 -1
- package/skills/using-leo/SKILL.md +11 -6
- package/skills/using-leo/references/claude-mapping.md +2 -1
- package/skills/using-leo/references/codex-mapping.md +4 -5
- package/skills/using-leo/references/cursor-mapping.md +2 -1
- package/skills/using-leo/references/hermes-mapping.md +2 -1
- package/skills/using-leo/references/opencode-mapping.md +6 -3
- package/skills/verification/SKILL.md +2 -1
- package/skills/visual-verification/SKILL.md +2 -1
- package/skills/watch-review/SKILL.md +17 -14
- package/skills/watch-review/agents/openai.yaml +5 -0
- package/skills/worktrees/SKILL.md +3 -1
- package/skills/writing-plans/SKILL.md +2 -1
- package/skills/writing-skills/SKILL.md +9 -2
- package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
- package/vendor/jsonc-parser-3.3.1/README.md +26 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
- package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
- package/vendor/jsonc-parser-3.3.1/package.json +37 -0
- package/workflows/cost-tiered-fix.js +32 -4
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
// Leo's OpenCode bridge: registers
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Leo's OpenCode bridge: registers a generated shadow copy of the skills
|
|
2
|
+
// tree, with every skill renamed leo-<name>, in place of the source tree
|
|
3
|
+
// (OpenCode's config schema gives skills only `paths` and `urls` — no
|
|
4
|
+
// namespace or prefix knob — verified against
|
|
5
|
+
// https://opencode.ai/config.json, and OpenCode requires a skill's
|
|
6
|
+
// frontmatter `name:` to equal its containing directory name, so the only
|
|
7
|
+
// way to get a leo-<name> identity here is a generated copy living under
|
|
8
|
+
// that name; there is no such constraint on Claude Code or Hermes, which
|
|
9
|
+
// read leo: off their own namespace instead), injects the
|
|
4
10
|
// generated subagent roster from adapters/opencode/agents.json, assembles
|
|
5
11
|
// the using-leo policy plus the OpenCode mapping appendix and hands it to
|
|
6
12
|
// OpenCode through config.instructions (belt) and the
|
|
@@ -10,14 +16,19 @@
|
|
|
10
16
|
// Node builtins only, ESM. No external dependencies. No runtime frontmatter
|
|
11
17
|
// parsing and no env-var tier overrides: agents.json and this policy are
|
|
12
18
|
// both generated by scripts/render_adapters.py from config/models.json, the
|
|
13
|
-
// single source of truth every other harness also reads.
|
|
19
|
+
// single source of truth every other harness also reads. (The shadow-skills
|
|
20
|
+
// frontmatter rewrite below is not an exception to that: it edits one
|
|
21
|
+
// generated-at-runtime `name:` line, not the policy or the roster.)
|
|
14
22
|
//
|
|
15
23
|
// Every read below is wrapped so a missing file degrades to "no
|
|
16
24
|
// skills/agents/policy" rather than breaking session start — same fail-open
|
|
17
|
-
// posture as hooks/session-start.py.
|
|
25
|
+
// posture as hooks/session-start.py. Shadow-skills generation follows suit,
|
|
26
|
+
// but does not register the source tree on failure: bare skills would falsely
|
|
27
|
+
// advertise identities OpenCode cannot provide. It leaves a doctor breadcrumb
|
|
28
|
+
// and starts without Leo skills instead.
|
|
18
29
|
|
|
19
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
20
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
30
|
+
import { readFile, writeFile, mkdir, readdir, stat, rm, utimes, chmod } from 'node:fs/promises';
|
|
31
|
+
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
21
32
|
import path from 'node:path';
|
|
22
33
|
import os from 'node:os';
|
|
23
34
|
import { createHash } from 'node:crypto';
|
|
@@ -32,6 +43,20 @@ function localStateRoot() {
|
|
|
32
43
|
return process.env.LEOS_AGENT_LOCAL_PATH || path.join(os.homedir(), '.leos-agent-local');
|
|
33
44
|
}
|
|
34
45
|
|
|
46
|
+
async function ensureLocalStateRoot() {
|
|
47
|
+
const dir = localStateRoot();
|
|
48
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
await chmod(dir, 0o700);
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ensureLocalStateRootSync() {
|
|
54
|
+
const dir = localStateRoot();
|
|
55
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
chmodSync(dir, 0o700);
|
|
57
|
+
return dir;
|
|
58
|
+
}
|
|
59
|
+
|
|
35
60
|
// One OpenCode process serves several project directories at once (its own log
|
|
36
61
|
// shows one run= creating an instance per directory), and ESM caches this
|
|
37
62
|
// module once per process, so nothing below may key off process.cwd() or hold
|
|
@@ -54,6 +79,240 @@ function stripFrontmatter(raw) {
|
|
|
54
79
|
return raw;
|
|
55
80
|
}
|
|
56
81
|
|
|
82
|
+
// --- Shadow skills tree ----------------------------------------------------
|
|
83
|
+
//
|
|
84
|
+
// OpenCode requires a skill's frontmatter `name:` to equal its directory
|
|
85
|
+
// name and offers no separate namespace, so `leo-<name>` identities can only
|
|
86
|
+
// come from an actual directory called `leo-<name>`. This section builds one
|
|
87
|
+
// generated copy of skills/ per content hash under localStateRoot() and
|
|
88
|
+
// hands its path to config() in place of the source tree.
|
|
89
|
+
//
|
|
90
|
+
// The directory name is the cache key, the same shape as
|
|
91
|
+
// opencode-policy-<sha256[:12]>.md below: a hash over every file's relative
|
|
92
|
+
// path and content, so any change anywhere under skills/ (a new skill, an
|
|
93
|
+
// edited SKILL.md, an edited reference file) yields a new directory rather
|
|
94
|
+
// than silently reusing a stale one — the failure mode that matters most
|
|
95
|
+
// here, since a stale shadow tree fails quietly rather than loudly.
|
|
96
|
+
|
|
97
|
+
const SHADOW_SKILLS_PREFIX = 'opencode-skills';
|
|
98
|
+
const SHADOW_COMPLETE_MARKER = '.leo-shadow-complete';
|
|
99
|
+
// How long a shadow tree must go untouched before a sweep may delete it.
|
|
100
|
+
// Generous on purpose: the cost of keeping one too long is a few hundred KB
|
|
101
|
+
// of disk, the cost of deleting one too early is pulling the skills directory
|
|
102
|
+
// out from under a running session.
|
|
103
|
+
const STALE_TREE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
// Recursively lists every file under `dir`, as paths relative to `dir`, in a
|
|
106
|
+
// sorted, deterministic order — both the hash and the copy below depend on
|
|
107
|
+
// walking every run in the same order regardless of the filesystem's own
|
|
108
|
+
// directory-entry ordering.
|
|
109
|
+
async function walkFiles(dir) {
|
|
110
|
+
const out = [];
|
|
111
|
+
async function recurse(sub) {
|
|
112
|
+
let entries;
|
|
113
|
+
try {
|
|
114
|
+
entries = await readdir(path.join(dir, sub), { withFileTypes: true });
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
const rel = sub ? path.join(sub, entry.name) : entry.name;
|
|
121
|
+
if (entry.isDirectory()) {
|
|
122
|
+
await recurse(rel);
|
|
123
|
+
} else if (entry.isFile()) {
|
|
124
|
+
out.push(rel);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
await recurse('');
|
|
129
|
+
return out.sort();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A sha256 over every file's relative path and content under skills/ — not
|
|
133
|
+
// mtimes, which lie across a git checkout/clone and would leave the shadow
|
|
134
|
+
// tree pinned to whatever happened to be on disk the first time this ran.
|
|
135
|
+
async function hashSkillsTree(skillsDir, files) {
|
|
136
|
+
const hash = createHash('sha256');
|
|
137
|
+
for (const rel of files) {
|
|
138
|
+
hash.update(rel.split(path.sep).join('/') + '\0');
|
|
139
|
+
hash.update(await readFile(path.join(skillsDir, rel)));
|
|
140
|
+
}
|
|
141
|
+
return hash.digest('hex').slice(0, 12);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Same 6-line strip as stripFrontmatter, but rewriting rather than removing:
|
|
145
|
+
// finds the `name:` line inside the frontmatter fence and replaces it. Every
|
|
146
|
+
// other line — including the rest of the frontmatter and the whole body —
|
|
147
|
+
// is copied through untouched.
|
|
148
|
+
function rewriteSkillName(raw, newName) {
|
|
149
|
+
const lines = raw.split('\n');
|
|
150
|
+
if (!lines.length || lines[0].trim() !== '---') return raw;
|
|
151
|
+
for (let i = 1; i < lines.length; i++) {
|
|
152
|
+
if (lines[i].trim() === '---') break;
|
|
153
|
+
if (/^name:\s*/.test(lines[i])) {
|
|
154
|
+
lines[i] = `name: ${newName}`;
|
|
155
|
+
return lines.join('\n');
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return raw;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Copies every file under skills/ into <shadowRoot>/leo-<skill>/..., which
|
|
162
|
+
// preserves subdirectories (skills/using-leo/references/) and sibling files
|
|
163
|
+
// for free since it walks the real tree rather than special-casing SKILL.md.
|
|
164
|
+
// Only each skill's own SKILL.md is rewritten; everything else is copied
|
|
165
|
+
// verbatim, byte for byte.
|
|
166
|
+
async function buildShadowSkillsTree(shadowRoot, skillsDir, files) {
|
|
167
|
+
for (const rel of files) {
|
|
168
|
+
const parts = rel.split(path.sep);
|
|
169
|
+
// A file directly under skills/ (e.g. .gitkeep) belongs to no skill
|
|
170
|
+
// directory and is not part of what OpenCode reads, so it is hashed
|
|
171
|
+
// (any change there still invalidates the cache) but not copied.
|
|
172
|
+
if (parts.length < 2) continue;
|
|
173
|
+
const skillName = parts[0];
|
|
174
|
+
const destFile = path.join(shadowRoot, `leo-${skillName}`, ...parts.slice(1));
|
|
175
|
+
await mkdir(path.dirname(destFile), { recursive: true });
|
|
176
|
+
if (parts.length === 2 && parts[1] === 'SKILL.md') {
|
|
177
|
+
const raw = await readFile(path.join(skillsDir, rel), 'utf8');
|
|
178
|
+
await writeFile(destFile, rewriteSkillName(raw, `leo-${skillName}`), 'utf8');
|
|
179
|
+
} else {
|
|
180
|
+
await writeFile(destFile, await readFile(path.join(skillsDir, rel)));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Deletes opencode-skills-* directories that nothing has claimed recently.
|
|
186
|
+
//
|
|
187
|
+
// Never deletes on hash mismatch alone. Two Leo payloads can legitimately
|
|
188
|
+
// share one state root — the repo's own documented dev setup points `plugin`
|
|
189
|
+
// at a working tree beside the installed npm package — and each returns its
|
|
190
|
+
// own tree from config(). An eager sweep would delete the *other* live
|
|
191
|
+
// instance's skills directory out from under it, and an upgrade would do the
|
|
192
|
+
// same to any session already open. Every call touches its own marker, so a
|
|
193
|
+
// tree in use stays young; only one untouched for STALE_TREE_MS is garbage.
|
|
194
|
+
async function cleanupStaleShadowTrees(root, currentHash) {
|
|
195
|
+
const currentName = `${SHADOW_SKILLS_PREFIX}-${currentHash}`;
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const cutoff = Date.now() - STALE_TREE_MS;
|
|
203
|
+
for (const entry of entries) {
|
|
204
|
+
if (!entry.isDirectory()) continue;
|
|
205
|
+
if (!entry.name.startsWith(`${SHADOW_SKILLS_PREFIX}-`)) continue;
|
|
206
|
+
if (entry.name === currentName) continue;
|
|
207
|
+
const dir = path.join(root, entry.name);
|
|
208
|
+
try {
|
|
209
|
+
// The marker is the liveness signal; a tree without one never finished
|
|
210
|
+
// building, and its own directory mtime is the best evidence available.
|
|
211
|
+
let seen;
|
|
212
|
+
try {
|
|
213
|
+
seen = (await stat(path.join(dir, SHADOW_COMPLETE_MARKER))).mtimeMs;
|
|
214
|
+
} catch {
|
|
215
|
+
seen = (await stat(dir)).mtimeMs;
|
|
216
|
+
}
|
|
217
|
+
if (seen >= cutoff) continue;
|
|
218
|
+
await rm(dir, { recursive: true, force: true });
|
|
219
|
+
} catch {
|
|
220
|
+
// A stale tree left behind costs disk, not correctness.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// breadcrumb + fail-open sibling of guardBreadcrumb below, for the one other
|
|
226
|
+
// place in this file where "cannot do the real thing" must still be loud
|
|
227
|
+
// somewhere rather than just silently degrading.
|
|
228
|
+
function shadowSkillsBreadcrumb(err) {
|
|
229
|
+
const reason = (err && err.message) || String(err);
|
|
230
|
+
try {
|
|
231
|
+
const dir = ensureLocalStateRootSync();
|
|
232
|
+
appendFileSync(
|
|
233
|
+
path.join(dir, 'opencode-skills.log'),
|
|
234
|
+
`${new Date().toISOString()} shadow skills tree generation failed, ` +
|
|
235
|
+
`registering no Leo skills: ${reason}\n`,
|
|
236
|
+
);
|
|
237
|
+
} catch {
|
|
238
|
+
// A breadcrumb that cannot be written must not itself break the session.
|
|
239
|
+
}
|
|
240
|
+
console.error('[leo skills] shadow tree generation failed; registering no Leo skills:', reason);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Builds (or reuses, keyed by content hash) the shadow tree and returns its
|
|
244
|
+
// path. A failed build returns null rather than the source skills/ directory:
|
|
245
|
+
// registering bare names would be a misleading partial success.
|
|
246
|
+
async function getShadowSkillsDir() {
|
|
247
|
+
const skillsDir = path.resolve(ROOT, 'skills');
|
|
248
|
+
let files;
|
|
249
|
+
try {
|
|
250
|
+
files = await walkFiles(skillsDir);
|
|
251
|
+
if (!files.length) throw new Error('no files found under skills/');
|
|
252
|
+
} catch (err) {
|
|
253
|
+
shadowSkillsBreadcrumb(err);
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
let hash;
|
|
258
|
+
try {
|
|
259
|
+
hash = await hashSkillsTree(skillsDir, files);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
shadowSkillsBreadcrumb(err);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let stateRoot;
|
|
266
|
+
try {
|
|
267
|
+
stateRoot = await ensureLocalStateRoot();
|
|
268
|
+
} catch (err) {
|
|
269
|
+
shadowSkillsBreadcrumb(err);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const shadowRoot = path.join(stateRoot, `${SHADOW_SKILLS_PREFIX}-${hash}`);
|
|
273
|
+
const marker = path.join(shadowRoot, SHADOW_COMPLETE_MARKER);
|
|
274
|
+
|
|
275
|
+
let alreadyBuilt = false;
|
|
276
|
+
try {
|
|
277
|
+
await stat(marker);
|
|
278
|
+
alreadyBuilt = true;
|
|
279
|
+
} catch {
|
|
280
|
+
// No marker: either never built, or a previous build crashed mid-way.
|
|
281
|
+
// Either way, (re)build below.
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (!alreadyBuilt) {
|
|
285
|
+
try {
|
|
286
|
+
await mkdir(shadowRoot, { recursive: true });
|
|
287
|
+
await buildShadowSkillsTree(shadowRoot, skillsDir, files);
|
|
288
|
+
// Written last: its presence is the only thing that means "complete",
|
|
289
|
+
// so a build killed partway through never looks done on the next call.
|
|
290
|
+
await writeFile(marker, hash + '\n', 'utf8');
|
|
291
|
+
} catch (err) {
|
|
292
|
+
shadowSkillsBreadcrumb(err);
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Mark this tree as in use *now*, so a concurrently live instance's tree
|
|
298
|
+
// never looks abandoned to whoever sweeps next.
|
|
299
|
+
try {
|
|
300
|
+
const now = new Date();
|
|
301
|
+
await utimes(marker, now, now);
|
|
302
|
+
} catch {
|
|
303
|
+
// Losing the touch only risks an early sweep of this tree; the next
|
|
304
|
+
// config() call rebuilds it.
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
await cleanupStaleShadowTrees(stateRoot, hash);
|
|
309
|
+
} catch {
|
|
310
|
+
// Best-effort; see cleanupStaleShadowTrees.
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return shadowRoot;
|
|
314
|
+
}
|
|
315
|
+
|
|
57
316
|
async function assemblePolicy(directory) {
|
|
58
317
|
let skillRaw;
|
|
59
318
|
try {
|
|
@@ -79,11 +338,16 @@ async function assemblePolicy(directory) {
|
|
|
79
338
|
// hooks/session-start.py:97 uses.
|
|
80
339
|
combined = combined.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
|
81
340
|
|
|
82
|
-
|
|
341
|
+
return LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
|
|
342
|
+
}
|
|
83
343
|
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
344
|
+
async function getPolicy(directory) {
|
|
345
|
+
// Policy source and mapping are immutable package inputs, so cache only
|
|
346
|
+
// those. Memory is repo-local mutable state and must be projected anew on
|
|
347
|
+
// every config() call in this long-lived OpenCode process.
|
|
348
|
+
if (!policyInputsCache) policyInputsCache = await assemblePolicy();
|
|
349
|
+
let policy = policyInputsCache;
|
|
350
|
+
if (!policy) return null;
|
|
87
351
|
const memory = await memoryBlock(directory);
|
|
88
352
|
if (memory) {
|
|
89
353
|
policy += '\n\n<leo-memory>\n' + memory + '\n</leo-memory>';
|
|
@@ -128,9 +392,17 @@ function memoryBlock(directory) {
|
|
|
128
392
|
async function writePolicyFile(policy, directory) {
|
|
129
393
|
const dir = localStateRoot();
|
|
130
394
|
const dest = path.join(dir, `opencode-policy-${directoryKey(directory)}.md`);
|
|
395
|
+
const digest = createHash('sha256').update(policy).digest('hex');
|
|
396
|
+
const cached = policyPathCache.get(directory);
|
|
131
397
|
try {
|
|
132
|
-
await
|
|
133
|
-
|
|
398
|
+
await ensureLocalStateRoot();
|
|
399
|
+
if (cached && cached.digest === digest) {
|
|
400
|
+
await chmod(cached.path, 0o600);
|
|
401
|
+
return cached.path;
|
|
402
|
+
}
|
|
403
|
+
await writeFile(dest, policy, { encoding: 'utf8', mode: 0o600 });
|
|
404
|
+
await chmod(dest, 0o600);
|
|
405
|
+
policyPathCache.set(directory, { digest, path: dest });
|
|
134
406
|
return dest;
|
|
135
407
|
} catch {
|
|
136
408
|
return null;
|
|
@@ -140,21 +412,11 @@ async function writePolicyFile(policy, directory) {
|
|
|
140
412
|
// Keyed by directory for the same reason. A bare module variable is shared by
|
|
141
413
|
// every instance in the process, which would pin whichever project started
|
|
142
414
|
// first and serve its memories to the rest.
|
|
143
|
-
|
|
144
|
-
async function getPolicy(directory) {
|
|
145
|
-
if (!policyCache.has(directory)) {
|
|
146
|
-
policyCache.set(directory, await assemblePolicy(directory));
|
|
147
|
-
}
|
|
148
|
-
return policyCache.get(directory);
|
|
149
|
-
}
|
|
150
|
-
|
|
415
|
+
let policyInputsCache = null;
|
|
151
416
|
const policyPathCache = new Map();
|
|
152
417
|
async function getPolicyPath(directory) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
policyPathCache.set(directory, policy ? await writePolicyFile(policy, directory) : null);
|
|
156
|
-
}
|
|
157
|
-
return policyPathCache.get(directory);
|
|
418
|
+
const policy = await getPolicy(directory);
|
|
419
|
+
return policy ? await writePolicyFile(policy, directory) : null;
|
|
158
420
|
}
|
|
159
421
|
|
|
160
422
|
async function loadAgents() {
|
|
@@ -166,6 +428,27 @@ async function loadAgents() {
|
|
|
166
428
|
}
|
|
167
429
|
}
|
|
168
430
|
|
|
431
|
+
function agentCollisionBreadcrumb(name) {
|
|
432
|
+
const message = `preserving user OpenCode agent definition for ${name}`;
|
|
433
|
+
try {
|
|
434
|
+
const dir = ensureLocalStateRootSync();
|
|
435
|
+
appendFileSync(path.join(dir, 'opencode-agents.log'), `${new Date().toISOString()} ${message}\n`);
|
|
436
|
+
} catch {}
|
|
437
|
+
console.warn(`[leo agents] ${message}`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function registerAgents(config, agents) {
|
|
441
|
+
config.agent ||= {};
|
|
442
|
+
for (const [role, definition] of Object.entries(agents)) {
|
|
443
|
+
const name = `leo-${role}`;
|
|
444
|
+
if (Object.prototype.hasOwnProperty.call(config.agent, name)) {
|
|
445
|
+
agentCollisionBreadcrumb(name);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
config.agent[name] = definition;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
169
452
|
// A guard that cannot run allows the command — the same posture as Claude
|
|
170
453
|
// Code and Codex, where PreToolUse blocks only on exit 2 and any other
|
|
171
454
|
// outcome is non-blocking. What must not happen is that it goes quiet: a
|
|
@@ -175,8 +458,7 @@ async function loadAgents() {
|
|
|
175
458
|
function guardBreadcrumb(err) {
|
|
176
459
|
const reason = (err && err.message) || String(err);
|
|
177
460
|
try {
|
|
178
|
-
const dir =
|
|
179
|
-
mkdirSync(dir, { recursive: true });
|
|
461
|
+
const dir = ensureLocalStateRootSync();
|
|
180
462
|
appendFileSync(
|
|
181
463
|
path.join(dir, 'opencode-guard.log'),
|
|
182
464
|
`${new Date().toISOString()} guard did not run, command allowed: ${reason}\n`,
|
|
@@ -205,14 +487,13 @@ export default async function leoPlugin(ctx) {
|
|
|
205
487
|
async config(config) {
|
|
206
488
|
config.skills ||= {};
|
|
207
489
|
config.skills.paths ||= [];
|
|
208
|
-
const skillsDir =
|
|
209
|
-
if (!config.skills.paths.includes(skillsDir)) {
|
|
490
|
+
const skillsDir = await getShadowSkillsDir();
|
|
491
|
+
if (skillsDir && !config.skills.paths.includes(skillsDir)) {
|
|
210
492
|
config.skills.paths.push(skillsDir);
|
|
211
493
|
}
|
|
212
494
|
|
|
213
495
|
const agents = await loadAgents();
|
|
214
|
-
config
|
|
215
|
-
Object.assign(config.agent, agents);
|
|
496
|
+
registerAgents(config, agents);
|
|
216
497
|
|
|
217
498
|
const policyPath = await getPolicyPath(directory);
|
|
218
499
|
if (policyPath) {
|
|
@@ -257,21 +538,28 @@ export default async function leoPlugin(ctx) {
|
|
|
257
538
|
try {
|
|
258
539
|
exitCode = await new Promise((resolve, reject) => {
|
|
259
540
|
const proc = spawn('python3', [guardPath], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
541
|
+
let settled = false;
|
|
542
|
+
const succeed = (value) => { if (!settled) { settled = true; resolve(value); } };
|
|
543
|
+
const fail = (err) => { if (!settled) { settled = true; reject(err); } };
|
|
260
544
|
// Every other guard channel is bounded — Claude Code, Codex and
|
|
261
545
|
// Cursor all set timeout 10 in their hook manifests, and Hermes
|
|
262
546
|
// runs in-process so it cannot hang on its own. Unbounded, a wedged
|
|
263
547
|
// python3 hangs the tool call forever with nothing shown to anyone.
|
|
264
548
|
timer = setTimeout(() => {
|
|
265
549
|
try { proc.kill('SIGKILL'); } catch {}
|
|
266
|
-
|
|
550
|
+
fail(new Error('bash-guard.py timed out after 10s'));
|
|
267
551
|
}, 10000);
|
|
268
552
|
proc.stderr.on('data', (d) => {
|
|
269
553
|
stderr += d.toString();
|
|
270
554
|
});
|
|
271
|
-
proc.on('error',
|
|
272
|
-
proc.on('close', (code) =>
|
|
273
|
-
|
|
274
|
-
|
|
555
|
+
proc.on('error', fail);
|
|
556
|
+
proc.on('close', (code) => succeed(code));
|
|
557
|
+
// An early child exit can turn either write or end into EPIPE. Keep
|
|
558
|
+
// handlers on both paths so it follows the existing breadcrumbed
|
|
559
|
+
// fail-open route instead of becoming an unhandled Node error.
|
|
560
|
+
proc.stdin.on('error', fail);
|
|
561
|
+
proc.stdin.write(payload, (err) => { if (err) fail(err); });
|
|
562
|
+
proc.stdin.end((err) => { if (err) fail(err); });
|
|
275
563
|
});
|
|
276
564
|
} catch (err) {
|
|
277
565
|
guardBreadcrumb(err);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# MCP executable pins
|
|
2
|
+
|
|
3
|
+
Leo setup executes only the exact core package versions declared together in
|
|
4
|
+
`models.json`'s `command` and `exactVersion` fields:
|
|
5
|
+
|
|
6
|
+
- `@upstash/context7-mcp@3.2.5`
|
|
7
|
+
- `@playwright/mcp@0.0.78`
|
|
8
|
+
- `chrome-devtools-mcp@1.6.0`
|
|
9
|
+
- `duckduckgo-mcp-server==0.5.0`
|
|
10
|
+
|
|
11
|
+
To update a pin, review the upstream release and its vendor-owned package
|
|
12
|
+
record, confirm that its command-line and MCP transport contract still match
|
|
13
|
+
setup's rendered shape, then change the command argument and `exactVersion`
|
|
14
|
+
in the same commit. Never use an unqualified package name, range, tag, or
|
|
15
|
+
`@latest`. Run both Python 3.9 and 3.14 setup suites, render adapters with
|
|
16
|
+
`--check`, and inspect a dry-run for every affected harness. The final diff
|
|
17
|
+
must receive the normal Opus-tier review before release.
|