From 4d99cecdc21cab5acf9ff8f16b73f198eb555797 Mon Sep 17 00:00:00 2001 From: Ludwig Kunz Date: Fri, 14 Aug 2026 10:19:11 +0200 Subject: [PATCH] feat: add oh-my-pi (omp) as a supported harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omp (omp.sh) is a coding-focused fork of Pi with a diverged extension surface. - config: new `omp` harness descriptor mirroring pi — InnerCmd `omp`, BridgeDir `.omp/extensions`, SkillsBase `omp`, UserConfigHome `.omp/agent`, HomeEnv PI_CODING_AGENT_DIR, SandboxDirs `~/.omp`, SessionListPi. Paths derive to ~/.omp/agent/{extensions,skills,sessions}. - bridge: adapted .omp/extensions/omac-bridge.ts for omp's surface — discovery root `.omp` (not `.pi`), cwd read from the handler ctx (events carry none), before_agent_start returns systemPrompt as a string[], and the control-plane fetch is bounded to fit omp's 30s per-handler cap. - test: TestOmpHarnessDescriptor + path/session/sandbox derivations, and TestOmpBridgeInjectsExactlyOneSystemBlock guarding the single system-block invariant on the omp bridge. - docs: list omp in HARNESSES.md, MULTI_DIR_DESKTOP.md, README.md and INSTALLATION.md (launch/resume/bridge/skills tables). Signed-off-by: Ludwig Kunz --- .omp/extensions/omac-bridge.ts | 211 ++++++++++++++++++++++++++++++++ README.md | 1 + docs/HARNESSES.md | 15 ++- docs/INSTALLATION.md | 1 + docs/MULTI_DIR_DESKTOP.md | 1 + internal/bridge/bridge_test.go | 28 +++++ internal/config/harness.go | 43 +++++++ internal/config/harness_test.go | 125 +++++++++++++++++++ 8 files changed, 420 insertions(+), 5 deletions(-) create mode 100644 .omp/extensions/omac-bridge.ts diff --git a/.omp/extensions/omac-bridge.ts b/.omp/extensions/omac-bridge.ts new file mode 100644 index 00000000..51bdb27f --- /dev/null +++ b/.omp/extensions/omac-bridge.ts @@ -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__BASE / OMAC_G__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 /.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 + 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 { + 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 +}) { + // 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() + + 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], + } + }) +} diff --git a/README.md b/README.md index a59d6269..230f1275 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index bb949ab8..171d2e29 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -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`). @@ -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 # 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 @@ -64,7 +66,8 @@ omac resume claude # ...with Claude Code `omac continue` re-enters the most recent session for this folder. Pass `-s`/`--session ` to target a specific session non-interactively (opencode `--session `, claude `--resume `, codex `resume `, -copilot `--session-id `, pi `--session `, codewhale `resume `). +copilot `--session-id `, pi `--session `, omp `--session `, +codewhale `resume `). After the inner command exits, omac prints a one-line hint with the most recent session id: @@ -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). @@ -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 @@ -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` | diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index 97f28678..65eb90cc 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -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. diff --git a/docs/MULTI_DIR_DESKTOP.md b/docs/MULTI_DIR_DESKTOP.md index a0579a8b..4052a174 100644 --- a/docs/MULTI_DIR_DESKTOP.md +++ b/docs/MULTI_DIR_DESKTOP.md @@ -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__BASE`) — see below | | Pi | `.pi/extensions/omac-bridge/index.ts` | `session_start` event | `before_agent_start` event (system prompt)| process-level flat aliases (`OMAC__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__BASE`) | **OpenCode** has a true per-session env hook, so it can inject distinct `OMAC_D___BASE` for every session and supports the full diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go index 5a92d173..1056a202 100644 --- a/internal/bridge/bridge_test.go +++ b/internal/bridge/bridge_test.go @@ -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 { diff --git a/internal/config/harness.go b/internal/config/harness.go index fb305c64..13238ed2 100644 --- a/internal/config/harness.go +++ b/internal/config/harness.go @@ -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"}, diff --git a/internal/config/harness_test.go b/internal/config/harness_test.go index 91aa3b80..3dec55e7 100644 --- a/internal/config/harness_test.go +++ b/internal/config/harness_test.go @@ -841,6 +841,131 @@ func TestPiSystemContextArgsNil(t *testing.T) { } } +// --- omp harness descriptor ------------------------------------------------ + +func TestOmpHarnessDescriptor(t *testing.T) { + h, ok := LookupHarness("omp") + if !ok { + t.Fatal("omp harness not registered") + } + if !reflect.DeepEqual(h.InnerCmd, []string{"omp"}) { + t.Errorf("omp InnerCmd = %v, want [omp]", h.InnerCmd) + } + if h.ServerLaunch != nil { + t.Errorf("omp ServerLaunch = %v, want nil", h.ServerLaunch) + } + if h.BridgeDir != ".omp/extensions" { + t.Errorf("omp BridgeDir = %q, want .omp/extensions", h.BridgeDir) + } + if h.SkillsBase != "omp" { + t.Errorf("omp SkillsBase = %q, want omp", h.SkillsBase) + } + if want := filepath.Join(".omp", "agent"); h.UserConfigHome != want { + t.Errorf("omp UserConfigHome = %q, want %q", h.UserConfigHome, want) + } + if h.HomeEnv != "PI_CODING_AGENT_DIR" { + t.Errorf("omp HomeEnv = %q, want PI_CODING_AGENT_DIR", h.HomeEnv) + } +} + +// TestOmpConfigHome guards the ~/.omp/agent (not ~/.omp) config home +func TestOmpConfigHome(t *testing.T) { + h, _ := LookupHarness("omp") + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, ".omp", "agent"); h.ConfigHome() != want { + t.Errorf("omp ConfigHome() = %q, want %q", h.ConfigHome(), want) + } +} + +func TestOmpConfigHomeEnvOverride(t *testing.T) { + h, _ := LookupHarness("omp") + t.Setenv("PI_CODING_AGENT_DIR", "/custom/omp/agent") + if got := h.ConfigHome(); got != "/custom/omp/agent" { + t.Errorf("omp ConfigHome() with PI_CODING_AGENT_DIR set = %q, want /custom/omp/agent", got) + } +} + +func TestOmpGlobalSkillsDir(t *testing.T) { + h, _ := LookupHarness("omp") + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, ".omp", "agent", "skills"); h.GlobalSkillsDir() != want { + t.Errorf("omp GlobalSkillsDir() = %q, want %q", h.GlobalSkillsDir(), want) + } +} + +func TestOmpGlobalBridgeDir(t *testing.T) { + h, _ := LookupHarness("omp") + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, ".omp", "agent", "extensions"); h.GlobalBridgeDir() != want { + t.Errorf("omp GlobalBridgeDir() = %q, want %q", h.GlobalBridgeDir(), want) + } +} + +func TestOmpSessionMetadata(t *testing.T) { + h, ok := LookupHarness("omp") + if !ok { + t.Fatal("omp harness not registered") + } + if h.Session == nil { + t.Fatal("omp Session is nil, want session metadata") + } + if !reflect.DeepEqual(h.Session.ContinueArgs, []string{"-c"}) { + t.Errorf("omp ContinueArgs = %v, want [-c]", h.Session.ContinueArgs) + } + if got := h.Session.ResumeByIDArgs("abc123"); !reflect.DeepEqual(got, []string{"--session", "abc123"}) { + t.Errorf("omp ResumeByIDArgs = %v, want [--session abc123]", got) + } + if h.Session.ListKind != SessionListPi { + t.Errorf("omp ListKind = %v, want SessionListPi", h.Session.ListKind) + } +} + +func TestOmpWorkdirSkillsDir(t *testing.T) { + h, _ := LookupHarness("omp") + if got := h.WorkdirSkillsDir(); got != ".omp/skills" { + t.Errorf("omp WorkdirSkillsDir = %q, want .omp/skills", got) + } +} + +func TestOmpInScopeSkillsBases(t *testing.T) { + h, _ := LookupHarness("omp") + if got := h.InScopeSkillsBases(); !reflect.DeepEqual(got, []string{"omp", SharedSkillsBase}) { + t.Errorf("omp bases = %v, want [omp agents]", got) + } +} + +func TestOmpSandboxDirs(t *testing.T) { + h, ok := LookupHarness("omp") + if !ok { + t.Fatal("omp harness not found") + } + if !reflect.DeepEqual(h.SandboxDirs, []string{"~/.omp"}) { + t.Errorf("omp SandboxDirs = %v, want [~/.omp]", h.SandboxDirs) + } +} + +func TestOmpSystemContextArgsNil(t *testing.T) { + h, ok := LookupHarness("omp") + if !ok { + t.Fatal("omp harness not found") + } + if h.SystemContextArgs != nil { + t.Error("omp SystemContextArgs should be nil (no system-prompt flag exists)") + } + if h.BriefingEnvFunc != nil { + t.Error("omp BriefingEnvFunc should be nil (briefing via OMAC_SANDBOX_BRIEFING + TS extension)") + } +} + // --- CodeWhale harness descriptor -------------------------------------------- func TestLookupCodewhaleHarness(t *testing.T) {