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
211 changes: 211 additions & 0 deletions .omp/extensions/omac-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* omac oh-my-pi (omp) bridge extension
* ====================================
*
* Bridges omp (running as `omp`, wrapped by `omac start omp`) to the omac
* control plane so that each directory a session opens gets its skills
* brought online lazily, and the skills manifest + sandbox briefing are
* injected into the system prompt.
*
* omp is a fork of Pi with a diverged extension surface;
* the three differences that matter here are called out inline.
*
* 1. Activate on session start — POST /__omac__/activate {dir}
* 2. Surface skills to the agent — inject manifest + briefing via
* before_agent_start
* 3. Expose per-skill base URLs — OMAC_<MOUNT>_BASE / OMAC_G_<MOUNT>_BASE
* (already in process env from omac launch)
*
* Degradation: if OMAC_CONTROL_BASE is unset (omp not running under omac),
* every branch is a no-op. The extension is inert and safe to ship anywhere.
*
* How omp differs from Pi:
*
* - Discovery root is `.omp`, not `.pi`. omp auto-discovers flat *.ts
* files under <cwd>/.omp/extensions (project) and ~/.omp/agent/extensions
* (user). `.pi/extensions` is NOT a native root in omp, so this file
* must live under .omp/extensions to be loaded at all.
*
* - The working directory is on the handler CONTEXT (2nd arg), not the
* event. omp's session_start / before_agent_start events carry no
* `cwd`/`directory`; read `ctx.cwd` instead (it is the live per-session
* directory, correct under multi-session hosts where process.cwd() is not).
*
* - before_agent_start's `event.systemPrompt` is a string[] (the already
* rendered system blocks), not a string. We append our block as a new
* array element and return { systemPrompt: string[] }.
*
* System prompt: the briefing and manifest are injected ONLY via the
* systemPrompt returned from before_agent_start. In omp this value flows to
* setTurnSystemPromptOverride -> agent.setSystemPrompt — the SAME channel
* that renders the base system block. It REPLACES the single system block; it
* never adds a second message. Never inject via a returned `message`: that is
* a separate channel and would put additional content at index > 0.
*
* IMPORTANT: this file targets omp's EXTENSION subsystem (default export +
* api.on), NOT omp's parallel `hooks/` subsystem — whose before_agent_start
* result only supports { message } and would silently drop a returned
* systemPrompt. It must land on the extension discovery path
* (.omp/extensions/…), a flat *.ts file, to work.
*
* Requirements: omp's extension system auto-discovers .omp/extensions/*.ts
* (project-local) and ~/.omp/agent/extensions/*.ts (user). This file uses
* only bundled modules (no package.json or npm install needed).
*/

// Minimal ambient declaration so this file typechecks without pulling in
// @types/node. The omp extension host (Bun) provides `process` and global
// `fetch`/`AbortSignal` at runtime; we only read OMAC_* vars from the
// environment.
declare const process: {
env: Record<string, string | undefined>
cwd: () => string
}

type SkillScope = "workdir" | "global"
type SkillState = "ready" | "pending-credentials" | "broken"

interface ManifestSkill {
name: string
scope: SkillScope
mount: string
state: SkillState
base?: string
socket_base?: string
missing?: string[]
detail?: string
}

interface DirManifest {
dir: string
dir_token: string
state: "activating" | "active" | "active_partial"
skills: ManifestSkill[]
}

function controlBase(): string | undefined {
return process.env.OMAC_CONTROL_BASE?.replace(/\/+$/, "")
}

