Przeglądaj źródła

Merge pull request #2106 from GoldJohnKing/feat/opencode-v2-support

feat(opencode): support OpenCode 2.0.4+ alongside V1
Drew Ritter 2 dni temu
rodzic
commit
8f5a89afe4

+ 60 - 8
.opencode/INSTALL.md

@@ -6,7 +6,11 @@
 
 ## Installation
 
-Add superpowers to the `plugin` array in your `opencode.json` (global or project-level):
+OpenCode V2 requires version 2.0.4 or later.
+
+### OpenCode V1
+
+Use the existing V1 plugin configuration:
 
 ```json
 {
@@ -14,7 +18,22 @@ Add superpowers to the `plugin` array in your `opencode.json` (global or project
 }
 ```
 
-Restart OpenCode. The plugin installs through OpenCode's plugin manager and
+### OpenCode V2 (2.0.4 or later)
+
+Use the V2 plugin configuration:
+
+```json
+{
+  "plugins": ["superpowers@git+https://github.com/obra/superpowers.git"]
+}
+```
+
+For a local V2 installation, configure the repository directory containing
+`index.js`. OpenCode 2.0.4 and 2.0.7 reject a configured direct JavaScript-file
+path. Discovered plugin symlinks remain supported.
+
+Restart OpenCode. V2 uses the `opencode` command; `opencode2` may be available
+as an alias. The plugin installs through OpenCode's plugin manager and
 registers all skills.
 
 Verify by asking: "Tell me about your superpowers"
@@ -55,19 +74,26 @@ and Bun versions pin that resolved git dependency in a lockfile or cache, so a
 restart may not pick up the newest Superpowers commit. If updates do not appear,
 clear OpenCode's package cache or reinstall the plugin.
 
-To pin a specific version:
+To pin a specific version, add a tag or commit to the spec (same form for the
+V1 `plugin` key and the V2 `plugins` key):
 
 ```json
 {
-  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v5.0.3"]
+  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v6.3.0"]
 }
 ```
 
+On V2, pin a tag or commit that includes OpenCode V2 support; `v6.3.0` and
+earlier releases load only on V1.
+
 ## Troubleshooting
 
 ### Plugin not loading
 
-1. Check logs: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`
+1. Check logs. V1: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`.
+   V2 loads plugins in the background server, so add `--standalone`:
+   `opencode run --standalone --print-logs "hello" 2>&1 | grep -i superpowers`,
+   or inspect `~/.local/share/opencode/log/opencode.log` filtering for `role=server`.
 2. Verify the plugin line in your `opencode.json`
 3. Make sure you're running a recent version of OpenCode
 
@@ -83,11 +109,23 @@ package:
 npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode"
 ```
 
-Then use the installed package path in `opencode.json`:
+Then use the absolute path of the installed package in `opencode.json` for your
+OpenCode version. OpenCode does not expand `~`; a `~/...` entry is treated as a
+package name, not a local directory.
+
+**V1:**
 
 ```json
 {
-  "plugin": ["~/.config/opencode/node_modules/superpowers"]
+  "plugin": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
+}
+```
+
+**V2 (2.0.4 or later):**
+
+```json
+{
+  "plugins": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
 }
 ```
 
@@ -98,7 +136,9 @@ Then use the installed package path in `opencode.json`:
 
 ### Tool mapping
 
-Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). On OpenCode these resolve to:
+Skills speak in actions ("create a todo", "dispatch a subagent", "read a file"). The plugin injects a flavor-specific mapping — check your OpenCode version:
+
+**V1 (`opencode` 1.x):**
 
 - "Create a todo" / "mark complete in todo list" → `todowrite`
 - `Subagent (general-purpose):` template → `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
@@ -109,6 +149,18 @@ Skills speak in actions ("create a todo", "dispatch a subagent", "read a file").
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Fetch a URL" → `webfetch`
 
+**V2 (`opencode` 2.0.4 or later; `opencode2` may be available as an alias):**
+
+- "Create a todo" → V2 has no todo tool; track the plan in a markdown file instead
+- `Subagent (general-purpose):` template → `subagent` tool with `agent: "general"` (or `"explore"`); pass `sessionID` to continue a previous subagent
+- "Invoke a skill" → OpenCode's native `skill` tool
+- "Read a file" → `read`
+- "Create, edit, or delete files" → use `patch` with `patchText` when available; otherwise use `write` to create or overwrite files, `edit` for targeted changes, and `shell` for deletion
+- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`)
+- "Search file contents" / "find files by name" → `grep`, `glob`
+- "Fetch a URL" → `webfetch`
+- "Search the web" → `websearch`
+
 ## Getting Help
 
 - Report issues: https://github.com/obra/superpowers/issues

+ 303 - 59
.opencode/plugins/superpowers.js

@@ -1,79 +1,80 @@
 /**
  * Superpowers plugin for OpenCode.ai
  *
- * Injects superpowers bootstrap context via message transform.
- * Auto-registers skills directory via config hook (no symlinks needed).
+ * Dual-compatible with OpenCode V1 and V2.
+ *
+ * V1 (opencode): loaded via named export SuperpowersPlugin — provides config
+ * hook for skills registration and experimental.chat.messages.transform for
+ * bootstrap injection.
+ *
+ * V2 (opencode2): loaded via default export { id, setup } by PluginSupervisor.
+ * setup() registers skills natively via ctx.skill.transform(), and injects
+ * bootstrap context via ctx.session.hook("context").
+ *
+ * No external dependencies — pure JavaScript works in both V1 and V2 without
+ * installing @opencode-ai/plugin or effect.
  */
 
 import path from 'path';
 import fs from 'fs';
-import os from 'os';
 import { fileURLToPath } from 'url';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
-// Simple frontmatter extraction (avoid dependency on skills-core for bootstrap)
+// Skills directory shared by V1 (config hook) and V2 (setup/ctx.skill.transform)
+const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
+
+// Simple frontmatter extraction (avoid dependency on skills-core for
+// bootstrap). Handles plain `key: value` lines, quoted values (including
+// quotes that close on an indented continuation line), YAML block scalar
+// markers (`>`, `|`) with indented continuation lines, and CRLF line
+// endings. Not a full YAML parser — nested maps flatten into their parent
+// key's value, which is fine for the name/description fields consumed here.
 const extractAndStripFrontmatter = (content) => {
-  const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
+  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
   if (!match) return { frontmatter: {}, content };
 
   const frontmatterStr = match[1];
   const body = match[2];
   const frontmatter = {};
+  let lastKey = null;
 
-  for (const line of frontmatterStr.split('\n')) {
+  for (const rawLine of frontmatterStr.split('\n')) {
+    const line = rawLine.replace(/\r$/, '');
     const colonIdx = line.indexOf(':');
-    if (colonIdx > 0) {
+    if (colonIdx > 0 && !/^\s/.test(line)) {
       const key = line.slice(0, colonIdx).trim();
-      const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
-      frontmatter[key] = value;
+      const value = line.slice(colonIdx + 1).trim();
+      // Block scalar markers (>, |, optionally with +/- chomping) carry no
+      // value themselves; the indented lines that follow do.
+      frontmatter[key] = /^(>[+-]?|\|[+-]?)$/.test(value) ? '' : value;
+      lastKey = key;
+    } else if (lastKey !== null && line.trim() !== '') {
+      // Continuation of a multi-line value: append rather than drop so long
+      // descriptions survive parsing. Newlines collapse to spaces — good
+      // enough for the single-line name/description fields consumed here.
+      frontmatter[lastKey] = `${frontmatter[lastKey]} ${line.trim()}`.trim();
     }
   }
 
-  return { frontmatter, content: body };
-};
-
-// Normalize a path: trim whitespace, expand ~, resolve to absolute
-const normalizePath = (p, homeDir) => {
-  if (!p || typeof p !== 'string') return null;
-  let normalized = p.trim();
-  if (!normalized) return null;
-  if (normalized.startsWith('~/')) {
-    normalized = path.join(homeDir, normalized.slice(2));
-  } else if (normalized === '~') {
-    normalized = homeDir;
+  // A quoted value may close on a continuation line, so unquote only once
+  // the value is fully assembled: strip exactly one matching surrounding
+  // pair and leave unbalanced quotes alone.
+  for (const key of Object.keys(frontmatter)) {
+    frontmatter[key] = frontmatter[key].replace(/^(["'])([\s\S]*)\1$/, '$2');
   }
-  return path.resolve(normalized);
-};
-
-// Module-level cache for bootstrap content.
-// The SKILL.md file does not change during a session, so reading + parsing it
-// once eliminates redundant fs.existsSync + fs.readFileSync + regex work on
-// every agent step.  See #1202 for the full analysis.
-let _bootstrapCache = undefined; // undefined = not yet loaded, null = file missing
 
-export const SuperpowersPlugin = async ({ client, directory }) => {
-  const homeDir = os.homedir();
-  const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
-  const envConfigDir = normalizePath(process.env.OPENCODE_CONFIG_DIR, homeDir);
-  const configDir = envConfigDir || path.join(homeDir, '.config/opencode');
-
-  // Helper to generate bootstrap content (cached after first call)
-  const getBootstrapContent = () => {
-    // Return cached result on subsequent calls
-    if (_bootstrapCache !== undefined) return _bootstrapCache;
-
-    // Try to load using-superpowers skill
-    const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
-    if (!fs.existsSync(skillPath)) {
-      _bootstrapCache = null;
-      return null;
-    }
+  return { frontmatter, content: body };
+};
 
-    const fullContent = fs.readFileSync(skillPath, 'utf8');
-    const { content } = extractAndStripFrontmatter(fullContent);
+// Tool mapping injected into the bootstrap, differentiated by host flavor.
+// V1 (OpenCode 1.18.x) and V2 (OpenCode 2.0.4/2.0.7) expose different built-in
+// tools, so each flavor's injection path picks its own constant below.
+// Exported for tests (tests/opencode/test-bootstrap-caching.mjs).
 
-    const toolMapping = `**Tool Mapping for OpenCode:**
+// V1 built-ins: todowrite, task (subagent_type), skill, read, apply_patch,
+// bash, grep, glob, webfetch.
+export const V1_MAPPING = `**Tool Mapping for OpenCode:**
 When skills request actions, substitute OpenCode equivalents:
 - Create or update todos → \`todowrite\`
 - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
@@ -86,7 +87,47 @@ When skills request actions, substitute OpenCode equivalents:
 
 Use OpenCode's native \`skill\` tool to list and load skills.`;
 
