Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Add native Oh My Pi (OMP) extension
  • Loading branch information
andrebrait committed Sep 26, 2026
commit 6194c2e60c71c3f456217eaad8f1f42d453de261
97 changes: 97 additions & 0 deletions .omp/extensions/superpowers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { readFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { ContextEvent, ExtensionAPI } from "@oh-my-pi/pi-coding-agent";

type Bootstrap = ContextEvent["messages"][number] & { superpowersBootstrap: true };

const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const skillsDir = resolve(packageRoot, "skills");
const bootstrapSkillPath = resolve(skillsDir, "using-superpowers", "SKILL.md");

// OMP discovers the bundled skills from the package root's skills/ directory.
// This extension only delivers the using-superpowers bootstrap.
export default function superpowersOmpExtension(omp: ExtensionAPI) {
let bootstrap: Bootstrap | undefined;
let generation = 0;
// The warning is persisted in the session; report a lasting failure once per session.
let warned = false;
const reset = () => {
generation++;
bootstrap = undefined;
};
const resetSession = () => {
reset();
warned = false;
};
omp.on("session_start", resetSession);
omp.on("session_switch", resetSession);
omp.on("session_branch", resetSession);
omp.on("session_tree", resetSession);
omp.on("session_shutdown", resetSession);

// Reload once per user run so an updated installation applies to the next run.
omp.on("before_agent_start", async () => {
reset();
const current = generation;
let text: string;
try {
text = bootstrapText(await readFile(bootstrapSkillPath, "utf8"));
} catch (error) {
if (current !== generation || warned) return;
warned = true;
return {
message: {
customType: "superpowers-bootstrap-error",
content: `Superpowers bootstrap unavailable: ${error instanceof Error ? error.message : String(error)}`,
display: true,
},
};
}
if (current !== generation) return;
warned = false;
bootstrap = {
role: "user",
content: [{ type: "text", text }],
timestamp: Date.now(),
superpowersBootstrap: true,
};
});

// The bootstrap is added to every provider request at the same position, so
// it is never persisted in the session, survives compaction and branch
// summaries, and keeps the request prefix byte-stable for provider prompt
// caching. Ownership is the message property, not its text, so quoting the
// bootstrap cannot suppress it or remove a real user message.
omp.on("context", (event) => {
const messages = event.messages.filter(
(message) => !("superpowersBootstrap" in message && message.superpowersBootstrap === true),
);
if (bootstrap) {
let index = 0;
while (messages[index]?.role === "compactionSummary" || messages[index]?.role === "branchSummary") index++;
messages.splice(index, 0, bootstrap);
}
if (bootstrap || messages.length !== event.messages.length) return { messages };
});
}

function bootstrapText(skill: string): string {
const body = skill.replace(/^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/, "").trim();
if (!body) throw new Error(`${bootstrapSkillPath} has no content`);
return `<EXTREMELY_IMPORTANT>
You have superpowers.

The using-superpowers skill content is included below and is already loaded for this Oh My Pi (OMP) session. Follow it now. Do not try to load using-superpowers again.

${body}

## OMP tool mapping

OMP loads Superpowers skills natively. When a Superpowers instruction says to invoke a skill, \`read\` \`skill://<name>\` instead of Claude Code's \`Skill\` tool.

- Subagents: use OMP's \`task\` tool wherever Superpowers says \`Task\` or asks you to dispatch a subagent.
- Task tracking: use OMP's \`todo\` tool wherever Superpowers says \`TodoWrite\` or asks for a todo list.
- Files and search: use \`read\`, \`write\`, \`edit\`, \`bash\`, \`grep\`, and \`glob\`.
</EXTREMELY_IMPORTANT>`;
}
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Superpowers is a complete software development methodology for your coding agent
- [GitHub Copilot CLI](#github-copilot-cli)
- [Grok Build CLI](#grok-build-cli)
- [Kimi Code](#kimi-code)
- [Oh My Pi (OMP)](#oh-my-pi-omp)
- [OpenCode](#opencode)
- [Pi](#pi)
- [Qwen Code](#qwen-code)
Expand Down Expand Up @@ -220,6 +221,22 @@ Superpowers is available in Kimi Code's plugin marketplace.

- Detailed docs: [docs/README.kimi.md](docs/README.kimi.md)

### Oh My Pi (OMP)

Install Superpowers as an OMP plugin package from this repository:

```bash
omp plugin install github:obra/superpowers
```

For local development, link this checkout instead:

```bash
omp plugin link /path/to/superpowers
```

OMP discovers the bundled skills natively. The package's `omp` entry loads a small extension that adds the `using-superpowers` bootstrap, with OMP's `task` and `todo` tool mappings, to every model request, including after compaction. OMP uses this entry instead of the Pi extension.

### OpenCode

OpenCode uses its own plugin install; install Superpowers separately even if you
Expand Down
1 change: 1 addition & 0 deletions docs/porting-to-a-new-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,7 @@ Use this as the live index; when in doubt, read the files, not this table.
| 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` (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 |
| Oh My Pi (OMP) | `.omp/extensions/superpowers.ts` (root `package.json` `omp` field; OMP prefers it over `pi`) | in-process: package-root `skills/` discovered natively; `before_agent_start` reloads the bootstrap each run; `context` adds one owned user message to every request, after compaction and branch summaries | inline in `superpowers.ts` | `tests/omp/` | `omp plugin install` git spec or `omp plugin link` |

## Appendix B — Gotchas that have bitten porters

Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
"collaboration",
"workflow"
],
"omp": {
"extensions": [
"./.omp/extensions/superpowers.ts"
]
},
"pi": {
"extensions": [
"./.pi/extensions/superpowers.ts"
Expand Down
183 changes: 183 additions & 0 deletions tests/omp/test-omp-extension.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import test from 'node:test';

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '../..');
const extensionSource = resolve(repoRoot, '.omp/extensions/superpowers.ts');
const roots = [];

test.afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

// Installs the real extension file into a throwaway package root, so each test
// controls the bundled using-superpowers skill the extension reads.
function install(skill) {
const root = mkdtempSync(join(tmpdir(), 'superpowers-omp-'));
roots.push(root);
mkdirSync(join(root, '.omp', 'extensions'), { recursive: true });
copyFileSync(extensionSource, join(root, '.omp', 'extensions', 'superpowers.ts'));
mkdirSync(join(root, 'skills', 'using-superpowers'), { recursive: true });
if (skill !== undefined) writeSkill(root, skill);
return root;
}

function writeSkill(root, body) {
writeFileSync(join(root, 'skills', 'using-superpowers', 'SKILL.md'), body);
}

async function load(root) {
const handlers = new Map();
const omp = {
on(event, handler) {
assert.equal(handlers.has(event), false, `duplicate ${event} handler`);
handlers.set(event, handler);
},
};
const mod = await import(pathToFileURL(join(root, '.omp', 'extensions', 'superpowers.ts')).href);
mod.default(omp);
const ctx = { cwd: root, hasUI: false };
const request = [{ role: 'user', content: 'Let\'s make a react todo list', timestamp: 1 }];
return {
handlers,
request,
async run(systemPrompt = ['Base system prompt.']) {
return handlers.get('before_agent_start')({ type: 'before_agent_start', prompt: 'go', systemPrompt }, ctx);
},
async context(messages = request) {
const result = await handlers.get('context')({ type: 'context', messages }, ctx);
return result?.messages ?? messages;
},
async emit(type) {
await handlers.get(type)?.({ type }, ctx);
},
};
}

const text = (message) => JSON.stringify(message.content);

test('package.json declares the native OMP entry and keeps the Pi entry', () => {
const pkg = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8'));
assert.deepEqual(pkg.omp, { extensions: ['./.omp/extensions/superpowers.ts'] });
assert.deepEqual(pkg.pi.extensions, ['./.pi/extensions/superpowers.ts']);
});

test('delivers the bootstrap as one leading user message, never in the system prompt', async () => {
const omp = await load(install('\uFEFF---\r\nname: frontmatter-only\r\n---\r\n\r\n# Using Superpowers\r\nInvoke skills first.\r\n'));
assert.equal(await omp.run(), undefined, 'system prompt and history stay untouched');

const messages = await omp.context();
assert.equal(messages.length, 2);
assert.equal(messages[0].role, 'user');
assert.match(text(messages[0]), /You have superpowers/);
assert.match(text(messages[0]), /Invoke skills first\./);
assert.match(text(messages[0]), /OMP tool mapping/);
assert.doesNotMatch(text(messages[0]), /frontmatter-only/);
assert.equal(messages[1], omp.request[0]);
assert.equal(omp.request.length, 1, 'the caller\'s array is not mutated');
assert.deepEqual(await omp.context(structuredClone(messages)), messages, 'a re-sent view is not duplicated');
});

// The shared Pi extension clears its flag at agent_end, so on OMP only the first
// run of a session saw the bootstrap. Every provider request must carry it.
test('keeps the bootstrap on later runs and after compaction', async () => {
const omp = await load(install('Policy stays available.'));
await omp.run();
await omp.context();
await omp.emit('agent_end');
// Compaction retries and queued follow-ups can reach the provider without a
// new before_agent_start.
assert.match(text((await omp.context())[0]), /Policy stays available\./);
await omp.run();
assert.match(text((await omp.context())[0]), /Policy stays available\./);

await omp.emit('session_compact');
const summary = { role: 'compactionSummary', summary: 'Earlier work', tokensBefore: 42, timestamp: 2 };
const compacted = await omp.context([summary, ...omp.request]);
assert.equal(compacted.length, 3);
assert.equal(compacted[0], summary, 'the bootstrap follows compaction summaries');
assert.match(text(compacted[1]), /Policy stays available\./);
assert.equal(compacted[2], omp.request[0]);
});

test('keeps the bootstrap after a branch summary', async () => {
const omp = await load(install('Branch policy.'));
await omp.run();
const summary = { role: 'branchSummary', summary: 'Abandoned branch', fromId: 'x', timestamp: 2 };
const messages = await omp.context([summary, ...omp.request]);
assert.equal(messages[0], summary);
assert.match(text(messages[1]), /Branch policy\./);
});

test('refreshes an updated skill at the next run, not mid-run', async () => {
const root = install('Original policy.');
const omp = await load(root);
await omp.run();
writeSkill(root, 'Updated policy.');
assert.match(text((await omp.context())[0]), /Original policy\./);
await omp.run();
const messages = await omp.context();
assert.equal(messages.length, 2);
assert.match(text(messages[0]), /Updated policy\./);
});

test('a missing skill warns visibly, injects nothing, and recovers after repair', async () => {
const root = install('Installed policy.');
const omp = await load(root);
await omp.run();
const previous = await omp.context();
rmSync(join(root, 'skills', 'using-superpowers', 'SKILL.md'));

const missing = await omp.run();
assert.equal(missing.message.display, true);
assert.match(missing.message.content, /SKILL\.md/);
assert.equal(missing.systemPrompt, undefined);
assert.deepEqual(await omp.context(previous), omp.request, 'stale policy is removed');
// The warning is persisted in the session, so a lasting failure reports once.
assert.equal(await omp.run(), undefined);

writeSkill(root, 'Repaired policy.');
assert.equal(await omp.run(), undefined);
assert.match(text((await omp.context())[0]), /Repaired policy\./);
rmSync(join(root, 'skills', 'using-superpowers', 'SKILL.md'));
assert.match((await omp.run()).message.content, /SKILL\.md/, 'a new failure after recovery warns again');
await omp.emit('session_switch');
assert.match((await omp.run()).message.content, /SKILL\.md/, 'another session is warned too');
});

test('a skill without a body warns instead of injecting an empty bootstrap', async () => {
const omp = await load(install('---\nname: using-superpowers\n---\n'));
assert.match((await omp.run()).message.content, /no content/);
assert.deepEqual(await omp.context(), omp.request);
});

test('quoted bootstrap text neither suppresses the bootstrap nor removes user messages', async () => {
const omp = await load(install('Real policy.'));
const quoted = [{ role: 'user', content: 'You have superpowers. <EXTREMELY_IMPORTANT>', timestamp: 1 }];
await omp.run();
const messages = await omp.context(quoted);
assert.equal(messages.length, 2);
assert.match(text(messages[0]), /Real policy\./);
assert.equal(messages[1], quoted[0]);
});

test('session changes drop the old bootstrap and ignore a load that finishes late', async () => {
const root = install('Old session policy.');
const omp = await load(root);
await omp.run();
const old = await omp.context();
for (const event of ['session_switch', 'session_branch', 'session_tree', 'session_shutdown', 'session_start']) {
await omp.run();
await omp.emit(event);
assert.deepEqual(await omp.context(old), omp.request, `${event} clears the bootstrap`);
}

const pending = omp.run();
await omp.emit('session_switch');
await pending;
assert.deepEqual(await omp.context(), omp.request, 'a load from the previous session is discarded');
});