superpowers.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. /**
  2. * Superpowers plugin for OpenCode.ai
  3. *
  4. * Dual-compatible with OpenCode V1 and V2.
  5. *
  6. * V1 (opencode): loaded via named export SuperpowersPlugin — provides config
  7. * hook for skills registration and experimental.chat.messages.transform for
  8. * bootstrap injection.
  9. *
  10. * V2 (opencode2): loaded via default export { id, setup } by PluginSupervisor.
  11. * setup() registers skills natively via ctx.skill.transform(), and injects
  12. * bootstrap context via ctx.session.hook("context").
  13. *
  14. * No external dependencies — pure JavaScript works in both V1 and V2 without
  15. * installing @opencode-ai/plugin or effect.
  16. */
  17. import path from 'path';
  18. import fs from 'fs';
  19. import { fileURLToPath } from 'url';
  20. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  21. // Skills directory shared by V1 (config hook) and V2 (setup/ctx.skill.transform)
  22. const superpowersSkillsDir = path.resolve(__dirname, '../../skills');
  23. // Simple frontmatter extraction (avoid dependency on skills-core for
  24. // bootstrap). Handles plain `key: value` lines, quoted values (including
  25. // quotes that close on an indented continuation line), YAML block scalar
  26. // markers (`>`, `|`) with indented continuation lines, and CRLF line
  27. // endings. Not a full YAML parser — nested maps flatten into their parent
  28. // key's value, which is fine for the name/description fields consumed here.
  29. const extractAndStripFrontmatter = (content) => {
  30. const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
  31. if (!match) return { frontmatter: {}, content };
  32. const frontmatterStr = match[1];
  33. const body = match[2];
  34. const frontmatter = {};
  35. let lastKey = null;
  36. for (const rawLine of frontmatterStr.split('\n')) {
  37. const line = rawLine.replace(/\r$/, '');
  38. const colonIdx = line.indexOf(':');
  39. if (colonIdx > 0 && !/^\s/.test(line)) {
  40. const key = line.slice(0, colonIdx).trim();
  41. const value = line.slice(colonIdx + 1).trim();
  42. // Block scalar markers (>, |, optionally with +/- chomping) carry no
  43. // value themselves; the indented lines that follow do.
  44. frontmatter[key] = /^(>[+-]?|\|[+-]?)$/.test(value) ? '' : value;
  45. lastKey = key;
  46. } else if (lastKey !== null && line.trim() !== '') {
  47. // Continuation of a multi-line value: append rather than drop so long
  48. // descriptions survive parsing. Newlines collapse to spaces — good
  49. // enough for the single-line name/description fields consumed here.
  50. frontmatter[lastKey] = `${frontmatter[lastKey]} ${line.trim()}`.trim();
  51. }
  52. }
  53. // A quoted value may close on a continuation line, so unquote only once
  54. // the value is fully assembled: strip exactly one matching surrounding
  55. // pair and leave unbalanced quotes alone.
  56. for (const key of Object.keys(frontmatter)) {
  57. frontmatter[key] = frontmatter[key].replace(/^(["'])([\s\S]*)\1$/, '$2');
  58. }
  59. return { frontmatter, content: body };
  60. };
  61. // Tool mapping injected into the bootstrap, differentiated by host flavor.
  62. // V1 (OpenCode 1.18.x) and V2 (OpenCode 2.0.4/2.0.7) expose different built-in
  63. // tools, so each flavor's injection path picks its own constant below.
  64. // Exported for tests (tests/opencode/test-bootstrap-caching.mjs).
  65. // V1 built-ins: todowrite, task (subagent_type), skill, read, apply_patch,
  66. // bash, grep, glob, webfetch.
  67. export const V1_MAPPING = `**Tool Mapping for OpenCode:**
  68. When skills request actions, substitute OpenCode equivalents:
  69. - Create or update todos → \`todowrite\`
  70. - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
  71. - Invoke a skill → OpenCode's native \`skill\` tool
  72. - Read files → \`read\`
  73. - Create, edit, or delete files → \`apply_patch\`
  74. - Run shell commands → \`bash\`
  75. - Search files → \`grep\`, \`glob\`
  76. - Fetch a URL → \`webfetch\`
  77. Use OpenCode's native \`skill\` tool to list and load skills.`;
  78. // V2 built-ins: no todo tool at all; task → subagent (agent name in 'agent',
  79. // continuation via sessionID); apply_patch → patch (patchText, same patch
  80. // format); bash → shell. read, write, edit, grep, glob, webfetch, websearch,
  81. // and skill all exist under those names (verified against the 2.0.4 and 2.0.7
  82. // host contracts).
  83. export const V2_MAPPING = `**Tool Mapping for OpenCode:**
  84. When skills request actions, substitute OpenCode equivalents:
  85. - Create or update todos → OpenCode v2 has no todo tool; track the plan in a markdown file (or the harness's plan facility) instead
  86. - \`Subagent (general-purpose):\` → \`subagent\` with \`agent: "general"\` (give it \`description\` and \`prompt\`, optionally \`background\`; pass \`sessionID\` to continue a previous subagent)
  87. - Invoke a skill → OpenCode's native \`skill\` tool
  88. - Read files → \`read\`
  89. - 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
  90. - Run shell commands → \`shell\` (\`command\`, \`workdir\`, \`timeout\`, \`background\`)
  91. - Search files → \`grep\`, \`glob\`
  92. - Fetch a URL → \`webfetch\`
  93. - Search the web → \`websearch\`
  94. Use OpenCode's native \`skill\` tool to list and load skills.`;
  95. // Module-level cache for bootstrap content, keyed by tool mapping (host
  96. // flavor). The SKILL.md file does not change during a session, so reading +
  97. // parsing it once eliminates redundant fs.existsSync + fs.readFileSync +
  98. // regex work on every agent step. See #1202 for the full analysis.
  99. const _bootstrapCache = new Map(); // mapping -> bootstrap (null = file missing)
  100. // Helper to generate bootstrap content (cached after first call per mapping)
  101. const getBootstrapContent = (toolMapping) => {
  102. // Return cached result on subsequent calls
  103. if (_bootstrapCache.has(toolMapping)) return _bootstrapCache.get(toolMapping);
  104. // Try to load using-superpowers skill
  105. const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
  106. if (!fs.existsSync(skillPath)) {
  107. _bootstrapCache.set(toolMapping, null);
  108. return null;
  109. }
  110. const fullContent = fs.readFileSync(skillPath, 'utf8');
  111. const { content } = extractAndStripFrontmatter(fullContent);
  112. _bootstrapCache.set(toolMapping, `<EXTREMELY_IMPORTANT>
  113. You have superpowers.
  114. **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.**
  115. ${content}
  116. ${toolMapping}
  117. </EXTREMELY_IMPORTANT>`);
  118. return _bootstrapCache.get(toolMapping);
  119. };
  120. // --- Task-subagent (child session) detection --------------------------------
  121. //
  122. // #2160: the bootstrap drives controller workflows (brainstorming, planning,
  123. // approval cycles). Injecting it into task subagent sessions makes workers
  124. // restart design/approval cycles for work the parent already authorised; the
  125. // <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
  126. // is not reliable. Detect child sessions structurally instead: a parentID on
  127. // the session is the child signal on both flavors (task sessions are created
  128. // with one; top-level sessions simply lack the field), so when the session
  129. // carrying the message has a parentID we skip bootstrap injection. Skills
  130. // stay registered for every session — workers keep explicit access to
  131. // execution skills.
  132. // sessionID -> is-child decision. parentID never changes for a session, so
  133. // the result is cached until eviction and the injection hook (which fires on
  134. // every agent step) pays only one client roundtrip per session. The V2
  135. // service process is long-lived and sessions accumulate over weeks, so the
  136. // cache is bounded: when full, drop the oldest quarter (Map iterates keys in
  137. // insertion order). An evicted session merely pays one extra lookup if seen
  138. // again.
  139. const CHILD_SESSION_CACHE_MAX = 512;
  140. const _childSessionCache = new Map();
  141. const _cacheChildSession = (sessionID, isChild) => {
  142. if (_childSessionCache.size >= CHILD_SESSION_CACHE_MAX) {
  143. let toDrop = Math.ceil(CHILD_SESSION_CACHE_MAX / 4);
  144. for (const key of _childSessionCache.keys()) {
  145. if (toDrop-- <= 0) break;
  146. _childSessionCache.delete(key);
  147. }
  148. }
  149. _childSessionCache.set(sessionID, isChild);
  150. };
  151. const isChildSession = async (fetchSession, sessionID) => {
  152. if (!sessionID) return false; // unknown session: keep current behavior
  153. if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);
  154. let isChild = false;
  155. try {
  156. const result = await fetchSession(sessionID);
  157. // V1 returns a successful SDK envelope while V2 returns a direct session
  158. // record. Validate both shapes before classifying or caching the result;
  159. // resolved SDK errors must follow the same fail-open path as rejections.
  160. if (!result || typeof result !== 'object' || Array.isArray(result)) {
  161. throw new Error('Session lookup returned no usable record');
  162. }
  163. if (result.error != null || result.response?.ok === false) {
  164. throw new Error('Session lookup was unsuccessful');
  165. }
  166. const session = 'data' in result ? result.data : result;
  167. if (!session || typeof session !== 'object' || Array.isArray(session) || session.id !== sessionID) {
  168. throw new Error('Session lookup returned an invalid session identity');
  169. }
  170. if (session.parentID !== undefined &&
  171. (typeof session.parentID !== 'string' || session.parentID.length === 0)) {
  172. throw new Error('Session lookup returned an invalid parent identity');
  173. }
  174. isChild = session.parentID !== undefined;
  175. } catch (err) {
  176. // Fail open: on lookup errors keep injecting (previous behavior) and do
  177. // not cache, so a transient failure can recover on the next step.
  178. console.error('[superpowers] session lookup failed, treating session as top-level:', err);
  179. return false;
  180. }
  181. _cacheChildSession(sessionID, isChild);
  182. return isChild;
  183. };
  184. /**
  185. * V1 Plugin Function (named export + default.server)
  186. *
  187. * Used by V1 (OpenCode 1.x): discovered via named export scanning.
  188. * Provides: config hook (V1 skills registration) + bootstrap injection
  189. * (experimental.chat.messages.transform).
  190. */
  191. export const SuperpowersPlugin = async ({ client, directory }) => {
  192. return {
  193. // Inject skills path into live config so OpenCode discovers superpowers skills
  194. // without requiring manual symlinks or config file edits.
  195. config: async (config) => {
  196. // V2: skills is a flat array — skip, setup() handles V2 skill registration
  197. if (Array.isArray(config.skills)) return;
  198. // V1: skills is { paths: [...] }
  199. config.skills = config.skills || {};
  200. config.skills.paths = config.skills.paths || [];
  201. if (!config.skills.paths.includes(superpowersSkillsDir)) {
  202. config.skills.paths.push(superpowersSkillsDir);
  203. }
  204. },
  205. // Inject bootstrap into the first user message of each top-level session.
  206. // Using a user message instead of a system message avoids:
  207. // 1. Token bloat from system messages repeated every turn (#750)
  208. // 2. Multiple system messages breaking Qwen and other models (#894)
  209. //
  210. // The hook fires on every agent step (not just every turn) because
  211. // opencode's prompt.ts reloads messages from DB each step. Fresh message
  212. // arrays may need injection again, so getBootstrapContent() must not do
  213. // repeated disk work.
  214. 'experimental.chat.messages.transform': async (_input, output) => {
  215. const bootstrap = getBootstrapContent(V1_MAPPING);
  216. if (!bootstrap || !output.messages.length) return;
  217. const firstUser = output.messages.find(m => m.info.role === 'user');
  218. if (!firstUser || !firstUser.parts.length) return;
  219. // Guard: skip if first user message already contains bootstrap.
  220. if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  221. // #2160: never restart the controller workflow inside task subagent
  222. // (child) sessions. V1 passes no input to this hook (verified in the
  223. // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID
  224. // from the message record itself.
  225. if (client && await isChildSession(
  226. (id) => client.session.get({ path: { id } }),
  227. firstUser.info.sessionID,
  228. )) return;
  229. const ref = firstUser.parts[0];
  230. firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
  231. }
  232. };
  233. };
  234. /**
  235. * V2 Setup Function (default.setup)
  236. *
  237. * Called by V2 PluginSupervisor (packages/core/src/plugin/supervisor.ts).
  238. * Performs two things:
  239. *
  240. * 1. Registers every skills/<name>/SKILL.md as a native Skill.Info object
  241. * via ctx.skill.transform((draft) => draft.add(info)).
  242. * V2 removed the old draft.source() directory registration; the draft API
  243. * is now { list, add, update, remove } where add() decodes plain objects
  244. * against the host's Skill.Info schema (OpenCode 2.0.4 contract):
  245. * { id, name, description?, autoinvoke?, path, content }. The file field
  246. * is `path` — renamed from `location` in upstream commit 199aabe9e2,
  247. * first released in v2.0.4.
  248. * See packages/core/src/plugin/skill.ts and packages/schema/src/skill.ts.
  249. * 2. Injects bootstrap context via ctx.session.hook("context"), the V2
  250. * equivalent of V1's experimental.chat.messages.transform.
  251. */
  252. async function setup(ctx) {
  253. // V1 (observed on opencode 1.18.18) also invokes default.setup, but with a
  254. // V1-shaped ctx that lacks the skill/session domains. Detect it and return
  255. // quietly — V1 is served entirely by the SuperpowersPlugin named export.
  256. if (!ctx || !ctx.skill || typeof ctx.skill.transform !== 'function' || !ctx.session || typeof ctx.session.hook !== 'function') {
  257. return;
  258. }
  259. // 1. Register skills (one transform; one draft.add per skill)
  260. try {
  261. const skills = [];
  262. if (fs.existsSync(superpowersSkillsDir)) {
  263. for (const entry of fs.readdirSync(superpowersSkillsDir, { withFileTypes: true })) {
  264. if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
  265. const skillPath = path.join(superpowersSkillsDir, entry.name, 'SKILL.md');
  266. if (!fs.existsSync(skillPath)) continue;
  267. const { frontmatter, content } = extractAndStripFrontmatter(fs.readFileSync(skillPath, 'utf8'));
  268. skills.push({
  269. id: entry.name,
  270. name: frontmatter.name || entry.name,
  271. ...(frontmatter.description ? { description: frontmatter.description } : {}),
  272. // Skill.Info renamed its required file field `location` -> `path`
  273. // in OpenCode v2.0.4 (upstream commit 199aabe9e2).
  274. path: skillPath,
  275. content,
  276. });
  277. }
  278. }
  279. await ctx.skill.transform((draft) => {
  280. // draft.add() decodes against the host's Skill.Info schema and throws
  281. // synchronously on a mismatch. A throw escaping this callback is what
  282. // the host escalates into an asynchronous hard-disable of the entire
  283. // plugin ("Plugin disabled after skill.transform failed") — the
  284. // try/catch around ctx.skill.transform never sees it, and the
  285. // bootstrap hook is torn down as collateral. Contain failures per
  286. // skill so one rejected payload skips that skill instead of killing
  287. // skills AND bootstrap.
  288. for (const skill of skills) {
  289. try {
  290. draft.add(skill);
  291. } catch (err) {
  292. console.error(`[superpowers] skill "${skill.id}" rejected by host, skipping:`, err);
  293. }
  294. }
  295. });
  296. } catch (err) {
  297. // Never break plugin activation: one failing plugin takes down the whole
  298. // V2 generation (including provider/catalog plugins => no models in TUI).
  299. console.error('[superpowers] skill registration failed:', err);
  300. }
  301. // 2. Inject bootstrap into first user message via V2 session context hook
  302. try {
  303. await ctx.session.hook('context', async (event) => {
  304. try {
  305. const bootstrap = getBootstrapContent(V2_MAPPING);
  306. if (!bootstrap || !event.messages || !event.messages.length) return;
  307. const firstUser = event.messages.find(m => m.role === 'user');
  308. if (firstUser && (!firstUser.content || !firstUser.content.length)) return;
  309. if (firstUser?.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  310. // #2160: the context event carries the sessionID directly. Skip the
  311. // controller bootstrap when this prompt belongs to a task subagent
  312. // (child) session. Skills registered above stay available to workers.
  313. if (typeof ctx.session.get === 'function' && await isChildSession(
  314. (id) => ctx.session.get({ sessionID: id }),
  315. event.sessionID,
  316. )) return;
  317. // Native compaction can leave only an opaque checkpoint. Keep it
  318. // intact and append the transient bootstrap as a user message.
  319. if (firstUser) {
  320. firstUser.content.unshift({ type: 'text', text: bootstrap });
  321. } else {
  322. event.messages.push({ role: 'user', content: [{ type: 'text', text: bootstrap }] });
  323. }
  324. } catch (err) {
  325. // Never let hook callback errors break the request pipeline.
  326. console.error('[superpowers] context hook failed:', err);
  327. }
  328. });
  329. } catch (err) {
  330. console.error('[superpowers] session hook registration failed:', err);
  331. }
  332. }
  333. /**
  334. * Default Export: { id, server, setup }
  335. *
  336. * V2 PluginSupervisor reads { id, setup }.
  337. * V1 reads named export SuperpowersPlugin.
  338. * server() is exported for V1 compatibility.
  339. */
  340. export default {
  341. id: 'superpowers',
  342. server: SuperpowersPlugin,
  343. setup,
  344. };