-    _bootstrapCache = `<EXTREMELY_IMPORTANT>
+// V2 built-ins: no todo tool at all; task → subagent (agent name in 'agent',
+// continuation via sessionID); apply_patch → patch (patchText, same patch
+// format); bash → shell. read, write, edit, grep, glob, webfetch, websearch,
+// and skill all exist under those names (verified against the 2.0.4 and 2.0.7
+// host contracts).
+export const V2_MAPPING = `**Tool Mapping for OpenCode:**
+When skills request actions, substitute OpenCode equivalents:
+- Create or update todos → OpenCode v2 has no todo tool; track the plan in a markdown file (or the harness's plan facility) instead
+- \`Subagent (general-purpose):\` → \`subagent\` with \`agent: "general"\` (give it \`description\` and \`prompt\`, optionally \`background\`; pass \`sessionID\` to continue a previous subagent)
+- Invoke a skill → OpenCode's native \`skill\` tool
+- Read files → \`read\`
+- Create, edit, or delete files → use \`patch\` with \`patchText\` when available; otherwise use \`write\` to create or overwrite files, \`edit\` for targeted changes, and \`shell\` for deletion
+- Run shell commands → \`shell\` (\`command\`, \`workdir\`, \`timeout\`, \`background\`)
+- Search files → \`grep\`, \`glob\`
+- Fetch a URL → \`webfetch\`
+- Search the web → \`websearch\`
+
+Use OpenCode's native \`skill\` tool to list and load skills.`;
+
+// Module-level cache for bootstrap content, keyed by tool mapping (host
+// flavor). The SKILL.md file does not change during a session, so reading +
+// parsing it once eliminates redundant fs.existsSync + fs.readFileSync +
+// regex work on every agent step.  See #1202 for the full analysis.
+const _bootstrapCache = new Map(); // mapping -> bootstrap (null = file missing)
+
+// Helper to generate bootstrap content (cached after first call per mapping)
+const getBootstrapContent = (toolMapping) => {
+  // Return cached result on subsequent calls
+  if (_bootstrapCache.has(toolMapping)) return _bootstrapCache.get(toolMapping);
+
+  // Try to load using-superpowers skill
+  const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
+  if (!fs.existsSync(skillPath)) {
+    _bootstrapCache.set(toolMapping, null);
+    return null;
+  }
+
+  const fullContent = fs.readFileSync(skillPath, 'utf8');
+  const { content } = extractAndStripFrontmatter(fullContent);
+
+  _bootstrapCache.set(toolMapping, `<EXTREMELY_IMPORTANT>
 You have superpowers.
 
 **IMPORTANT: The using-superpowers skill content is included below. It is ALREADY LOADED - you are currently following it. Do NOT use the skill tool to load "using-superpowers" again - that would be redundant.**
@@ -94,17 +135,96 @@ You have superpowers.
 ${content}
 
 ${toolMapping}
-</EXTREMELY_IMPORTANT>`;
+</EXTREMELY_IMPORTANT>`);
 
-    return _bootstrapCache;
-  };
+  return _bootstrapCache.get(toolMapping);
+};
+
+// --- Task-subagent (child session) detection --------------------------------
+//
+// #2160: the bootstrap drives controller workflows (brainstorming, planning,
+// approval cycles). Injecting it into task subagent sessions makes workers
+// restart design/approval cycles for work the parent already authorised; the
+// <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
+// is not reliable. Detect child sessions structurally instead: a parentID on
+// the session is the child signal on both flavors (task sessions are created
+// with one; top-level sessions simply lack the field), so when the session
+// carrying the message has a parentID we skip bootstrap injection. Skills
+// stay registered for every session — workers keep explicit access to
+// execution skills.
+
+// sessionID -> is-child decision. parentID never changes for a session, so
+// the result is cached until eviction and the injection hook (which fires on
+// every agent step) pays only one client roundtrip per session. The V2
+// service process is long-lived and sessions accumulate over weeks, so the
+// cache is bounded: when full, drop the oldest quarter (Map iterates keys in
+// insertion order). An evicted session merely pays one extra lookup if seen
+// again.
+const CHILD_SESSION_CACHE_MAX = 512;
+const _childSessionCache = new Map();
+
+const _cacheChildSession = (sessionID, isChild) => {
+  if (_childSessionCache.size >= CHILD_SESSION_CACHE_MAX) {
+    let toDrop = Math.ceil(CHILD_SESSION_CACHE_MAX / 4);
+    for (const key of _childSessionCache.keys()) {
+      if (toDrop-- <= 0) break;
+      _childSessionCache.delete(key);
+    }
+  }
+  _childSessionCache.set(sessionID, isChild);
+};
+
+const isChildSession = async (fetchSession, sessionID) => {
+  if (!sessionID) return false; // unknown session: keep current behavior
+  if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);
 
+  let isChild = false;
+  try {
+    const result = await fetchSession(sessionID);
+    // V1 returns a successful SDK envelope while V2 returns a direct session
+    // record. Validate both shapes before classifying or caching the result;
+    // resolved SDK errors must follow the same fail-open path as rejections.
+    if (!result || typeof result !== 'object' || Array.isArray(result)) {
+      throw new Error('Session lookup returned no usable record');
+    }
+    if (result.error != null || result.response?.ok === false) {
+      throw new Error('Session lookup was unsuccessful');
+    }
+    const session = 'data' in result ? result.data : result;
+    if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== sessionID) {
+      throw new Error('Session lookup returned an invalid session identity');
+    }
+    if (session.parentID !== undefined &&
+        (typeof session.parentID !== 'string' || session.parentID.length === 0)) {
+      throw new Error('Session lookup returned an invalid parent identity');
+    }
+    isChild = session.parentID !== undefined;
+  } catch (err) {
+    // Fail open: on lookup errors keep injecting (previous behavior) and do
+    // not cache, so a transient failure can recover on the next step.
+    console.error('[superpowers] session lookup failed, treating session as top-level:', err);
+    return false;
+  }
+  _cacheChildSession(sessionID, isChild);
+  return isChild;
+};
+
+/**
+ * V1 Plugin Function (named export + default.server)
+ *
+ * Used by V1 (OpenCode 1.x): discovered via named export scanning.
+ * Provides: config hook (V1 skills registration) + bootstrap injection
+ * (experimental.chat.messages.transform).
+ */
+export const SuperpowersPlugin = async ({ client, directory }) => {
   return {
     // Inject skills path into live config so OpenCode discovers superpowers skills
     // without requiring manual symlinks or config file edits.
-    // This works because Config.get() returns a cached singleton — modifications
-    // here are visible when skills are lazily discovered later.
     config: async (config) => {
+      // V2: skills is a flat array — skip, setup() handles V2 skill registration
+      if (Array.isArray(config.skills)) return;
+
+      // V1: skills is { paths: [...] }
       config.skills = config.skills || {};
       config.skills.paths = config.skills.paths || [];
       if (!config.skills.paths.includes(superpowersSkillsDir)) {
@@ -112,7 +232,7 @@ ${toolMapping}
       }
     },
 
-    // Inject bootstrap into the first user message of each session.
+    // Inject bootstrap into the first user message of each top-level session.
     // Using a user message instead of a system message avoids:
     //   1. Token bloat from system messages repeated every turn (#750)
     //   2. Multiple system messages breaking Qwen and other models (#894)
@@ -122,18 +242,142 @@ ${toolMapping}
     // arrays may need injection again, so getBootstrapContent() must not do
     // repeated disk work.
     'experimental.chat.messages.transform': async (_input, output) => {
-      const bootstrap = getBootstrapContent();
+      const bootstrap = getBootstrapContent(V1_MAPPING);
       if (!bootstrap || !output.messages.length) return;
       const firstUser = output.messages.find(m => m.info.role === 'user');
       if (!firstUser || !firstUser.parts.length) return;
 
       // Guard: skip if first user message already contains bootstrap.
-      // This prevents double injection when OpenCode passes an already
-      // transformed in-memory message array through the hook again.
       if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
 
+      // #2160: never restart the controller workflow inside task subagent
+      // (child) sessions. V1 passes no input to this hook (verified in the
+      // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID
+      // from the message record itself.
+      if (client && await isChildSession(
+        (id) => client.session.get({ path: { id } }),
+        firstUser.info.sessionID,
+      )) return;
+
       const ref = firstUser.parts[0];
       firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
     }
   };
 };
+
+/**
+ * V2 Setup Function (default.setup)
+ *
+ * Called by V2 PluginSupervisor (packages/core/src/plugin/supervisor.ts).
+ * Performs two things:
+ *
+ * 1. Registers every skills/<name>/SKILL.md as a native Skill.Info object
+ *    via ctx.skill.transform((draft) => draft.add(info)).
+ *    V2 removed the old draft.source() directory registration; the draft API
+ *    is now { list, add, update, remove } where add() decodes plain objects
+ *    against the host's Skill.Info schema (OpenCode 2.0.4 contract):
+ *    { id, name, description?, autoinvoke?, path, content }. The file field
+ *    is `path` — renamed from `location` in upstream commit 199aabe9e2,
+ *    first released in v2.0.4.
+ *    See packages/core/src/plugin/skill.ts and packages/schema/src/skill.ts.
+ * 2. Injects bootstrap context via ctx.session.hook("context"), the V2
+ *    equivalent of V1's experimental.chat.messages.transform.
+ */
+async function setup(ctx) {
+  // V1 (observed on opencode 1.18.18) also invokes default.setup, but with a
+  // V1-shaped ctx that lacks the skill/session domains. Detect it and return
+  // quietly — V1 is served entirely by the SuperpowersPlugin named export.
+  if (!ctx || !ctx.skill || typeof ctx.skill.transform !== 'function' || !ctx.session || typeof ctx.session.hook !== 'function') {
+    return;
+  }
+
+  // 1. Register skills (one transform; one draft.add per skill)
+  try {
+    const skills = [];
+    if (fs.existsSync(superpowersSkillsDir)) {
+      for (const entry of fs.readdirSync(superpowersSkillsDir, { withFileTypes: true })) {
+        if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
+        const skillPath = path.join(superpowersSkillsDir, entry.name, 'SKILL.md');
+        if (!fs.existsSync(skillPath)) continue;
+        const { frontmatter, content } = extractAndStripFrontmatter(fs.readFileSync(skillPath, 'utf8'));
+        skills.push({
+          id: entry.name,
+          name: frontmatter.name || entry.name,
+          ...(frontmatter.description ? { description: frontmatter.description } : {}),
+          // Skill.Info renamed its required file field `location` -> `path`
+          // in OpenCode v2.0.4 (upstream commit 199aabe9e2).
+          path: skillPath,
+          content,
+        });
+      }
+    }
+    await ctx.skill.transform((draft) => {
+      // draft.add() decodes against the host's Skill.Info schema and throws
+      // synchronously on a mismatch. A throw escaping this callback is what
+      // the host escalates into an asynchronous hard-disable of the entire
+      // plugin ("Plugin disabled after skill.transform failed") — the
+      // try/catch around ctx.skill.transform never sees it, and the
+      // bootstrap hook is torn down as collateral. Contain failures per
+      // skill so one rejected payload skips that skill instead of killing
+      // skills AND bootstrap.
+      for (const skill of skills) {
+        try {
+          draft.add(skill);
+        } catch (err) {
+          console.error(`[superpowers] skill "${skill.id}" rejected by host, skipping:`, err);
+        }
+      }
+    });
+  } catch (err) {
+    // Never break plugin activation: one failing plugin takes down the whole
+    // V2 generation (including provider/catalog plugins => no models in TUI).
+    console.error('[superpowers] skill registration failed:', err);
+  }
+
+  // 2. Inject bootstrap into first user message via V2 session context hook
+  try {
+    await ctx.session.hook('context', async (event) => {
+      try {
+        const bootstrap = getBootstrapContent(V2_MAPPING);
+        if (!bootstrap || !event.messages || !event.messages.length) return;
+        const firstUser = event.messages.find(m => m.role === 'user');
+        if (firstUser && (!firstUser.content || !firstUser.content.length)) return;
+        if (firstUser?.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return;
+
+        // #2160: the context event carries the sessionID directly. Skip the
+        // controller bootstrap when this prompt belongs to a task subagent
+        // (child) session. Skills registered above stay available to workers.
+        if (typeof ctx.session.get === 'function' && await isChildSession(
+          (id) => ctx.session.get({ sessionID: id }),
+          event.sessionID,
+        )) return;
+
+        // Native compaction can leave only an opaque checkpoint. Keep it
+        // intact and append the transient bootstrap as a user message.
+        if (firstUser) {
+          firstUser.content.unshift({ type: 'text', text: bootstrap });
+        } else {
+          event.messages.push({ role: 'user', content: [{ type: 'text', text: bootstrap }] });
+        }
+      } catch (err) {
+        // Never let hook callback errors break the request pipeline.
+        console.error('[superpowers] context hook failed:', err);
+      }
+    });
+  } catch (err) {
+    console.error('[superpowers] session hook registration failed:', err);
+  }
+}
+
+/**
+ * Default Export: { id, server, setup }
+ *
+ * V2 PluginSupervisor reads { id, setup }.
+ * V1 reads named export SuperpowersPlugin.
+ * server() is exported for V1 compatibility.
+ */
+export default {
+  id: 'superpowers',
+  server: SuperpowersPlugin,
+  setup,
+};

+ 104 - 22
docs/README.opencode.md

@@ -4,7 +4,11 @@ Complete guide for using Superpowers with [OpenCode.ai](https://opencode.ai).
 
 ## Installation
 
-Add superpowers to the `plugin` array in your `opencode.json` (global or project-level):
+OpenCode V2 requires version 2.0.4 or later.
+
+### OpenCode V1
+
+Use the existing V1 plugin configuration:
 
 ```json
 {
@@ -12,15 +16,27 @@ Add superpowers to the `plugin` array in your `opencode.json` (global or project
 }
 ```
 
-Restart OpenCode. The plugin installs through OpenCode's plugin manager and
+### OpenCode V2 (2.0.4 or later)
+
+Use the V2 plugin configuration:
+
+```json
+{
+  "plugins": ["superpowers@git+https://github.com/obra/superpowers.git"]
+}
+```
+
+For a local V2 installation, configure the repository directory containing
+`index.js`. OpenCode 2.0.4 and 2.0.7 reject a configured direct JavaScript-file
+path. Discovered plugin symlinks remain supported.
+
+Restart OpenCode. V2 uses the `opencode` command; `opencode2` may be available
+as an alias. The plugin installs through OpenCode's plugin manager and
 registers all skills.
 
 Verify by asking: "Tell me about your superpowers"
 
-OpenCode uses its own plugin install. If you also use Claude Code, Codex, or
-another harness, install Superpowers separately for each one.
-
-### Migrating from the old symlink-based install
+### Migrating from the old symlink-based install (V1)
 
 If you previously installed superpowers using `git clone` and symlinks, remove the old setup:
 
@@ -78,7 +94,10 @@ description: Use when [condition] - [what it does]
 
 Create project-specific skills in `.opencode/skills/` within your project.
 
-**Skill Priority:** Project skills > Personal skills > Superpowers skills
+**V2 Skill Priority:** Project skills > Personal skills > Superpowers skills. On
+tested V1 1.18.31, bundled Superpowers skills take precedence when a personal
+or project skill has the same name; use distinct names for personal and project
+skills. This behavior is unchanged by the migration.
 
 ## Updating
 
@@ -87,24 +106,46 @@ and Bun versions pin that resolved git dependency in a lockfile or cache, so a
 restart may not pick up the newest Superpowers commit. If updates do not appear,
 clear OpenCode's package cache or reinstall the plugin.
 
-To pin a specific version, use a branch or tag:
+To pin a specific version, add a tag or commit to the spec (same form for the
+V1 `plugin` key and the V2 `plugins` key):
 
 ```json
 {
-  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v5.0.3"]
+  "plugin": ["superpowers@git+https://github.com/obra/superpowers.git#v6.3.0"]
 }
 ```
 
+On V2, pin a tag or commit that includes OpenCode V2 support; `v6.3.0` and
+earlier releases load only on V1.
+
 ## How It Works
 
-The plugin does two things:
+The plugin does two things, using host-flavor-specific APIs:
+
+1. **Registers the skills directory** so OpenCode discovers all superpowers skills without symlinks or manual config.
+    - **V1:** via the `config` hook, injecting into `config.skills.paths`
+    - **V2:** via the `setup()` function using `ctx.skill.transform()` (V2 native API, confirmed active at runtime)
+2. **Injects bootstrap context** with a flavor-specific tool mapping: V1 sessions get the V1 tool names below, and V2 sessions get the V2 names.
+    - **V1:** via `experimental.chat.messages.transform` hook
+    - **V2:** via `ctx.session.hook("context")` — the V2 equivalent (confirmed active at runtime)
 
-1. **Injects bootstrap context** via the `experimental.chat.messages.transform` hook, adding superpowers awareness to every conversation.
-2. **Registers the skills directory** via the `config` hook, so OpenCode discovers all superpowers skills without symlinks or manual config.
+Controller sessions receive the using-superpowers bootstrap in transient model
+context. Delegated child sessions keep access to native skills but do not receive
+the controller bootstrap. A manual fork without a parent session keeps controller
+behavior. When V2 native compaction retains earlier user messages (the default
+`compaction.keep.tokens` budget), the bootstrap goes into the first retained user
+message ahead of the checkpoint, as in an uncompacted session. When compaction
+removes all user messages, the plugin appends a transient bootstrap message after
+the checkpoint. Saved history is unchanged either way.
+
+If session lookup fails, the plugin keeps bootstrap for that request and retries
+on the next request. Failed lookups are not cached as controller decisions.
 
 ### Tool Mapping
 
-Skills speak in actions rather than naming any one runtime's tools. On OpenCode these resolve to:
+Skills speak in actions rather than naming any one runtime's tools. The bootstrap maps them to the tools your OpenCode flavor actually exposes.
+
+**V1 (`opencode` 1.x):**
 
 - "Create a todo" / "mark complete in todo list" → `todowrite`
 - `Subagent (general-purpose):` template → OpenCode's `task` tool with `subagent_type: "general"` (or `"explore"` for codebase exploration)
@@ -115,15 +156,43 @@ Skills speak in actions rather than naming any one runtime's tools. On OpenCode
 - "Search file contents" / "find files by name" → `grep`, `glob`
 - "Fetch a URL" → `webfetch`
 
-(Verified against the installed OpenCode CLI's tool inventory.)
+**V2 (`opencode` 2.0.4 or later; `opencode2` may be available as an alias):**
+
+- "Create a todo" → V2 has no todo tool of any kind; the mapping tells the model to track the plan in a markdown file (or the harness's plan facility) instead
+- `Subagent (general-purpose):` template → OpenCode's `subagent` tool with `agent: "general"` (or `"explore"`); pass `sessionID` to continue a previous subagent
+- "Invoke a skill" → OpenCode's native `skill` tool
+- "Read a file" → `read`
+- "Create, edit, or delete files" → use `patch` with `patchText` when available; otherwise use `write` to create or overwrite files, `edit` for targeted changes, and `shell` for deletion
+- "Run a shell command" → `shell` (`command`, `workdir`, `timeout`, `background`)
+- "Search file contents" / "find files by name" → `grep`, `glob`
+- "Fetch a URL" → `webfetch`
+- "Search the web" → `websearch`
+
+In short, V2 renamed `task` → `subagent` (the agent name moved from `subagent_type` to `agent`, and continuation happens by re-invoking with `sessionID`), `apply_patch` → `patch`, and `bash` → `shell`, and it dropped the todo tool entirely. The available mutation tools depend on the selected model: `patch` is available for selected GPT model IDs, while other models use `write` and `edit`.
+
+(V1 list verified against the installed OpenCode 1.18.x CLI's tool inventory; V2 list verified against the OpenCode 2.0.4 and 2.0.7 host contracts.)
 
 ## Troubleshooting
 
 ### Plugin not loading
 
-1. Check OpenCode logs: `opencode run --print-logs "hello" 2>&1 | grep -i superpowers`
-2. Verify the plugin line in your `opencode.json` is correct
-3. Make sure you're running a recent version of OpenCode
+**V1:** Check OpenCode logs:
+
+```
+opencode run --print-logs "hello" 2>&1 | grep -i superpowers
+```
+
+**V2:** Plugins load in the background server, whose logs `--print-logs` only
+shows with `--standalone`:
+
+```
+opencode run --standalone --print-logs "hello" 2>&1 | grep -i superpowers
+```
+
+Or inspect `~/.local/share/opencode/log/opencode.log`, filtering for `role=server`.
+
+Also verify the plugin path in your `opencode.json` is correct and that you're
+running a recent version of OpenCode.
 
 ### Windows install issues
 
@@ -137,11 +206,23 @@ package:
 npm install superpowers@git+https://github.com/obra/superpowers.git --prefix "$HOME\.config\opencode"
 ```
 
-Then use the installed package path in `opencode.json`:
+Then use the absolute path of the installed package in `opencode.json` for your
+OpenCode version. OpenCode does not expand `~`; a `~/...` entry is treated as a
+package name, not a local directory.
+
+**V1:**
+
+```json
+{
+  "plugin": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
+}
+```
+
+**V2 (2.0.4 or later):**
 
 ```json
 {
-  "plugin": ["~/.config/opencode/node_modules/superpowers"]
+  "plugins": ["C:\\Users\\<you>\\.config\\opencode\\node_modules\\superpowers"]
 }
 ```
 
@@ -153,11 +234,12 @@ Then use the installed package path in `opencode.json`:
 
 ### Bootstrap not appearing
 
-1. Check OpenCode version supports `experimental.chat.messages.transform` hook
-2. Restart OpenCode after config changes
+- **V1:** Check OpenCode version supports `experimental.chat.messages.transform` hook. Restart OpenCode after config changes.
+- **V2:** The plugin uses `ctx.session.hook("context")` for bootstrap injection. Verify the plugin loaded via `opencode api get /api/plugin`. Restart with `opencode service restart` after config changes. The `opencode2` command may be available as an alias.
 
 ## Getting Help
 
 - Report issues: https://github.com/obra/superpowers/issues
 - Main documentation: https://github.com/obra/superpowers
-- OpenCode docs: https://opencode.ai/docs/
+- OpenCode V2 docs: https://opencode.ai/v2/docs/
+- OpenCode V1 docs: https://opencode.ai/docs/

+ 1 - 1
docs/porting-to-a-new-harness.md

@@ -802,7 +802,7 @@ Use this as the live index; when in doubt, read the files, not this table.
 | Copilot CLI | (shares Claude Code hook path; `COPILOT_CLI` env) | shell hook → `hooks/session-start` (`additionalContext`) | none needed (Claude Code–compatible tool surface) | `tests/hooks/` | — |
 | Gemini CLI | `gemini-extension.json` + `GEMINI.md` | instructions file `@`-includes bootstrap + mapping | `references/gemini-tools.md` | — | `gemini extensions install` |
 | Kimi Code | `.kimi-plugin/plugin.json` | manifest `sessionStart.skill` loads `using-superpowers` | inline `skillInstructions` in manifest | `tests/kimi/` | marketplace or `/plugins install` GitHub URL |
-| OpenCode | `.opencode/plugins/superpowers.js` (declared via root `package.json` `main`) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` plugin git URL |
+| OpenCode | `.opencode/plugins/superpowers.js` (root `package.json` `main` for package installs; root `index.js` re-export for the V2 directory form) | in-process: `config` hook registers skills dir; `experimental.chat.messages.transform` (V1) / `session.hook("context")` (V2) injects user message | inline in `superpowers.js` | `tests/opencode/` | `opencode.json` `plugin` (V1) / `plugins` (V2) git URL |
 | pi | `.pi/extensions/superpowers.ts` | in-process: `resources_discover` registers skills; `context` event injects user message; lifecycle-flag + compaction-aware | `piToolMapping()` inline **and** `references/pi-tools.md` | `tests/pi/` | repo-root `package.json` fields |
 
 ## Appendix B — Gotchas that have bitten porters

+ 9 - 0
index.js

@@ -0,0 +1,9 @@
+// Root entrypoint for OpenCode v2 directory-form plugin registration.
+//
+// OpenCode V2 hosts (2.0.4 or later) require config plugin entries to be directories
+// with an index entrypoint (`index.js`) and reject bare file paths
+// ("configured plugin path must be a directory"). npm/git package installs
+// resolve via package.json `main`; this file only serves the directory form,
+// an absolute path such as `"plugins": ["/path/to/superpowers"]` (`~` is not
+// expanded).
+export { default } from "./.opencode/plugins/superpowers.js";

+ 1 - 0
scripts/sync-to-codex-plugin.sh

@@ -69,6 +69,7 @@ EXCLUDES=(
   "/GEMINI.md"
   "/RELEASE-NOTES.md"
   "/gemini-extension.json"
+  "/index.js"
   "/package.json"
 
   # Directories not shipped by canonical Codex plugins

+ 6 - 0
tests/codex-plugin-sync/test-sync-to-codex-plugin.sh

@@ -194,6 +194,10 @@ write_upstream_fixture() {
   "name": "fixture-upstream",
   "version": "$PACKAGE_VERSION"
 }
+EOF
+
+    cat > "$repo/index.js" <<'EOF'
+export { default } from "./.opencode/plugins/superpowers.js";
 EOF
 
     cat > "$repo/.gitignore" <<'EOF'
@@ -303,6 +307,7 @@ EOF
         hooks/run-hook.cmd \
         hooks/session-start \
         hooks/session-start-codex \
+        index.js \
         package.json \
         scripts/sync-to-codex-plugin.sh \
         skills/example/SKILL.md
@@ -664,6 +669,7 @@ main() {
     assert_not_contains "$preview_section" "evals/" "Preview excludes eval harness"
     assert_not_contains "$preview_section" ".gitmodules" "Preview excludes repo submodule metadata"
     assert_not_contains "$preview_section" ".pre-commit-config.yaml" "Preview excludes repo pre-commit config"
+    assert_not_contains "$preview_section" "index.js" "Preview excludes OpenCode root entrypoint"
     assert_not_contains "$preview_output" "Overlay file (.codex-plugin/plugin.json) will be regenerated" "Preview omits overlay regeneration note"
     assert_not_contains "$preview_output" "Assets (superpowers-small.svg, app-icon.png) will be seeded from" "Preview omits assets seeding note"
     assert_contains "$preview_section" "skills/example/SKILL.md" "Preview reflects dirty tracked destination file"

+ 4 - 0
tests/opencode/run-tests.sh

@@ -45,6 +45,8 @@ while [[ $# -gt 0 ]]; do
             echo "Tests:"
             echo "  test-plugin-loading.sh  Verify plugin installation and structure"
             echo "  test-bootstrap-caching.sh  Verify bootstrap content caching"
+            echo "  test-session-bootstrap.sh  Verify session classification and lookup recovery"
+            echo "  test-skill-registration.sh  Verify V2 skill registration contract (2.0.4 path field)"
             echo "  test-tools.sh           Test use_skill and find_skills tools (integration)"
             echo "  test-priority.sh        Test skill priority resolution (integration)"
             exit 0
@@ -61,6 +63,8 @@ done
 tests=(
     "test-plugin-loading.sh"
     "test-bootstrap-caching.sh"
+    "test-session-bootstrap.sh"
+    "test-skill-registration.sh"
 )
 
 # Integration tests (require OpenCode)

+ 122 - 0
tests/opencode/test-bootstrap-caching.mjs

@@ -32,6 +32,10 @@ const mod = await import(pathToFileURL(pluginPath).href);
 const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
 const transform = plugin['experimental.chat.messages.transform'];
 
+// Mapping constants are flavor-specific (#opencode-v2): V1 keeps the 1.18.x
+// tool names, V2 teaches the renamed tools. Assert both directly.
+const mappingFailures = assertMappingConstants(mod);
+
 const firstOutput = makeOutput(`${scenario} bootstrap first step`);
 await transform({}, firstOutput);
 const afterFirst = { existsCount, readCount };
@@ -40,6 +44,11 @@ const secondOutput = makeOutput(`${scenario} bootstrap second step`);
 await transform({}, secondOutput);
 const afterSecond = { existsCount, readCount };
 
+// Exercise the V2 path (setup() + ctx.session.hook("context")) with a mock
+// ctx so the V2_MAPPING wiring is verified, not just the constant. Run after
+// the V1 count snapshots: setup() reads SKILL.md files during registration.
+const v2Result = await runV2ContextHook(mod);
+
 const result = {
   scenario,
   firstBootstrapParts: countBootstrapParts(firstOutput),
@@ -52,12 +61,24 @@ const result = {
   secondReadCount: afterSecond.readCount,
   firstExistsCount: afterFirst.existsCount,
   secondExistsCount: afterSecond.existsCount,
+  v2BootstrapParts: v2Result.bootstrapParts,
+  mapsV2SubagentTool: v2Result.text.includes('`subagent` with `agent: "general"`'),
+  mapsV2SessionIDContinuation: v2Result.text.includes('`sessionID` to continue a previous subagent'),
+  mapsV2NoTodoTool: v2Result.text.includes('no todo tool'),
+  mapsV2MutationToPatch: v2Result.text.includes('`patch` with `patchText`'),
+  mapsV2Shell: v2Result.text.includes('`shell`'),
+  staleV1ToolsInV2: v2Result.text.includes('`apply_patch`') || v2Result.text.includes('`todowrite`') || v2Result.text.includes('`subagent_type`'),
 };
 
 const failures = scenario === 'present'
   ? assertPresentBootstrap(result)
   : assertMissingBootstrap(result);
 
+if (scenario === 'present') {
+  failures.push(...assertV2Bootstrap(result));
+}
+failures.push(...mappingFailures);
+
 if (failures.length > 0) {
   console.error(JSON.stringify(result, null, 2));
   for (const failure of failures) {
@@ -144,3 +165,104 @@ function assertMissingBootstrap(result) {
   }
   return failures;
 }
+
+function assertMappingConstants(mod) {
+  const failures = [];
+  if (typeof mod.V1_MAPPING !== 'string' || typeof mod.V2_MAPPING !== 'string') {
+    failures.push('expected plugin to export V1_MAPPING and V2_MAPPING string constants');
+    return failures;
+  }
+  for (const needle of ['`todowrite`', '`task` with `subagent_type: "general"`', '`apply_patch`', '`bash`']) {
+    if (!mod.V1_MAPPING.includes(needle)) {
+      failures.push(`expected V1_MAPPING to keep the 1.18.x tool name ${needle}`);
+    }
+  }
+  for (const needle of [
+    '`subagent` with `agent: "general"`',
+    '`sessionID` to continue a previous subagent',
+    'no todo tool',
+    '`write`',
+    '`edit`',
+    '`patch` with `patchText`',
+    '`shell`',
+    '`read`',
+    '`grep`, `glob`',
+    '`webfetch`',
+    '`websearch`',
+  ]) {
+    if (!mod.V2_MAPPING.includes(needle)) {
+      failures.push(`expected V2_MAPPING to teach the V2 tool ${needle}`);
+    }
+  }
+  for (const stale of ['`todowrite`', '`task` with', '`apply_patch`', '`bash`']) {
+    if (mod.V2_MAPPING.includes(stale)) {
+      failures.push(`expected V2_MAPPING not to teach the V1-only tool name ${stale}`);
+    }
+  }
+  return failures;
+}
+
+// Drive setup() with a mock V2 ctx and fire the captured "context" hook on a
+// top-level (parentID-less) session. Returns the injected-part count and the
+// injected bootstrap text ('' when nothing was injected).
+async function runV2ContextHook(mod) {
+  let contextHook = null;
+  const ctx = {
+    skill: {
+      transform: async (fn) => {
+        fn({ add: () => {} });
+      },
+    },
+    session: {
+      hook: async (name, cb) => {
+        if (name === 'context') contextHook = cb;
+      },
+      get: async ({ sessionID }) => ({ id: sessionID }), // top-level: no parentID
+    },
+  };
+  try {
+    await mod.default.setup(ctx);
+  } catch (err) {
+    console.error('[test] V2 setup() threw:', err);
+    return { bootstrapParts: 0, text: '' };
+  }
+  if (typeof contextHook !== 'function') {
+    return { bootstrapParts: 0, text: '' };
+  }
+  const event = {
+    sessionID: 'sess-v2-top',
+    messages: [{ role: 'user', content: [{ type: 'text', text: 'v2 bootstrap step' }] }],
+  };
+  await contextHook(event);
+  const parts = event.messages[0].content.filter(
+    (part) => part.type === 'text' && part.text.includes('EXTREMELY_IMPORTANT')
+  );
+  return { bootstrapParts: parts.length, text: parts[0]?.text || '' };
+}
+
+function assertV2Bootstrap(result) {
+  const failures = [];
+  if (result.v2BootstrapParts !== 1) {
+    failures.push(`expected V2 context hook to inject one bootstrap part, got ${result.v2BootstrapParts}`);
+    return failures;
+  }
+  if (!result.mapsV2SubagentTool) {
+    failures.push('expected V2 bootstrap to map general-purpose subagents to subagent with agent');
+  }
+  if (!result.mapsV2SessionIDContinuation) {
+    failures.push('expected V2 bootstrap to teach sessionID continuation for subagents');
+  }
+  if (!result.mapsV2NoTodoTool) {
+    failures.push('expected V2 bootstrap to state that V2 has no todo tool');
+  }
+  if (!result.mapsV2MutationToPatch) {
+    failures.push('expected V2 bootstrap to map file mutation to patch with patchText');
+  }
+  if (!result.mapsV2Shell) {
+    failures.push('expected V2 bootstrap to map shell commands to the shell tool');
+  }
+  if (result.staleV1ToolsInV2) {
+    failures.push('expected V2 bootstrap not to teach V1-only tool names (apply_patch/todowrite/subagent_type)');
+  }
+  return failures;
+}

+ 224 - 0
tests/opencode/test-session-bootstrap.mjs

@@ -0,0 +1,224 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+const [, , inputPath] = process.argv;
+assert.ok(inputPath, 'pass the plugin module path');
+const pluginURL = pathToFileURL(fs.realpathSync(inputPath));
+const marker = '<EXTREMELY_IMPORTANT>\nYou have superpowers.';
+let generation = 0;
+
+function reply(flavor, session) {
+  return flavor === 'v1' ? { data: session } : session;
+}
+
+function makeEvent(flavor, sessionID) {
+  const text = { type: 'text', text: 'Execute the assigned task' };
+  return {
+    sessionID,
+    messages: [flavor === 'v1'
+      ? { info: { role: 'user', sessionID }, parts: [text] }
+      : { role: 'user', content: [text] }],
+  };
+}
+
+function bootstrapCount(event) {
+  return event.messages.flatMap((message) => message.parts ?? message.content ?? []).filter(
+    (part) => part.type === 'text' && part.text.startsWith(marker)
+  ).length;
+}
+
+async function makeHarness(flavor, fetchSession) {
+  const mod = await import(`${pluginURL.href}?session-test=${++generation}`);
+  const lookups = [];
+  const registered = [];
+  const get = async (id) => {
+    lookups.push(id);
+    return fetchSession(id, lookups.length);
+  };
+  let invoke;
+  if (flavor === 'v1') {
+    const hooks = await mod.SuperpowersPlugin({
+      client: { session: { get: ({ path: { id } }) => get(id) } },
+      directory: '.',
+    });
+    invoke = (event) => hooks['experimental.chat.messages.transform']({}, event);
+  } else {
+    await mod.default.setup({
+      skill: { transform: async (transform) => transform({ add: (skill) => registered.push(skill) }) },
+      session: {
+        get: ({ sessionID }) => get(sessionID),
+        hook: async (name, callback) => { if (name === 'context') invoke = callback; },
+      },
+    });
+  }
+  assert.equal(typeof invoke, 'function');
+  return { invoke, lookups, registered };
+}
+
+for (const flavor of ['v1', 'v2']) {
+  for (const [kind, extra, expected] of [
+    ['root', {}, 1],
+    ['child', { parentID: 'parent' }, 0],
+    ['fork', { fork: { sessionID: 'origin' } }, 1],
+  ]) {
+    const id = `${flavor}-${kind}`;
+    const h = await makeHarness(flavor, () => reply(flavor, { id, ...extra }));
+    const event = makeEvent(flavor, id);
+    await h.invoke(event);
+    assert.equal(bootstrapCount(event), expected, `${id}: first request`);
+    await h.invoke(event);
+    assert.equal(bootstrapCount(event), expected, `${id}: repeated event`);
+    const fresh = makeEvent(flavor, id);
+    await h.invoke(fresh);
+    assert.equal(bootstrapCount(fresh), expected, `${id}: fresh request`);
+    assert.deepEqual(h.lookups, [id], `${id}: cache successful classification`);
+    if (flavor === 'v2' && kind === 'child') {
+      assert.ok(h.registered.some((skill) => skill.id === 'brainstorming'));
+    }
+  }
+
+  const failures = [
+    ['throws', () => { throw new Error('temporary lookup failure'); }],
+    ['missing', () => undefined],
+    ['null', () => null],
+    ['empty', () => reply(flavor, {})],
+    ['wrong-id', () => reply(flavor, { id: 'different-session' })],
+    ['invalid-parent', (id) => reply(flavor, { id, parentID: 42 })],
+  ];
+  if (flavor === 'v1') {
+    failures.push(['resolved-http-error', () => ({
+      data: undefined,
+      error: { name: 'UnknownError', data: { message: 'temporary 503' } },
+      response: { ok: false, status: 503 },
+    })]);
+  }
+  for (const [kind, firstResult] of failures) {
+    const id = `${flavor}-${kind}`;
+    const h = await makeHarness(flavor, (sessionID, call) => call === 1
+      ? firstResult(sessionID)
+      : reply(flavor, { id: sessionID, parentID: 'parent' }));
+    const counts = [];
+    for (let step = 0; step < 2; step++) {
+      const event = makeEvent(flavor, id);
+      await h.invoke(event);
+      counts.push(bootstrapCount(event));
+    }
+    assert.deepEqual(counts, [1, 0], `${id}: recover on the next request`);
+    assert.deepEqual(h.lookups, [id, id], `${id}: never cache the failure`);
+  }
+
+  const isolated = await makeHarness(flavor, (id) => reply(flavor,
+    id === 'child-session' ? { id, parentID: 'parent' } : { id }));
+  for (const [id, expected] of [['root-session', 1], ['child-session', 0], ['root-session', 1], ['child-session', 0]]) {
+    const event = makeEvent(flavor, id);
+    await isolated.invoke(event);
+    assert.equal(bootstrapCount(event), expected);
+  }
+  assert.deepEqual(isolated.lookups, ['root-session', 'child-session']);
+
+  const bounded = await makeHarness(flavor, (id) => reply(flavor, { id, parentID: 'parent' }));
+  for (let index = 0; index <= 512; index++) {
+    const event = makeEvent(flavor, `eviction-${index}`);
+    await bounded.invoke(event);
+    assert.equal(bootstrapCount(event), 0);
+  }
+  const evicted = makeEvent(flavor, 'eviction-0');
+  await bounded.invoke(evicted);
+  assert.equal(bootstrapCount(evicted), 0);
+  assert.equal(bounded.lookups.filter((id) => id === 'eviction-0').length, 2);
+
+  const restarted = await makeHarness(flavor, (id) => reply(flavor, { id, parentID: 'parent' }));
+  const afterRestart = makeEvent(flavor, 'eviction-0');
+  await restarted.invoke(afterRestart);
+  assert.equal(bootstrapCount(afterRestart), 0);
+  assert.deepEqual(restarted.lookups, ['eviction-0']);
+
+  const unknown = await makeHarness(flavor, () => { throw new Error('must not look up a missing ID'); });
+  const noID = makeEvent(flavor, undefined);
+  await unknown.invoke(noID);
+  assert.equal(bootstrapCount(noID), 1);
+  assert.deepEqual(unknown.lookups, []);
+}
+
+function compactedEvent(sessionID) {
+  return {
+    sessionID,
+    system: [],
+    messages: [{
+      role: 'assistant',
+      content: [{ type: 'compaction', provider: 'fixture', encrypted: 'opaque-checkpoint' }],
+    }],
+  };
+}
+
+const compactedRoot = await makeHarness('v2', (id) => ({ id }));
+const rootEvent = compactedEvent('compacted-root');
+const checkpoint = structuredClone(rootEvent.messages[0]);
+await compactedRoot.invoke(rootEvent);
+assert.equal(bootstrapCount(rootEvent), 1);
+assert.deepEqual(rootEvent.messages[0], checkpoint);
+assert.equal(rootEvent.messages.length, 2);
+assert.equal(rootEvent.messages[1].role, 'user');
+assert.deepEqual(rootEvent.system, []);
+await compactedRoot.invoke(rootEvent);
+assert.equal(bootstrapCount(rootEvent), 1);
+assert.equal(rootEvent.messages.length, 2);
+const freshRootEvent = compactedEvent('compacted-root');
+await compactedRoot.invoke(freshRootEvent);
+assert.equal(bootstrapCount(freshRootEvent), 1);
+assert.deepEqual(compactedRoot.lookups, ['compacted-root']);
+
+const compactedChild = await makeHarness('v2', (id) => ({ id, parentID: 'parent' }));
+const childEvent = compactedEvent('compacted-child');
+const originalChild = structuredClone(childEvent);
+await compactedChild.invoke(childEvent);
+assert.equal(bootstrapCount(childEvent), 0);
+assert.deepEqual(childEvent, originalChild);
+assert.deepEqual(compactedChild.lookups, ['compacted-child']);
+
+const retryChild = await makeHarness('v2', (id, call) => {
+  if (call === 1) throw new Error('temporary lookup failure');
+  return { id, parentID: 'parent' };
+});
+const unknownChild = compactedEvent('retry-compacted-child');
+await retryChild.invoke(unknownChild);
+assert.equal(bootstrapCount(unknownChild), 1);
+const recoveredChild = compactedEvent('retry-compacted-child');
+await retryChild.invoke(recoveredChild);
+assert.equal(bootstrapCount(recoveredChild), 0);
+assert.equal(recoveredChild.messages.length, 1);
+assert.equal(retryChild.lookups.length, 2);
+
+const newPromptAfterCheckpoint = compactedEvent('new-prompt-after-checkpoint-root');
+newPromptAfterCheckpoint.messages.push({ role: 'user', content: [{ type: 'text', text: 'Continue' }] });
+await compactedRoot.invoke(newPromptAfterCheckpoint);
+assert.equal(bootstrapCount(newPromptAfterCheckpoint), 1);
+assert.equal(newPromptAfterCheckpoint.messages.length, 2);
+assert.equal(newPromptAfterCheckpoint.messages[1].content.length, 2);
+
+const retainedUser = compactedEvent('retained-user-root');
+retainedUser.messages.unshift({ role: 'user', content: [{ type: 'text', text: 'Keep going' }] });
+const retainedCheckpoint = structuredClone(retainedUser.messages[1]);
+await compactedRoot.invoke(retainedUser);
+assert.equal(bootstrapCount(retainedUser), 1);
+assert.equal(retainedUser.messages.length, 2);
+assert.equal(retainedUser.messages[0].content.length, 2);
+assert.ok(retainedUser.messages[0].content[0].text.startsWith(marker));
+assert.equal(retainedUser.messages[0].content[1].text, 'Keep going');
+assert.deepEqual(retainedUser.messages[1], retainedCheckpoint);
+await compactedRoot.invoke(retainedUser);
+assert.equal(bootstrapCount(retainedUser), 1);
+assert.equal(retainedUser.messages.length, 2);
+
+const retainedUserChild = compactedEvent('retained-user-child');
+retainedUserChild.messages.unshift({ role: 'user', content: [{ type: 'text', text: 'Keep going' }] });
+const originalRetainedUserChild = structuredClone(retainedUserChild);
+await compactedChild.invoke(retainedUserChild);
+assert.equal(bootstrapCount(retainedUserChild), 0);
+assert.deepEqual(retainedUserChild, originalRetainedUserChild);
+const empty = { sessionID: 'empty', messages: [] };
+await compactedRoot.invoke(empty);
+assert.deepEqual(empty.messages, []);
+
+console.log('Session classification, recovery and cache lifetime passed');

+ 4 - 0
tests/opencode/test-session-bootstrap.sh

@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+node "$SCRIPT_DIR/test-session-bootstrap.mjs" "$SCRIPT_DIR/../../.opencode/plugins/superpowers.js"

+ 205 - 0
tests/opencode/test-skill-registration.mjs

@@ -0,0 +1,205 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { pathToFileURL } from 'url';
+
+// Verifies the V2 skill registration payload matches OpenCode 2.0.4's
+// Skill.Info contract (packages/schema/src/skill.ts):
+//   { id, name, description?, autoinvoke?, path, content }
+// Upstream commit 199aabe9e2 (first released in v2.0.4) renamed the required
+// file field `location` -> `path`. A wrong field name makes draft.add()
+// throw inside the host's transform rebuild, which asynchronously disables
+// the whole plugin ("Plugin disabled after skill.transform failed") and
+// takes the bootstrap hook down with it — see PR #2106 review by 80avin.
+
+const [, , inputPath] = process.argv;
+
+if (!inputPath) {
+  console.error('Usage: node test-skill-registration.mjs PLUGIN_PATH');
+  process.exit(2);
+}
+
+const pluginPath = fs.realpathSync(inputPath);
+const skillsDir = path.resolve(path.dirname(pluginPath), '../../skills');
+const mod = await import(pathToFileURL(pluginPath).href);
+
+const failures = [];
+
+// --- Run 1: passive capture of every draft.add payload -------------------
+const added = [];
+await mod.default.setup(makeCtx({ add: (skill) => added.push(skill) }));
+
+const expectedIds = fs.existsSync(skillsDir)
+  ? fs.readdirSync(skillsDir, { withFileTypes: true })
+      .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
+      .filter((e) => fs.existsSync(path.join(skillsDir, e.name, 'SKILL.md')))
+      .map((e) => e.name)
+      .sort()
+  : [];
+
+if (added.length === 0) {
+  failures.push('expected setup() to register at least one skill via draft.add()');
+}
+if (JSON.stringify(added.map((s) => s.id).sort()) !== JSON.stringify(expectedIds)) {
+  failures.push(`expected draft.add() ids to match skills dir contents, got ${JSON.stringify(added.map((s) => s.id))}`);
+}
+
+for (const skill of added) {
+  if (typeof skill.path !== 'string' || !path.isAbsolute(skill.path)) {
+    failures.push(`skill "${skill.id}": expected required absolute Skill.Info field "path", got ${JSON.stringify(skill.path)}`);
+  } else if (skill.path !== path.join(skillsDir, skill.id, 'SKILL.md')) {
+    failures.push(`skill "${skill.id}": expected path ${path.join(skillsDir, skill.id, 'SKILL.md')}, got ${skill.path}`);
+  } else if (!fs.existsSync(skill.path)) {
+    failures.push(`skill "${skill.id}": path does not exist on disk: ${skill.path}`);
+  }
+  // Stale 2.0.3-era fields must not leak into the payload: the host strips
+  // unknown keys, but keeping them would silently mask a future regression
+  // to a schema that no longer accepts `path`.
+  if ('location' in skill) {
+    failures.push(`skill "${skill.id}": payload still carries the pre-2.0.4 field "location"`);
+  }
+  if ('slash' in skill) {
+    failures.push(`skill "${skill.id}": payload carries "slash", removed from Skill.Info in 2.0.4`);
+  }
+  if (typeof skill.id !== 'string' || skill.id.length === 0) failures.push(`skill payload missing non-empty "id"`);
+  if (typeof skill.name !== 'string' || skill.name.length === 0) failures.push(`skill "${skill.id}" missing non-empty "name"`);
+  if (typeof skill.content !== 'string' || !skill.content.trim()) failures.push(`skill "${skill.id}" missing non-empty "content"`);
+  if ('description' in skill && typeof skill.description !== 'string') {
+    failures.push(`skill "${skill.id}": "description" must be a string when present`);
+  } else if ('description' in skill && /["']$/.test(skill.description)) {
+    failures.push(`skill "${skill.id}": description ends with a dangling quote: ${JSON.stringify(skill.description)}`);
+  }
+  if (typeof skill.content === 'string' && skill.content.startsWith('---')) {
+    failures.push(`skill "${skill.id}": content still starts with the frontmatter delimiter`);
+  }
+}
+
+// --- Run 2: hostile draft.add must not abort the remaining registrations --
+// The real host swallows a throw escaping the transform callback and then
+// hard-disables the plugin asynchronously. Locally we can only observe the
+// synchronous half of that contract: when draft.add() rejects one skill, the
+// plugin must keep registering the rest instead of aborting the loop.
+const hostileId = added.length > 1 ? added[Math.floor(added.length / 2)].id : null;
+const survived = [];
+let setupThrew = null;
+let survivingContextHook;
+try {
+  await mod.default.setup(makeCtx({
+    add: (skill) => {
+      if (skill.id === hostileId) throw new Error('Simulated Skill.Info decode failure');
+      survived.push(skill.id);
+    },
+    onHook: (name, callback) => {
+      if (name === 'context') survivingContextHook = callback;
+    },
+  }));
+} catch (err) {
+  setupThrew = err;
+}
+if (setupThrew) {
+  failures.push(`expected setup() to contain draft.add() failures, but it threw: ${setupThrew.message}`);
+} else if (hostileId) {
+  const expectedSurvivors = added.map((s) => s.id).filter((id) => id !== hostileId);
+  if (JSON.stringify(survived.sort()) !== JSON.stringify(expectedSurvivors.sort())) {
+    failures.push(`expected all non-rejected skills to still register when one draft.add() throws, got ${JSON.stringify(survived)}`);
+  }
+}
+if (typeof survivingContextHook !== 'function') {
+  failures.push('expected bootstrap hook to survive a rejected skill');
+} else {
+  const event = {
+    sessionID: 'registration-survival-root',
+    messages: [{ role: 'user', content: [{ type: 'text', text: 'Continue' }] }],
+  };
+  await survivingContextHook(event);
+  const count = event.messages.flatMap((message) => message.content).filter(
+    (part) => part.type === 'text' && part.text.startsWith('<EXTREMELY_IMPORTANT>\nYou have superpowers.')
+  ).length;
+  if (count !== 1) failures.push(`expected surviving bootstrap once, got ${count}`);
+}
+
+// --- Run 3: quoted and multi-line frontmatter values ---------------------
+// The description is what the host shows in its skill list. A quoted value
+// that wraps onto indented continuation lines must register as one unquoted
+// line, so exercise each layout against a synthetic install: a copy of the
+// plugin next to fixture skills, laid out like a real package root.
+const frontmatterFixtures = {
+  'multi-line-double': {
+    frontmatter: 'description: "Use when foo happens\n  and bar continues\n  and baz ends"',
+    expected: 'Use when foo happens and bar continues and baz ends',
+  },
+  'multi-line-single': {
+    frontmatter: "description: 'Use when foo happens\n  and bar continues\n  and baz ends'",
+    expected: 'Use when foo happens and bar continues and baz ends',
+  },
+  'single-line-quoted': {
+    frontmatter: 'description: "Plain quoted"',
+    expected: 'Plain quoted',
+  },
+  'block-scalar': {
+    frontmatter: 'description: >\n  Folded line one\n  line two',
+    expected: 'Folded line one line two',
+  },
+};
+const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'superpowers-frontmatter-'));
+try {
+  const fixturePlugin = path.join(fixtureRoot, '.opencode', 'plugins', 'superpowers.js');
+  fs.mkdirSync(path.dirname(fixturePlugin), { recursive: true });
+  fs.copyFileSync(pluginPath, fixturePlugin);
+  for (const [id, { frontmatter }] of Object.entries(frontmatterFixtures)) {
+    const skillDir = path.join(fixtureRoot, 'skills', id);
+    fs.mkdirSync(skillDir, { recursive: true });
+    fs.writeFileSync(path.join(skillDir, 'SKILL.md'), `---\nname: ${id}\n${frontmatter}\n---\n# Title\n\nBody.\n`);
+  }
+  const fixtureMod = await import(pathToFileURL(fixturePlugin).href);
+  const fixtureAdded = [];
+  await fixtureMod.default.setup(makeCtx({ add: (skill) => fixtureAdded.push(skill) }));
+  for (const [id, { expected }] of Object.entries(frontmatterFixtures)) {
+    const skill = fixtureAdded.find((s) => s.id === id);
+    if (!skill) {
+      failures.push(`fixture "${id}": expected setup() to register it`);
+      continue;
+    }
+    if (skill.description !== expected) {
+      failures.push(`fixture "${id}": expected description ${JSON.stringify(expected)}, got ${JSON.stringify(skill.description)}`);
+    }
+    if (skill.content.startsWith('---')) {
+      failures.push(`fixture "${id}": content still starts with the frontmatter delimiter`);
+    }
+  }
+} finally {
+  fs.rmSync(fixtureRoot, { recursive: true, force: true });
+}
+
+const result = {
+  registered: added.length,
+  ids: added.map((s) => s.id),
+  allPathsValid: added.every((s) => s.path === path.join(skillsDir, s.id, 'SKILL.md') && fs.existsSync(s.path)),
+  staleLocationField: added.some((s) => 'location' in s),
+  hostileRejectedId: hostileId,
+  survivedHostileAdd: JSON.stringify(survived.sort()) === JSON.stringify(added.map((s) => s.id).filter((id) => id !== hostileId).sort()),
+};
+
+if (failures.length > 0) {
+  console.error(JSON.stringify(result, null, 2));
+  for (const failure of failures) {
+    console.error(`FAIL: ${failure}`);
+  }
+  process.exit(1);
+}
+
+console.log(JSON.stringify(result, null, 2));
+
+function makeCtx({ add, onHook = () => {} }) {
+  return {
+    skill: {
+      transform: async (fn) => {
+        await fn({ list: () => [], get: () => undefined, add, update: () => {}, remove: () => {} });
+      },
+    },
+    session: {
+      hook: async (name, callback) => onHook(name, callback),
+      get: async ({ sessionID }) => ({ id: sessionID }), // top-level: no parentID
+    },
+  };
+}

+ 22 - 0
tests/opencode/test-skill-registration.sh

@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Test: V2 Skill Registration Contract (#2106 review)
+# Verifies setup() registers skills matching OpenCode 2.0.4's Skill.Info
+# schema (path field, no stale location/slash) and contains per-skill
+# draft.add() failures instead of aborting registration.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "=== Test: V2 Skill Registration Contract ==="
+
+source "$SCRIPT_DIR/setup.sh"
+trap cleanup_test_env EXIT
+
+node "$SCRIPT_DIR/test-skill-registration.mjs" "$SUPERPOWERS_PLUGIN_FILE"
+node "$SCRIPT_DIR/test-skill-registration.mjs" "$OPENCODE_CONFIG_DIR/plugins/superpowers.js"
+
+echo "  [PASS] Skill payloads match the 2.0.4 Skill.Info contract"
+echo "  [PASS] A rejected draft.add() skips one skill without aborting the rest"
+echo "  [PASS] Quoted and multi-line frontmatter values register unquoted"
+echo ""
+echo "=== All skill registration tests passed ==="