superpowers.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  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 bootstrap)
  24. const extractAndStripFrontmatter = (content) => {
  25. const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
  26. if (!match) return { frontmatter: {}, content };
  27. const frontmatterStr = match[1];
  28. const body = match[2];
  29. const frontmatter = {};
  30. for (const line of frontmatterStr.split('\n')) {
  31. const colonIdx = line.indexOf(':');
  32. if (colonIdx > 0) {
  33. const key = line.slice(0, colonIdx).trim();
  34. const value = line.slice(colonIdx + 1).trim().replace(/^["']|["']$/g, '');
  35. frontmatter[key] = value;
  36. }
  37. }
  38. return { frontmatter, content: body };
  39. };
  40. // Tool mapping injected into the bootstrap, differentiated by host flavor.
  41. // V1 (OpenCode 1.18.x) and V2 (OpenCode 2.x beta) expose different built-in
  42. // tools, so each flavor's injection path picks its own constant below.
  43. // Exported for tests (tests/opencode/test-bootstrap-caching.mjs).
  44. // V1 built-ins: todowrite, task (subagent_type), skill, read, apply_patch,
  45. // bash, grep, glob, webfetch.
  46. export const V1_MAPPING = `**Tool Mapping for OpenCode:**
  47. When skills request actions, substitute OpenCode equivalents:
  48. - Create or update todos → \`todowrite\`
  49. - \`Subagent (general-purpose):\` → \`task\` with \`subagent_type: "general"\`
  50. - Invoke a skill → OpenCode's native \`skill\` tool
  51. - Read files → \`read\`
  52. - Create, edit, or delete files → \`apply_patch\`
  53. - Run shell commands → \`bash\`
  54. - Search files → \`grep\`, \`glob\`
  55. - Fetch a URL → \`webfetch\`
  56. Use OpenCode's native \`skill\` tool to list and load skills.`;
  57. // V2 built-ins: no todo tool at all; task → subagent (agent name in 'agent',
  58. // continuation via sessionID); apply_patch → patch (patchText, same patch
  59. // format); bash → shell. read, grep, glob, webfetch, skill keep their names.
  60. export const V2_MAPPING = `**Tool Mapping for OpenCode:**
  61. When skills request actions, substitute OpenCode equivalents:
  62. - Create or update todos → OpenCode v2 has no todo tool; track the plan in a markdown file (or the harness's plan facility) instead
  63. - \`Subagent (general-purpose):\` → \`subagent\` with \`agent: "general"\` (give it \`description\` and \`prompt\`, optionally \`background\`; pass \`sessionID\` to continue a previous subagent)
  64. - Invoke a skill → OpenCode's native \`skill\` tool
  65. - Read files → \`read\`
  66. - Create, edit, or delete files → \`patch\` with \`patchText\` (same patch format)
  67. - Run shell commands → \`shell\` (\`command\`, \`workdir\`, \`timeout\`, \`background\`)
  68. - Search files → \`grep\`, \`glob\`
  69. - Fetch a URL → \`webfetch\`
  70. Use OpenCode's native \`skill\` tool to list and load skills.`;
  71. // Module-level cache for bootstrap content, keyed by tool mapping (host
  72. // flavor). The SKILL.md file does not change during a session, so reading +
  73. // parsing it once eliminates redundant fs.existsSync + fs.readFileSync +
  74. // regex work on every agent step. See #1202 for the full analysis.
  75. const _bootstrapCache = new Map(); // mapping -> bootstrap (null = file missing)
  76. // Helper to generate bootstrap content (cached after first call per mapping)
  77. const getBootstrapContent = (toolMapping) => {
  78. // Return cached result on subsequent calls
  79. if (_bootstrapCache.has(toolMapping)) return _bootstrapCache.get(toolMapping);
  80. // Try to load using-superpowers skill
  81. const skillPath = path.join(superpowersSkillsDir, 'using-superpowers', 'SKILL.md');
  82. if (!fs.existsSync(skillPath)) {
  83. _bootstrapCache.set(toolMapping, null);
  84. return null;
  85. }
  86. const fullContent = fs.readFileSync(skillPath, 'utf8');
  87. const { content } = extractAndStripFrontmatter(fullContent);
  88. _bootstrapCache.set(toolMapping, `<EXTREMELY_IMPORTANT>
  89. You have superpowers.
  90. **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.**
  91. ${content}
  92. ${toolMapping}
  93. </EXTREMELY_IMPORTANT>`);
  94. return _bootstrapCache.get(toolMapping);
  95. };
  96. // --- Task-subagent (child session) detection --------------------------------
  97. //
  98. // #2160: the bootstrap drives controller workflows (brainstorming, planning,
  99. // approval cycles). Injecting it into task subagent sessions makes workers
  100. // restart design/approval cycles for work the parent already authorised; the
  101. // <SUBAGENT-STOP> note inside the bootstrap relies on model compliance, which
  102. // is not reliable. Detect child sessions structurally instead: a parentID on
  103. // the session is the child signal on both flavors (task sessions are created
  104. // with one; top-level sessions simply lack the field), so when the session
  105. // carrying the message has a parentID we skip bootstrap injection. Skills
  106. // stay registered for every session — workers keep explicit access to
  107. // execution skills.
  108. // sessionID -> is-child decision. parentID never changes for a session, so
  109. // the result is cached for life and the injection hook (which fires on every
  110. // agent step) pays only one client roundtrip per session.
  111. const _childSessionCache = new Map();
  112. const isChildSession = async (fetchSession, sessionID) => {
  113. if (!sessionID) return false; // unknown session: keep current behavior
  114. if (_childSessionCache.has(sessionID)) return _childSessionCache.get(sessionID);
  115. let isChild = false;
  116. try {
  117. const result = await fetchSession(sessionID);
  118. // Defensive dual-shape unwrap: fetchers may return the session record
  119. // itself (V2 ctx) or an SDK envelope { data: Session } (V1 client). An
  120. // envelope never carries parentID at the top level, so if `result` has
  121. // one it already IS the session record — never unwrap past it.
  122. const session = result && typeof result === 'object' && result.data && typeof result.data === 'object' && !('parentID' in result)
  123. ? result.data
  124. : result;
  125. // parentID presence is the child-session signal on both flavors.
  126. isChild = Boolean(session && typeof session === 'object' && session.parentID);
  127. } catch (err) {
  128. // Fail open: on lookup errors keep injecting (previous behavior) and do
  129. // not cache, so a transient failure can recover on the next step.
  130. console.error('[superpowers] session lookup failed, treating session as top-level:', err);
  131. return false;
  132. }
  133. _childSessionCache.set(sessionID, isChild);
  134. return isChild;
  135. };
  136. /**
  137. * V1 Plugin Function (named export + default.server)
  138. *
  139. * Used by V1 (OpenCode 1.x): discovered via named export scanning.
  140. * Provides: config hook (V1 skills registration) + bootstrap injection
  141. * (experimental.chat.messages.transform).
  142. */
  143. export const SuperpowersPlugin = async ({ client, directory }) => {
  144. return {
  145. // Inject skills path into live config so OpenCode discovers superpowers skills
  146. // without requiring manual symlinks or config file edits.
  147. config: async (config) => {
  148. // V2: skills is a flat array — skip, setup() handles V2 skill registration
  149. if (Array.isArray(config.skills)) return;
  150. // V1: skills is { paths: [...] }
  151. config.skills = config.skills || {};
  152. config.skills.paths = config.skills.paths || [];
  153. if (!config.skills.paths.includes(superpowersSkillsDir)) {
  154. config.skills.paths.push(superpowersSkillsDir);
  155. }
  156. },
  157. // Inject bootstrap into the first user message of each top-level session.
  158. // Using a user message instead of a system message avoids:
  159. // 1. Token bloat from system messages repeated every turn (#750)
  160. // 2. Multiple system messages breaking Qwen and other models (#894)
  161. //
  162. // The hook fires on every agent step (not just every turn) because
  163. // opencode's prompt.ts reloads messages from DB each step. Fresh message
  164. // arrays may need injection again, so getBootstrapContent() must not do
  165. // repeated disk work.
  166. 'experimental.chat.messages.transform': async (_input, output) => {
  167. const bootstrap = getBootstrapContent(V1_MAPPING);
  168. if (!bootstrap || !output.messages.length) return;
  169. const firstUser = output.messages.find(m => m.info.role === 'user');
  170. if (!firstUser || !firstUser.parts.length) return;
  171. // Guard: skip if first user message already contains bootstrap.
  172. if (firstUser.parts.some(p => p.type === 'text' && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  173. // #2160: never restart the controller workflow inside task subagent
  174. // (child) sessions. V1 passes no input to this hook (verified in the
  175. // 1.18.x bundle: trigger(..., {}, {messages})), so take the sessionID
  176. // from the message record itself.
  177. if (client && await isChildSession(
  178. (id) => client.session.get({ path: { id } }),
  179. firstUser.info.sessionID,
  180. )) return;
  181. const ref = firstUser.parts[0];
  182. firstUser.parts.unshift({ ...ref, type: 'text', text: bootstrap });
  183. }
  184. };
  185. };
  186. /**
  187. * V2 Setup Function (default.setup)
  188. *
  189. * Called by V2 PluginSupervisor (packages/core/src/plugin/supervisor.ts).
  190. * Performs two things:
  191. *
  192. * 1. Registers every skills/<name>/SKILL.md as a native Skill.Info object
  193. * via ctx.skill.transform((draft) => draft.add(info)).
  194. * V2 removed the old draft.source() directory registration; the draft API
  195. * is now { list, add, update, remove } where add() decodes plain objects:
  196. * { id, name, description?, slash?, autoinvoke?, location, content }.
  197. * See packages/core/src/plugin/skill.ts and packages/schema/src/skill.ts.
  198. * 2. Injects bootstrap context via ctx.session.hook("context"), the V2
  199. * equivalent of V1's experimental.chat.messages.transform.
  200. */
  201. async function setup(ctx) {
  202. // V1 (observed on opencode 1.18.18) also invokes default.setup, but with a
  203. // V1-shaped ctx that lacks the skill/session domains. Detect it and return
  204. // quietly — V1 is served entirely by the SuperpowersPlugin named export.
  205. if (!ctx || !ctx.skill || typeof ctx.skill.transform !== 'function' || !ctx.session || typeof ctx.session.hook !== 'function') {
  206. return;
  207. }
  208. // 1. Register skills (one transform; one draft.add per skill)
  209. try {
  210. const skills = [];
  211. if (fs.existsSync(superpowersSkillsDir)) {
  212. for (const entry of fs.readdirSync(superpowersSkillsDir, { withFileTypes: true })) {
  213. if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
  214. const skillPath = path.join(superpowersSkillsDir, entry.name, 'SKILL.md');
  215. if (!fs.existsSync(skillPath)) continue;
  216. const { frontmatter, content } = extractAndStripFrontmatter(fs.readFileSync(skillPath, 'utf8'));
  217. skills.push({
  218. id: entry.name,
  219. name: frontmatter.name || entry.name,
  220. ...(frontmatter.description ? { description: frontmatter.description } : {}),
  221. location: skillPath,
  222. content,
  223. });
  224. }
  225. }
  226. await ctx.skill.transform((draft) => {
  227. for (const skill of skills) draft.add(skill);
  228. });
  229. } catch (err) {
  230. // Never break plugin activation: one failing plugin takes down the whole
  231. // V2 generation (including provider/catalog plugins => no models in TUI).
  232. console.error('[superpowers] skill registration failed:', err);
  233. }
  234. // 2. Inject bootstrap into first user message via V2 session context hook
  235. try {
  236. await ctx.session.hook('context', async (event) => {
  237. try {
  238. const bootstrap = getBootstrapContent(V2_MAPPING);
  239. if (!bootstrap || !event.messages || !event.messages.length) return;
  240. const firstUser = event.messages.find(m => m.role === 'user');
  241. if (!firstUser || !firstUser.content || !firstUser.content.length) return;
  242. if (firstUser.content.some(p => p.type === 'text' && p.text && p.text.includes('EXTREMELY_IMPORTANT'))) return;
  243. // #2160: the context event carries the sessionID directly. Skip the
  244. // controller bootstrap when this prompt belongs to a task subagent
  245. // (child) session. Skills registered above stay available to workers.
  246. if (typeof ctx.session.get === 'function' && await isChildSession(
  247. (id) => ctx.session.get({ sessionID: id }),
  248. event.sessionID,
  249. )) return;
  250. firstUser.content.unshift({ type: 'text', text: bootstrap });
  251. } catch (err) {
  252. // Never let hook callback errors break the request pipeline.
  253. console.error('[superpowers] context hook failed:', err);
  254. }
  255. });
  256. } catch (err) {
  257. console.error('[superpowers] session hook registration failed:', err);
  258. }
  259. }
  260. /**
  261. * Default Export: { id, server, setup }
  262. *
  263. * V2 PluginSupervisor reads { id, setup }.
  264. * V1 reads named export SuperpowersPlugin.
  265. * server() is exported for V1 compatibility.
  266. */
  267. export default {
  268. id: 'superpowers',
  269. server: SuperpowersPlugin,
  270. setup,
  271. };