async function controlPost(path: string, body: unknown): Promise<DirManifest | null> {
const base = controlBase()
if (!base) return null
try {
const resp = await fetch(`${base}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
// omp force-times-out each event handler at 30s. Bound the request
// well under that so a hung control plane can never blow the budget
// (which would silently drop the injection for that turn).
signal: AbortSignal.timeout(20_000),
})
if (!resp.ok) return null
return (await resp.json()) as DirManifest
} catch {
return null
}
}

function renderManifest(manifest: DirManifest): string {
const skillsDir = process.env.OMAC_HARNESS_SKILLS_DIR || ".omp/skills"
const lines: string[] = [
"## omac skills available in this workspace",
"",
"You can call the following skill HTTP endpoints. Each `base` is the root URL for that skill's sidecar; append the skill's documented path.",
"",
`This workspace's project directory is: \`${manifest.dir || ""}\``,
]

const globalReady = manifest.skills?.filter(
(s) => s.scope === "global" && s.state === "ready",
)
if (globalReady && globalReady.length > 0) {
lines.push(
"",
`IMPORTANT: **global** skills are shared by every workspace. When a global skill writes into the project (e.g. the marketplace installing a skill), you MUST pass this workspace's project directory explicitly — for the marketplace use \`"target_path": "${manifest.dir || ""}/${skillsDir}"\` (the active harness's skills directory) in the /install request body.`,
)
}

lines.push("")
const sorted = [...(manifest.skills || [])].sort((a, b) =>
a.name.localeCompare(b.name),
)
for (const sk of sorted) {
if (sk.state === "ready" && sk.base) {
lines.push(`- **${sk.name}** (${sk.scope || ""}) — ready — base: \`${sk.base}\``)
} else if (sk.state === "pending-credentials") {
const missing = (sk.missing || []).join(", ")
lines.push(
`- **${sk.name}** (${sk.scope || ""}) — UNAVAILABLE (missing credentials: ${missing}). Run in your own terminal: ${(sk.missing || []).map((m) => `omac secrets set ${sk.name} ${m}`).join(" ; ")}`,
)
} else if (sk.state === "broken") {
lines.push(
`- **${sk.name}** (${sk.scope || ""}) — BROKEN: ${sk.detail || "see omac logs"}`,
)
}
}

return lines.join("\n")
}

// sessionDir resolves the live working directory for a handler. In omp the
// cwd is on the context (2nd arg), not the event; fall back to the event
// fields (Pi compatibility) and finally process.cwd().
function sessionDir(event: any, ctx: any): string {
return ctx?.cwd || event?.cwd || event?.directory || process.cwd()
}

export default function (api: {
on: (event: string, handler: (event: any, ctx: any) => void | Promise<void>) => void
}) {
// Keyed by resolved session directory so a multi-session host (one
// process serving several cwds) never injects another session's manifest.
const manifests = new Map<string, DirManifest>()

api.on("session_start", async (event: any, ctx: any) => {
const base = controlBase()
if (!base) return

const dir = sessionDir(event, ctx)
const m = await controlPost("/__omac__/activate", { dir })
if (m) manifests.set(dir, m)
})

api.on("before_agent_start", async (event: any, ctx: any) => {
const base = controlBase()
if (!base) return

// Refresh every turn so skills installed/fixed after the first turn
// surface on the next. controlPost bounds the request at 20s and
// returns null on failure; keep the prior manifest if the fresh fetch
// returns null so a transient control-plane blip doesn't erase the
// injection for the turn.
const dir = sessionDir(event, ctx)
const fresh = await controlPost("/__omac__/activate", { dir })
if (fresh) manifests.set(dir, fresh)
const manifest = manifests.get(dir)
if (!manifest) return

const manifestText = renderManifest(manifest)
const briefing = process.env.OMAC_SANDBOX_BRIEFING || ""
const contextBlock = briefing
? `${briefing}\n\n${manifestText}`
: manifestText

// The returned systemPrompt is the ONLY injection path: omp folds it
// back through setTurnSystemPromptOverride -> agent.setSystemPrompt,
// the same setter used for the base prompt, so no second
// {role:"system"} message is ever created. omp's event.systemPrompt is
// a string[] (the already-rendered blocks); append our block as a new
// element rather than string-concatenating, preserving prior blocks.
const original: string[] = Array.isArray(event?.systemPrompt)
? event.systemPrompt
: event?.systemPrompt
? [String(event.systemPrompt)]
: []
return {
systemPrompt: [...original, contextBlock],
}
})
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ security model applies whichever agent you use:
| OpenAI Codex | `omac start codex` | `cx` |
| GitHub Copilot CLI | `omac start copilot` | `co` |
| Pi | `omac start pi` | — |
| oh-my-pi | `omac start omp` | — |
| CodeWhale | `omac start codewhale` | `cw` |

Skills are harness-agnostic: the same skill works unchanged under any harness.
Expand Down
15 changes: 10 additions & 5 deletions docs/HARNESSES.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ omac start claude # Claude Code
omac start codex # OpenAI Codex CLI
omac start copilot # GitHub Copilot CLI
omac start pi # Pi (pi.dev)
omac start omp # oh-my-pi (omp.sh) — Pi fork
omac start codewhale # CodeWhale (bring-your-own-model)
omac serve claude # multi-directory server, Claude Code harness
```

Supported harnesses (and aliases): `opencode` (`oc`), `claude-code`
(`claude`, `cc`), `codex` (`cx`), `copilot` (`co`), `pi`, `codewhale`
(`claude`, `cc`), `codex` (`cx`), `copilot` (`co`), `pi`, `omp`, `codewhale`
(`cw`). Omitting the token defaults to `opencode`. An unknown token is rejected with the list
of supported names. Inner arguments that happen to be barewords go after
`--` (e.g. `omac start claude -- --model sonnet`).
Expand Down Expand Up @@ -56,6 +57,7 @@ omac continue claude # ...with Claude Code
omac continue codex # ...with OpenAI Codex
omac continue copilot # ...with GitHub Copilot
omac continue pi # ...with Pi
omac continue omp # ...with oh-my-pi
omac continue -s <id> # reopen a specific session by id (shorthand for --session)
omac resume # pick from this folder's recent sessions, then launch
omac resume claude # ...with Claude Code
Expand All @@ -64,7 +66,8 @@ omac resume claude # ...with Claude Code
`omac continue` re-enters the most recent session for this folder. Pass
`-s`/`--session <id>` to target a specific session non-interactively
(opencode `--session <id>`, claude `--resume <id>`, codex `resume <id>`,
copilot `--session-id <id>`, pi `--session <id>`, codewhale `resume <id>`).
copilot `--session-id <id>`, pi `--session <id>`, omp `--session <id>`,
codewhale `resume <id>`).
After the inner command exits, omac prints a one-line hint with the most
recent session id:

Expand Down Expand Up @@ -95,13 +98,14 @@ omac's control plane (skill activation, the skills manifest, skill base URLs):
| Codex | `.codex/` | SessionStart hook |
| Copilot | `.copilot/` | SessionStart + SessionEnd hooks |
| Pi | `.pi/extensions/` | TypeScript extension (`omac-bridge`) |
| oh-my-pi | `.omp/extensions/` | TypeScript extension (`omac-bridge`) |
| CodeWhale | *(none)* | Briefing delivered as a rules file |

Skills themselves are **harness-agnostic** — the same skill works unchanged
under any harness. Adding a new agentic harness means registering one
descriptor in `internal/config/harness.go` plus shipping its bridge; no
command-dispatch code changes. The six supported harnesses — OpenCode,
Claude Code, Codex, Copilot, Pi, CodeWhale — are worked examples. See
command-dispatch code changes. The seven supported harnesses — OpenCode,
Claude Code, Codex, Copilot, Pi, oh-my-pi, CodeWhale — are worked examples. See
[`CREATING_A_SKILL.md`](../CREATING_A_SKILL.md) and
[`MULTI_DIR_DESKTOP.md`](./MULTI_DIR_DESKTOP.md).

Expand All @@ -111,7 +115,7 @@ omac ships a small set of **built-in skills** embedded in the binary and
**auto-provisions them on `omac start` / `omac serve`** — no separate step. On
launch, omac idempotently writes them into the active harness's skills directory
(`~/.config/opencode/skills`, `~/.claude/skills`, `~/.codex/skills`,
`~/.copilot/skills`, `~/.pi/agent/skills`, `~/.codewhale/skills`); it stays silent when they're already current and never
`~/.copilot/skills`, `~/.pi/agent/skills`, `~/.omp/agent/skills`, `~/.codewhale/skills`); it stays silent when they're already current and never
overwrites a same-named directory it doesn't own.

Today the only built-in is **`omac-write-a-skill`** — a guidance-only skill
Expand Down Expand Up @@ -139,6 +143,7 @@ matches that: discovery is scoped to the active harness.
| Codex | `.codex/skills` / `~/.codex/skills` |
| Copilot | `.copilot/skills` / `~/.copilot/skills` |
| Pi | `.pi/skills` / `~/.pi/agent/skills` |
| oh-my-pi | `.omp/skills` / `~/.omp/agent/skills` |
| CodeWhale | `.agents/skills` / `~/.codewhale/skills` |
| *(shared)* | `.agents/skills` / `~/.config/agents/skills` |

Expand Down
1 change: 1 addition & 0 deletions docs/INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ If the dialog is cut off or crowds the screen, `OMAC_PROMPT_WIDTH` and
| **codex** (OpenAI Codex CLI) | see [Codex docs](https://github.com/openai/codex) | Alternative harness (`omac start codex`) |
| **copilot** (GitHub Copilot CLI) | see [Copilot CLI docs](https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli) | Alternative harness (`omac start copilot`) |
| **pi** (Pi coding agent) | see [Pi docs](https://pi.dev) | Alternative harness (`omac start pi`) |
| **omp** (oh-my-pi) | see [oh-my-pi docs](https://omp.sh) | Alternative harness (`omac start omp`), a Pi fork |
| **codewhale** (CodeWhale CLI) | npm package `codewhale` | Alternative harness (`omac start codewhale`), bring-your-own-model |

At least one inner harness must be installed; `opencode` is the default.
Expand Down
1 change: 1 addition & 0 deletions docs/MULTI_DIR_DESKTOP.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and the `/__omac__/*` endpoints.
| OpenCode | `.opencode/plugins/omac-multidir.ts` | plugin construction + `session.*` events | `experimental.chat.system.transform` | `shell.env` → `OMAC_D_*` per session (§4.1) |
| Claude Code | `.claude/` (`settings.json` + `hooks/omac-bridge.sh`) | `SessionStart` hook | `SessionStart` hook `additionalContext` | process-level flat aliases (`OMAC_<MOUNT>_BASE`) — see below |
| Pi | `.pi/extensions/omac-bridge/index.ts` | `session_start` event | `before_agent_start` event (system prompt)| process-level flat aliases (`OMAC_<MOUNT>_BASE`) |
| oh-my-pi | `.omp/extensions/omac-bridge.ts` | `session_start` event (cwd on `ctx`) | `before_agent_start` event (system prompt, `string[]`) | process-level flat aliases (`OMAC_<MOUNT>_BASE`) |

**OpenCode** has a true per-session env hook, so it can inject distinct
`OMAC_D_<token>_<MOUNT>_BASE` for every session and supports the full
Expand Down
28 changes: 28 additions & 0 deletions internal/bridge/bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,34 @@ func TestPiBridgeInjectsExactlyOneSystemBlock(t *testing.T) {
}
}

const ompBridgePath = "../../.omp/extensions/omac-bridge.ts"

func TestOmpBridgeInjectsExactlyOneSystemBlock(t *testing.T) {
src, err := os.ReadFile(ompBridgePath)
if err != nil {
t.Fatalf("read omp bridge: %v", err)
}
text := stripSpace(string(src))

for _, banned := range []string{
`unshift({role:"system"`,
`push({role:"system"`,
`message:{role:"system"`,
} {
if strings.Contains(text, banned) {
t.Errorf("%s must not %s: injecting a message alongside the "+
"returned systemPrompt yields two {role:\"system\"} messages "+
"and breaks providers that require exactly one at index 0",
ompBridgePath, banned)
}
}

if !strings.Contains(text, "systemPrompt:") {
t.Errorf("%s no longer returns a merged systemPrompt; the briefing "+
"and skills manifest would never reach the model", ompBridgePath)
}
}

// stripSpace removes all whitespace, making a source-level match immune to
// reformatting.
func stripSpace(s string) string {
Expand Down
43 changes: 43 additions & 0 deletions internal/config/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,49 @@ func harnessRegistry() []Harness {
BriefingEnvFunc: nil,
NeedsPluginBootstrap: false,
},
{
Name: "omp",
Aliases: []string{},
// oh-my-pi CLI executable is `omp` (omp.sh). omp is a Pi fork
// with a diverged extension surface: extensions and config live
// under .omp/ (not .pi/), and before_agent_start's
// event.systemPrompt is a string[]. See .omp/extensions/omac-bridge.ts.
InnerCmd: []string{"omp"},
// omp has no server mode; under `omac serve` it runs as-is.
ServerLaunch: nil,
BridgeDir: ".omp/extensions",
SkillsBase: "omp",
// omp's config home is ~/.omp/agent (mirrors pi's ~/.pi/agent):
// models.json, sessions/, skills/, and extensions/ all live under
// ~/.omp/agent/. UserConfigHome must include the "agent" segment so
// ConfigHome()/GlobalSkillsDir()/GlobalBridgeDir() resolve to the
// paths omp's own loader reads (GlobalBridgeDir() ->
// ~/.omp/agent/extensions, GlobalSkillsDir() -> ~/.omp/agent/skills,
// piSessionsRoot -> ~/.omp/agent/sessions).
UserConfigHome: filepath.Join(".omp", "agent"),
// PI_CODING_AGENT_DIR is omp's config-home override (inherited from
// pi; omp still honors this env var).
HomeEnv: "PI_CODING_AGENT_DIR",
// omp stores models.json, sessions, skills, and extensions under
// ~/.omp/ (package caches nest under there too).
SandboxDirs: []string{"~/.omp"},
// Like pi, omp is multi-provider and resolves $ENV_VAR references
// in models.json, so omac does NOT auto-forward a grab-bag of
// provider keys: the user declares the specific key their
// models.json references in the profile's environment.allow_vars.
Session: &HarnessSession{
ContinueArgs: []string{"-c"},
ResumeByIDArgs: func(id string) []string { return []string{"--session", id} },
ListKind: SessionListPi,
},
// omp has no system-prompt CLI flag. The briefing is delivered via
// OMAC_SANDBOX_BRIEFING env var (set by omac at launch), read by
// the TS extension in before_agent_start and injected into the
// system prompt (returned as a string[] element).
SystemContextArgs: nil,
BriefingEnvFunc: nil,
NeedsPluginBootstrap: false,
},
{
Name: "codewhale",
Aliases: []string{"cw"},
Expand Down
Loading
Loading