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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +14 -7
  3. package/adapters/cursor/agents/executor.md +1 -1
  4. package/adapters/cursor/agents/implementer.md +2 -2
  5. package/adapters/cursor/agents/review-lens.md +22 -0
  6. package/adapters/cursor/agents/reviewer.md +2 -2
  7. package/adapters/opencode/agents.json +43 -4
  8. package/adapters/opencode/plugin.js +325 -37
  9. package/config/MCP_PINS.md +17 -0
  10. package/config/models.json +276 -8
  11. package/hooks/bash-guard.py +51 -9
  12. package/package.json +3 -6
  13. package/roles/executor.md +1 -1
  14. package/roles/implementer.md +2 -2
  15. package/roles/review-lens.md +20 -0
  16. package/roles/reviewer.md +2 -2
  17. package/scripts/doctor.py +267 -31
  18. package/scripts/ghreview.py +7 -3
  19. package/scripts/jsonc_bridge.cjs +23 -0
  20. package/scripts/memory.py +74 -35
  21. package/scripts/render_adapters.py +57 -22
  22. package/scripts/resolve_attach_target.py +45 -13
  23. package/scripts/setup.py +1594 -2
  24. package/skills/brainstorming/SKILL.md +3 -1
  25. package/skills/debugging/SKILL.md +4 -2
  26. package/skills/delegation/SKILL.md +10 -8
  27. package/skills/doctor/SKILL.md +33 -14
  28. package/skills/executing-plans/SKILL.md +2 -1
  29. package/skills/finishing-a-branch/SKILL.md +4 -2
  30. package/skills/freshness/SKILL.md +23 -10
  31. package/skills/memory/SKILL.md +12 -2
  32. package/skills/resolve-ticket/SKILL.md +15 -9
  33. package/skills/review-pr/SKILL.md +26 -16
  34. package/skills/setup/SKILL.md +123 -9
  35. package/skills/setup/agents/openai.yaml +5 -0
  36. package/skills/test-first/SKILL.md +3 -1
  37. package/skills/using-leo/SKILL.md +11 -6
  38. package/skills/using-leo/references/claude-mapping.md +2 -1
  39. package/skills/using-leo/references/codex-mapping.md +4 -5
  40. package/skills/using-leo/references/cursor-mapping.md +2 -1
  41. package/skills/using-leo/references/hermes-mapping.md +2 -1
  42. package/skills/using-leo/references/opencode-mapping.md +6 -3
  43. package/skills/verification/SKILL.md +2 -1
  44. package/skills/visual-verification/SKILL.md +2 -1
  45. package/skills/watch-review/SKILL.md +17 -14
  46. package/skills/watch-review/agents/openai.yaml +5 -0
  47. package/skills/worktrees/SKILL.md +3 -1
  48. package/skills/writing-plans/SKILL.md +2 -1
  49. package/skills/writing-skills/SKILL.md +9 -2
  50. package/vendor/jsonc-parser-3.3.1/LICENSE.md +21 -0
  51. package/vendor/jsonc-parser-3.3.1/README.md +26 -0
  52. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/edit.js +201 -0
  53. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/format.js +275 -0
  54. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/parser.js +682 -0
  55. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/scanner.js +456 -0
  56. package/vendor/jsonc-parser-3.3.1/lib/umd/impl/string-intern.js +42 -0
  57. package/vendor/jsonc-parser-3.3.1/lib/umd/main.d.ts +351 -0
  58. package/vendor/jsonc-parser-3.3.1/lib/umd/main.js +194 -0
  59. package/vendor/jsonc-parser-3.3.1/package.json +37 -0
  60. package/workflows/cost-tiered-fix.js +32 -4
@@ -1,6 +1,12 @@
1
- // Leo's OpenCode bridge: registers the skills dir by path (OpenCode names
2
- // each skill from its own frontmatter, so there is no leo: prefix here as
3
- // there is on Claude Code and Hermes), injects the
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
- let policy = LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
341
+ return LEO_POLICY_MARKER + '\n' + combined + '</leo-policy>';
342
+ }
83
343
 
84
- // Memory rides in its own envelope after the policy. Spawned rather than
85
- // reimplemented in JS: memory.py already owns the store, the projection and
86
- // the marker engine, and a fourth copy of that renderer would drift.
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 mkdir(dir, { recursive: true });
133
- await writeFile(dest, policy, 'utf8');
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
- const policyCache = new Map();
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
- if (!policyPathCache.has(directory)) {
154
- const policy = await getPolicy(directory);
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 = localStateRoot();
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 = path.resolve(ROOT, 'skills');
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.agent ||= {};
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
- reject(new Error('bash-guard.py timed out after 10s'));
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', reject);
272
- proc.on('close', (code) => resolve(code));
273
- proc.stdin.write(payload);
274
- proc.stdin.end();
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.