leos-agent 6.1.1 → 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 +18 -11
- package/adapters/cursor/agents/executor.md +2 -2
- package/adapters/cursor/agents/implementer.md +2 -2
- package/adapters/cursor/agents/review-lens.md +22 -0
- package/adapters/cursor/agents/reviewer.md +3 -2
- package/adapters/opencode/agents.json +44 -5
- package/adapters/opencode/plugin.js +435 -47
- package/config/MCP_PINS.md +17 -0
- package/config/models.json +647 -33
- package/hooks/bash-guard.py +51 -9
- package/hooks/session-start.py +27 -0
- package/package.json +19 -8
- package/roles/executor.md +2 -2
- package/roles/implementer.md +2 -2
- package/roles/review-lens.md +20 -0
- package/roles/reviewer.md +3 -2
- package/scripts/doctor.py +520 -0
- package/scripts/ghreview.py +558 -0
- package/scripts/jsonc_bridge.cjs +23 -0
- package/scripts/memory.py +744 -0
- package/scripts/render_adapters.py +253 -121
- package/scripts/resolve_attach_target.py +389 -0
- package/scripts/setup.py +1753 -0
- 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 +124 -0
- package/skills/executing-plans/SKILL.md +2 -1
- package/skills/finishing-a-branch/SKILL.md +4 -2
- package/skills/freshness/SKILL.md +131 -0
- package/skills/memory/SKILL.md +154 -0
- package/skills/resolve-ticket/SKILL.md +275 -0
- package/skills/review-pr/SKILL.md +327 -0
- package/skills/setup/SKILL.md +199 -0
- package/skills/setup/agents/openai.yaml +5 -0
- package/skills/test-first/SKILL.md +3 -1
- package/skills/using-leo/SKILL.md +18 -6
- package/skills/using-leo/references/claude-mapping.md +23 -1
- package/skills/using-leo/references/codex-mapping.md +18 -9
- package/skills/using-leo/references/cursor-mapping.md +19 -6
- package/skills/using-leo/references/hermes-mapping.md +18 -7
- package/skills/using-leo/references/opencode-mapping.md +18 -9
- package/skills/verification/SKILL.md +9 -1
- package/skills/visual-verification/SKILL.md +115 -0
- package/skills/watch-review/SKILL.md +128 -0
- 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 +141 -0
- 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,15 +16,22 @@
|
|
|
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';
|
|
30
|
+
import { readFile, writeFile, mkdir, readdir, stat, rm, utimes, chmod } from 'node:fs/promises';
|
|
31
|
+
import { appendFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
20
32
|
import path from 'node:path';
|
|
21
33
|
import os from 'node:os';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
22
35
|
import { fileURLToPath } from 'node:url';
|
|
23
36
|
import { spawn } from 'node:child_process';
|
|
24
37
|
|
|
@@ -30,6 +43,29 @@ function localStateRoot() {
|
|
|
30
43
|
return process.env.LEOS_AGENT_LOCAL_PATH || path.join(os.homedir(), '.leos-agent-local');
|
|
31
44
|
}
|
|
32
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
|
+
|
|
60
|
+
// One OpenCode process serves several project directories at once (its own log
|
|
61
|
+
// shows one run= creating an instance per directory), and ESM caches this
|
|
62
|
+
// module once per process, so nothing below may key off process.cwd() or hold
|
|
63
|
+
// per-directory state in a bare module variable. The directory arrives on
|
|
64
|
+
// PluginInput and is threaded through every consumer instead.
|
|
65
|
+
function directoryKey(directory) {
|
|
66
|
+
return createHash('sha256').update(directory).digest('hex').slice(0, 12);
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
// A 6-line strip, not a YAML parser: the body starts after the second
|
|
34
70
|
// leading '---' fence line. Anything malformed just returns the raw text.
|
|
35
71
|
function stripFrontmatter(raw) {
|
|
@@ -43,7 +79,241 @@ function stripFrontmatter(raw) {
|
|
|
43
79
|
return raw;
|
|
44
80
|
}
|
|
45
81
|
|
|
46
|
-
|
|
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
|
+
|
|
316
|
+
async function assemblePolicy(directory) {
|
|
47
317
|
let skillRaw;
|
|
48
318
|
try {
|
|
49
319
|
skillRaw = await readFile(path.join(ROOT, 'skills', 'using-leo', 'SKILL.md'), 'utf8');
|
|
@@ -71,33 +341,82 @@ async function assemblePolicy() {
|
|
|
71
341
|
return LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
|
|
72
342
|
}
|
|
73
343
|
|
|
74
|
-
async function
|
|
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;
|
|
351
|
+
const memory = await memoryBlock(directory);
|
|
352
|
+
if (memory) {
|
|
353
|
+
policy += '\n\n<leo-memory>\n' + memory + '\n</leo-memory>';
|
|
354
|
+
}
|
|
355
|
+
return policy;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Hard-kills after 4s: OpenCode session start must not hang on a memory store
|
|
359
|
+
// living on a slow or unreachable filesystem. Any failure yields '' and the
|
|
360
|
+
// session proceeds with policy only.
|
|
361
|
+
//
|
|
362
|
+
// `memory.py session` renders the block for the repo it is run in, so the
|
|
363
|
+
// directory has to be the session's, not the server's: process.cwd() here
|
|
364
|
+
// would serve one project's repo-scoped memories to every other project the
|
|
365
|
+
// same OpenCode process is hosting.
|
|
366
|
+
function memoryBlock(directory) {
|
|
367
|
+
return new Promise((resolve) => {
|
|
368
|
+
let done = false;
|
|
369
|
+
const finish = (value) => { if (!done) { done = true; resolve(value); } };
|
|
370
|
+
try {
|
|
371
|
+
const child = spawn('python3', [path.join(ROOT, 'scripts', 'memory.py'), 'session'], {
|
|
372
|
+
cwd: directory,
|
|
373
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
374
|
+
});
|
|
375
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} finish(''); }, 4000);
|
|
376
|
+
let out = '';
|
|
377
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString(); });
|
|
378
|
+
child.on('error', () => { clearTimeout(timer); finish(''); });
|
|
379
|
+
child.on('close', (code) => {
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
finish(code === 0 ? out.trim() : '');
|
|
382
|
+
});
|
|
383
|
+
} catch {
|
|
384
|
+
finish('');
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Named per directory: the memory envelope inside is repo-scoped, so a single
|
|
390
|
+
// shared filename would have each project's instance overwriting the file the
|
|
391
|
+
// other projects' config.instructions already point at.
|
|
392
|
+
async function writePolicyFile(policy, directory) {
|
|
75
393
|
const dir = localStateRoot();
|
|
76
|
-
const dest = path.join(dir,
|
|
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);
|
|
77
397
|
try {
|
|
78
|
-
await
|
|
79
|
-
|
|
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 });
|
|
80
406
|
return dest;
|
|
81
407
|
} catch {
|
|
82
408
|
return null;
|
|
83
409
|
}
|
|
84
410
|
}
|
|
85
411
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let policyPathCache;
|
|
95
|
-
async function getPolicyPath() {
|
|
96
|
-
if (policyPathCache === undefined) {
|
|
97
|
-
const policy = await getPolicy();
|
|
98
|
-
policyPathCache = policy ? await writePolicyFile(policy) : null;
|
|
99
|
-
}
|
|
100
|
-
return policyPathCache;
|
|
412
|
+
// Keyed by directory for the same reason. A bare module variable is shared by
|
|
413
|
+
// every instance in the process, which would pin whichever project started
|
|
414
|
+
// first and serve its memories to the rest.
|
|
415
|
+
let policyInputsCache = null;
|
|
416
|
+
const policyPathCache = new Map();
|
|
417
|
+
async function getPolicyPath(directory) {
|
|
418
|
+
const policy = await getPolicy(directory);
|
|
419
|
+
return policy ? await writePolicyFile(policy, directory) : null;
|
|
101
420
|
}
|
|
102
421
|
|
|
103
422
|
async function loadAgents() {
|
|
@@ -109,23 +428,74 @@ async function loadAgents() {
|
|
|
109
428
|
}
|
|
110
429
|
}
|
|
111
430
|
|
|
112
|
-
|
|
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
|
+
|
|
452
|
+
// A guard that cannot run allows the command — the same posture as Claude
|
|
453
|
+
// Code and Codex, where PreToolUse blocks only on exit 2 and any other
|
|
454
|
+
// outcome is non-blocking. What must not happen is that it goes quiet: a
|
|
455
|
+
// one-shot warning latch meant the second and every later unguarded command
|
|
456
|
+
// passed with nothing said anywhere. Breadcrumb every one of them, to the
|
|
457
|
+
// same local log the other bootstraps write to.
|
|
458
|
+
function guardBreadcrumb(err) {
|
|
459
|
+
const reason = (err && err.message) || String(err);
|
|
460
|
+
try {
|
|
461
|
+
const dir = ensureLocalStateRootSync();
|
|
462
|
+
appendFileSync(
|
|
463
|
+
path.join(dir, 'opencode-guard.log'),
|
|
464
|
+
`${new Date().toISOString()} guard did not run, command allowed: ${reason}\n`,
|
|
465
|
+
);
|
|
466
|
+
} catch {
|
|
467
|
+
// A breadcrumb that cannot be written must not itself break the session.
|
|
468
|
+
}
|
|
469
|
+
console.error('[leo guard] guard did not run, allowing command:', reason);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export default async function leoPlugin(ctx) {
|
|
473
|
+
// The only place the session's directory is available. tool.execute.before
|
|
474
|
+
// receives just {tool, sessionID, callID} (@opencode-ai/plugin's own types),
|
|
475
|
+
// so reading a directory off that input yields undefined every time and
|
|
476
|
+
// falls through to the server's cwd — a different tree whenever one process
|
|
477
|
+
// hosts more than one project.
|
|
478
|
+
const directory = (ctx && (ctx.directory || ctx.worktree)) || process.cwd();
|
|
479
|
+
|
|
480
|
+
// OpenCode runs no session-start hook and exports no plugin-root variable,
|
|
481
|
+
// so scripts/doctor.py cannot tell this harness apart from a bare shell and
|
|
482
|
+
// used to inherit the bootstrap's "claude" default. Declare it, and do it
|
|
483
|
+
// here so the guard and memory subprocesses inherit it too.
|
|
484
|
+
process.env.LEOS_AGENT_HARNESS = 'opencode';
|
|
113
485
|
|
|
114
|
-
export default async function leoPlugin(_ctx) {
|
|
115
486
|
return {
|
|
116
487
|
async config(config) {
|
|
117
488
|
config.skills ||= {};
|
|
118
489
|
config.skills.paths ||= [];
|
|
119
|
-
const skillsDir =
|
|
120
|
-
if (!config.skills.paths.includes(skillsDir)) {
|
|
490
|
+
const skillsDir = await getShadowSkillsDir();
|
|
491
|
+
if (skillsDir && !config.skills.paths.includes(skillsDir)) {
|
|
121
492
|
config.skills.paths.push(skillsDir);
|
|
122
493
|
}
|
|
123
494
|
|
|
124
495
|
const agents = await loadAgents();
|
|
125
|
-
config
|
|
126
|
-
Object.assign(config.agent, agents);
|
|
496
|
+
registerAgents(config, agents);
|
|
127
497
|
|
|
128
|
-
const policyPath = await getPolicyPath();
|
|
498
|
+
const policyPath = await getPolicyPath(directory);
|
|
129
499
|
if (policyPath) {
|
|
130
500
|
config.instructions ||= [];
|
|
131
501
|
if (!config.instructions.includes(policyPath)) {
|
|
@@ -143,7 +513,7 @@ export default async function leoPlugin(_ctx) {
|
|
|
143
513
|
if (!Array.isArray(system)) return;
|
|
144
514
|
if (system.some((s) => typeof s === 'string' && s.includes(LEO_POLICY_MARKER))) return;
|
|
145
515
|
|
|
146
|
-
const policy = await getPolicy();
|
|
516
|
+
const policy = await getPolicy(directory);
|
|
147
517
|
if (policy) system.push(policy);
|
|
148
518
|
},
|
|
149
519
|
|
|
@@ -152,32 +522,50 @@ export default async function leoPlugin(_ctx) {
|
|
|
152
522
|
const command = output && output.args && output.args.command;
|
|
153
523
|
if (typeof command !== 'string' || !command) return;
|
|
154
524
|
|
|
155
|
-
|
|
156
|
-
|
|
525
|
+
// bash-guard expands relative targets against this cwd, so the wrong
|
|
526
|
+
// one misjudges in both directions: a home-level delete read as safe,
|
|
527
|
+
// a project-local delete read as critical.
|
|
528
|
+
const payload = JSON.stringify({
|
|
529
|
+
tool_name: 'Bash',
|
|
530
|
+
tool_input: { command },
|
|
531
|
+
cwd: directory,
|
|
532
|
+
});
|
|
157
533
|
const guardPath = path.join(ROOT, 'hooks', 'bash-guard.py');
|
|
158
534
|
|
|
159
535
|
let exitCode;
|
|
160
536
|
let stderr = '';
|
|
537
|
+
let timer;
|
|
161
538
|
try {
|
|
162
539
|
exitCode = await new Promise((resolve, reject) => {
|
|
163
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); } };
|
|
544
|
+
// Every other guard channel is bounded — Claude Code, Codex and
|
|
545
|
+
// Cursor all set timeout 10 in their hook manifests, and Hermes
|
|
546
|
+
// runs in-process so it cannot hang on its own. Unbounded, a wedged
|
|
547
|
+
// python3 hangs the tool call forever with nothing shown to anyone.
|
|
548
|
+
timer = setTimeout(() => {
|
|
549
|
+
try { proc.kill('SIGKILL'); } catch {}
|
|
550
|
+
fail(new Error('bash-guard.py timed out after 10s'));
|
|
551
|
+
}, 10000);
|
|
164
552
|
proc.stderr.on('data', (d) => {
|
|
165
553
|
stderr += d.toString();
|
|
166
554
|
});
|
|
167
|
-
proc.on('error',
|
|
168
|
-
proc.on('close', (code) =>
|
|
169
|
-
|
|
170
|
-
|
|
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); });
|
|
171
563
|
});
|
|
172
564
|
} catch (err) {
|
|
173
|
-
|
|
174
|
-
guardWarnedOnce = true;
|
|
175
|
-
console.error(
|
|
176
|
-
'[leo guard] infra failure spawning bash-guard.py, allowing command:',
|
|
177
|
-
err && err.message ? err.message : err,
|
|
178
|
-
);
|
|
179
|
-
}
|
|
565
|
+
guardBreadcrumb(err);
|
|
180
566
|
return; // infra fail-open
|
|
567
|
+
} finally {
|
|
568
|
+
clearTimeout(timer);
|
|
181
569
|
}
|
|
182
570
|
|
|
183
571
|
if (exitCode === 2) {
|
|
@@ -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.
|