diff --git a/app/src/components/agent-glyph.tsx b/app/src/components/agent-glyph.tsx
index 7818f98c..90161d04 100644
--- a/app/src/components/agent-glyph.tsx
+++ b/app/src/components/agent-glyph.tsx
@@ -19,6 +19,8 @@ export function AgentIcon({ agent, className }: { agent?: string; className?: st
return ;
case "pi":
return π;
+ case "omp":
+ return π;
case "opencode":
return ;
default:
diff --git a/app/src/lib/derive.ts b/app/src/lib/derive.ts
index 41566ada..be61da93 100644
--- a/app/src/lib/derive.ts
+++ b/app/src/lib/derive.ts
@@ -5,7 +5,7 @@ import type { Location, Session, Stats, Worktree } from "@/lib/api";
// or "idle" for a plain shell.
export type SessionState = "ready" | "running" | "waiting" | "finished" | "exited" | "idle";
-export const AGENTS = ["claude", "codex", "opencode", "gemini", "pi", "cursor-agent"];
+export const AGENTS = ["claude", "codex", "opencode", "gemini", "pi", "omp", "cursor-agent"];
export function agentOf(s: Session): string | undefined {
// A service's terminal runs no agent, whatever its command.
@@ -148,7 +148,7 @@ export function sortedWorktrees(loc: Location): Worktree[] {
return [...(loc.worktrees ?? [])].sort((a, b) => Number(!!b.main) - Number(!!a.main) || a.name.localeCompare(b.name));
}
-const labels: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", pi: "Pi", "cursor-agent": "Cursor Agent", cursor: "Cursor Agent" };
+const labels: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", pi: "Pi", omp: "oh-my-pi", "cursor-agent": "Cursor Agent", cursor: "Cursor Agent" };
// agentLabel is an agent's product name: "claude" → "Claude Code".
export const agentLabel = (a: string) => labels[a] ?? a.charAt(0).toUpperCase() + a.slice(1);
diff --git a/app/src/lib/session-open.ts b/app/src/lib/session-open.ts
index e1ae4902..66fa9e59 100644
--- a/app/src/lib/session-open.ts
+++ b/app/src/lib/session-open.ts
@@ -11,7 +11,7 @@ import { activateTab, findSession, focusPane, openTab, refOf, removePane, select
// that worktree, and never takes the keyboard from them; otherwise a
// notification offers to open it.
-const agentNames: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", cursor: "Cursor" };
+const agentNames: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
function agentLabel(box: string, agent?: string): string {
if (!agent) return "A terminal";
diff --git a/app/src/views/automations/flows/model.ts b/app/src/views/automations/flows/model.ts
index ce5d2c3d..63bd3787 100644
--- a/app/src/views/automations/flows/model.ts
+++ b/app/src/views/automations/flows/model.ts
@@ -56,7 +56,7 @@ const TRIGGER_PHRASE: Record = {
"box.upgraded": "the box is upgraded",
};
-const AGENT_NAME: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", cursor: "Cursor" };
+const AGENT_NAME: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
export const agentName = (id?: string) => (id ? (AGENT_NAME[id] ?? id) : "an agent");
export const GITHUB_ONS: { on: GitHubOn; label: string; phrase: string; fields: string[] }[] = [
diff --git a/app/src/views/settings/agents-section.tsx b/app/src/views/settings/agents-section.tsx
index 382fc476..4dc343a5 100644
--- a/app/src/views/settings/agents-section.tsx
+++ b/app/src/views/settings/agents-section.tsx
@@ -21,6 +21,7 @@ const INSTALL: Record = {
opencode: "npm install -g opencode-ai",
gemini: "npm install -g @google/gemini-cli",
cursor: "curl https://cursor.com/install -fsS | bash",
+ omp: "bun install -g @oh-my-pi/pi-coding-agent",
};
const BUILTIN: Pick[] = [
@@ -29,6 +30,7 @@ const BUILTIN: Pick[] = [
{ id: "opencode", name: "OpenCode" },
{ id: "gemini", name: "Gemini CLI" },
{ id: "cursor", name: "Cursor Agent" },
+ { id: "omp", name: "oh-my-pi" },
];
export function AgentsSection() {
diff --git a/app/src/views/settings/labs-section.tsx b/app/src/views/settings/labs-section.tsx
index cb2424d9..0d626821 100644
--- a/app/src/views/settings/labs-section.tsx
+++ b/app/src/views/settings/labs-section.tsx
@@ -30,7 +30,7 @@ export function LabsSection() {
{labs && (
setPrefs({ structuredClaude: on })} />
diff --git a/docs/changelog.mdx b/docs/changelog.mdx
index caad1cc7..eb20d0c9 100644
--- a/docs/changelog.mdx
+++ b/docs/changelog.mdx
@@ -9,8 +9,15 @@ upgrades with `berth upgrade BOX`, or by running the
[install command](/getting-started/add-a-box#with-the-install-command)
again.
-
## Unreleased
+- **oh-my-pi is a built-in agent.** Start it from a project like any other
+ (`omp`), with a model and thinking level. With *Structured agents* on, it runs
+ through the Agent Client Protocol: its replies and tool calls fill the chat
+ and its permission requests come to it. `berthd integrations install omp`
+ adds a small extension to `~/.omp/agent/extensions` so a terminal session
+ says when it is working, needs you or is done, and adds Shipyard's MCP
+ server to `~/.omp/agent/mcp.json` so omp can drive other sessions; a box
+ that has omp gets both with the other agents' hooks.
- **Settings › Boxes reads correctly to a screen reader.** A box with no
browsers running put its "No browsers running" note inside the browsers
list, so assistive tech announced an empty list with a stray line in it.
diff --git a/docs/concepts/sessions-and-agents.mdx b/docs/concepts/sessions-and-agents.mdx
index ccbc871d..6d79d70e 100644
--- a/docs/concepts/sessions-and-agents.mdx
+++ b/docs/concepts/sessions-and-agents.mdx
@@ -109,7 +109,8 @@ announces `task.created`.
An agent is a preset: an id, a name and a command. The box offers each
built-in whose command it finds on its `PATH` (ids `claude`, `codex`,
-`opencode`, `gemini`, and `cursor`, which runs `cursor-agent`), plus any a
+`opencode`, `gemini`, `omp` (oh-my-pi), and `cursor`, which runs
+`cursor-agent`), plus any a
repository's config adds or replaces. `berth agents BOX` lists the
built-ins the box has; a repository's own presets come with its location
(`agents` in `berth locations BOX --json`).
@@ -121,7 +122,7 @@ A first prompt is passed as the command's last argument, or after its
Shipyard knows what an agent is doing from the agent's own hooks, which
`berthd install` sets up for the agent CLIs it finds on the box (later,
`berthd integrations install claude`, or `codex`, `cursor`, `gemini`,
-`opencode`, `all`; an upgrade brings hooks Shipyard installed before up to
+`opencode`, `omp`, `all`; an upgrade brings hooks Shipyard installed before up to
date):
| State | In the app | Meaning | From |
@@ -196,6 +197,7 @@ changing ends the turn).
| Cursor Agent | ✓ | ✓ | screen | ✓ | hooks in `~/.cursor/hooks.json` |
| Gemini CLI | ✓ | ✓ | ✓ | ✓ | hooks in `~/.gemini/settings.json` |
| OpenCode | | ✓ | ✓ | ✓ | a plugin in `~/.config/opencode/plugin/berth.js` |
+| oh-my-pi | ✓ | ✓ | ✓ | ✓ | an extension in `~/.omp/agent/extensions/berth.ts` |
| anything else | screen | screen | screen | screen | the terminal |
The box lists these as `adapters` in `GET /v1/info`, with `capabilities`
diff --git a/docs/guides/agent-integrations.mdx b/docs/guides/agent-integrations.mdx
index 63c61327..51e8a16a 100644
--- a/docs/guides/agent-integrations.mdx
+++ b/docs/guides/agent-integrations.mdx
@@ -1,6 +1,6 @@
---
title: Agent integrations
-description: Claude Code, Codex, Cursor, Gemini CLI and OpenCode hooks, the skills Shipyard teaches agents, and announcing events from any tool.
+description: Claude Code, Codex, Cursor, Gemini CLI, OpenCode and oh-my-pi hooks, the skills Shipyard teaches agents, and announcing events from any tool.
---
Shipyard works with the tools around it in both directions: tools drive
@@ -44,6 +44,11 @@ with the command that installs them.
`AfterAgent` / `SessionEnd` hooks in `~/.gemini/settings.json`.
- **OpenCode**: a small plugin, `~/.config/opencode/plugin/berth.js`, that
runs the hook when a session gets busy, asks for a permission, or goes idle.
+- **oh-my-pi**: a small extension, `~/.omp/agent/extensions/berth.ts`, that
+ runs the hook when a session starts, a turn starts or ends, or a tool asks
+ for approval. Subagents have no UI and report nothing. On a box it also
+ adds `berthd mcp` to `mcpServers` in `~/.omp/agent/mcp.json`, next to any
+ servers already there.
### Every account
diff --git a/docs/guides/labs.mdx b/docs/guides/labs.mdx
index d5f1a8b8..c9785352 100644
--- a/docs/guides/labs.mdx
+++ b/docs/guides/labs.mdx
@@ -271,8 +271,8 @@ own record of the conversation. The box drives it from there:
chat. Its modes are default (asks to go past its workspace sandbox),
untrusted (asks before untrusted commands), read only, and bypass (no
sandbox, nothing asked). It has no terminal switch.
-- **OpenCode, Gemini CLI and Cursor Agent** speak the Agent Client Protocol
- (`opencode acp`, `gemini --acp`, `cursor-agent acp`), and so does any agent
+- **OpenCode, Gemini CLI, Cursor Agent and oh-my-pi** speak the Agent Client
+ Protocol (`opencode acp`, `gemini --acp`, `cursor-agent acp`, `omp acp`), and so does any agent
whose preset in a repository's config has an [`acp`](/reference/config):
the box opens its session (with its own login, for Cursor Agent), streams
its reply, cancels its turn, switches its own modes, and brings its
diff --git a/docs/guides/project-config.mdx b/docs/guides/project-config.mdx
index 74653a4e..f45e5693 100644
--- a/docs/guides/project-config.mdx
+++ b/docs/guides/project-config.mdx
@@ -169,6 +169,7 @@ Shipyard passes them with each CLI's own flags, checked against its `--help`:
| `claude` | `--model` | `--effort` (low, medium, high, xhigh, max) | `opus`, `sonnet`, `haiku` (Claude Code's aliases for the latest of each) |
| `codex` | `--model` | `-c model_reasoning_effort=` (minimal, low, medium, high) | none: Codex's default |
| `opencode`, `cursor` | `--model` | none | none |
+| `omp` | `--model` | `--thinking` (low, medium, high, xhigh, max) | none: omp's default |
Unset means the CLI's default. A value must be a plain name (letters,
digits, `.` `_` `:` `/` `-`); anything else is refused, so nothing reaches a
diff --git a/docs/reference/berthd.mdx b/docs/reference/berthd.mdx
index 9dbc7784..7ed1cb93 100644
--- a/docs/reference/berthd.mdx
+++ b/docs/reference/berthd.mdx
@@ -31,7 +31,7 @@ flows, skills) can drive it directly. `berthd help` prints this list.
| `berthd doctor [--json]` | Check this box's setup and how to fix it. It looks for the agent CLIs afresh, and names each one's version, path and how it was installed |
| `berthd version` | Print this build's version |
| `berthd session attach NAME` | Attach to a session in this terminal |
-| `berthd mcp` | A stdio MCP server of Shipyard's tools for agents on this box, talking to its socket; `integrations install` adds it to Claude Code, Codex and Gemini CLI ([runs](/guides/runs#from-an-agent-mcp)) |
+| `berthd mcp` | A stdio MCP server of Shipyard's tools for agents on this box, talking to its socket; `integrations install` adds it to Claude Code, Codex, Gemini CLI and oh-my-pi ([runs](/guides/runs#from-an-agent-mcp)) |
| `berthd headless --agent A --out FILE.jsonl --prompt-file FILE [--read-only] [--add-dir D]` | One non-interactive agent turn, as runs start in tmux for judges and reviewers |
`~/.berth/runs.json` on the box sets `max_concurrent_runs` (10),
@@ -237,7 +237,7 @@ refuse a helper, and a `before:helper.attach` gate a look in a worktree's
| Command | What it does |
| --- | --- |
| `berthd integrations [status]` | Where Shipyard's hooks and skills are for each agent CLI on this box, per Claude Code and Codex account (`Claude Code: hooks and 7 skills in ~/.claude, ~/.berth/accounts/claude/personal`), and the command that adds them where they're missing |
-| `berthd integrations install claude\|cursor\|codex\|gemini\|opencode\|all\|present` | Install Shipyard's skills and agent hooks for a tool (`present`: every agent CLI on this box, quietly skipping the rest), and `berthd mcp` as an MCP server for Claude Code, Codex and Gemini CLI. Claude Code and Codex get them in [every account folder](/guides/agent-integrations#every-account) |
+| `berthd integrations install claude\|cursor\|codex\|gemini\|opencode\|omp\|all\|present` | Install Shipyard's skills and agent hooks for a tool (`present`: every agent CLI on this box, quietly skipping the rest), and `berthd mcp` as an MCP server for Claude Code, Codex, Gemini CLI and oh-my-pi. Claude Code and Codex get them in [every account folder](/guides/agent-integrations#every-account) |
| `berthd hook TOOL EVENT [PAYLOAD]` | What those hooks run: turns a tool's hook into a Shipyard event (`agent.finished`, `agent.waiting`), naming the session from `$BERTH_SESSION`. While berthd is down it keeps the event in `~/.berth/box/spool/`, published when berthd starts |
See [agent integrations](/guides/agent-integrations) for what the hooks and
diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx
index 0fbc10f1..1374b5bb 100644
--- a/docs/reference/cli.mdx
+++ b/docs/reference/cli.mdx
@@ -214,7 +214,7 @@ See [orchestration](/guides/orchestration) and
| Command | What it does |
| --- | --- |
| `berth integrations [status]` | Where Shipyard's hooks and skills are for each agent CLI on this laptop, per Claude Code and Codex account |
-| `berth integrations install claude\|cursor\|codex\|gemini\|opencode\|all` | Install Shipyard's skills and agent hooks for a tool, on this laptop, in every account folder it has |
+| `berth integrations install claude\|cursor\|codex\|gemini\|opencode\|omp\|all` | Install Shipyard's skills and agent hooks for a tool, on this laptop, in every account folder it has |
| `berth hook TOOL EVENT [PAYLOAD]` | What those hooks run: turns a tool's hook into a Shipyard event |
See [agent integrations](/guides/agent-integrations).
diff --git a/docs/reference/config.mdx b/docs/reference/config.mdx
index 9ac60aaa..6f9b15ab 100644
--- a/docs/reference/config.mdx
+++ b/docs/reference/config.mdx
@@ -45,8 +45,8 @@ guide is in [project config](/guides/project-config).
| `acp` | string | What follows `command` to start the agent's Agent Client Protocol server (`acp`, `--acp`). With it the agent can run as a [structured agent](/guides/labs#structured-agents). Given with an `id` and no `command`, it adds that to a built-in agent. |
The built-ins are offered when their command is installed on the box:
-`claude`, `codex`, `opencode` (`--prompt`), `gemini` (`-i`) and `cursor`
-(`cursor-agent`).
+`claude`, `codex`, `opencode` (`--prompt`), `gemini` (`-i`), `omp` (oh-my-pi) and
+`cursor` (`cursor-agent`).
### `hooks`
diff --git a/internal/agentcli/agentcli.go b/internal/agentcli/agentcli.go
index bedf7597..da923bdb 100644
--- a/internal/agentcli/agentcli.go
+++ b/internal/agentcli/agentcli.go
@@ -78,6 +78,9 @@ var Catalog = []Agent{
{ID: "gemini", Name: "Gemini CLI", Command: "gemini",
Install: "npm install -g @google/gemini-cli",
Why: "it installs with npm and needs Node.js 20 or newer, which Shipyard doesn't install; install Node, then run the command"},
+ {ID: "omp", Name: "oh-my-pi", Command: "omp",
+ Install: "bun install -g @oh-my-pi/pi-coding-agent",
+ Why: "it installs with bun, which Shipyard doesn't install; install bun, then run the command"},
}
// ByID finds an agent in the catalog.
diff --git a/internal/agentpath/agentpath.go b/internal/agentpath/agentpath.go
index 2ed5be6c..0e4b4aef 100644
--- a/internal/agentpath/agentpath.go
+++ b/internal/agentpath/agentpath.go
@@ -43,7 +43,7 @@ import (
)
// Commands are the agent CLIs Shipyard starts, looked for together.
-var Commands = []string{"claude", "codex", "opencode", "gemini", "cursor-agent", "pi"}
+var Commands = []string{"claude", "codex", "opencode", "gemini", "cursor-agent", "pi", "omp"}
// Found is where an agent's command is.
type Found struct {
diff --git a/internal/box/agentpaths.go b/internal/box/agentpaths.go
index 9cfb1af1..a8a6ea21 100644
--- a/internal/box/agentpaths.go
+++ b/internal/box/agentpaths.go
@@ -154,7 +154,7 @@ func agentChecks() []doctor.Check {
}
if found == 0 {
checks = append(checks, doctor.Check{Area: "Agents", Name: "agent CLIs", Status: doctor.Info,
- Detail: "none found (claude, codex, opencode, gemini, cursor-agent), through your shell or where installers put them",
+ Detail: "none found (claude, codex, opencode, gemini, cursor-agent, omp), through your shell or where installers put them",
Fix: "Add agents from Shipyard (Settings → Boxes), or: berthd agents install claude"})
}
st := agentFinder().Status()
diff --git a/internal/box/omp_test.go b/internal/box/omp_test.go
new file mode 100644
index 00000000..08e81981
--- /dev/null
+++ b/internal/box/omp_test.go
@@ -0,0 +1,32 @@
+package box
+
+import "testing"
+
+func TestOmpIsABuiltinAgentThatRunsOverACP(t *testing.T) {
+ var omp *AgentPreset
+ for i := range builtinAgents {
+ if builtinAgents[i].ID == "omp" {
+ omp = &builtinAgents[i]
+ }
+ }
+ if omp == nil {
+ t.Fatal("omp is not a built-in agent")
+ }
+ if omp.Command != "omp" || omp.ACP != "acp" || omp.ModelFlag != "--model" || omp.EffortFlag != "--thinking" {
+ t.Fatalf("preset = %+v", *omp)
+ }
+}
+
+func TestAnOmpCommandIsRecognisedAsOmp(t *testing.T) {
+ for command, want := range map[string]string{
+ "omp": "omp",
+ "env FOO=1 omp --model opus": "omp",
+ "/Users/a/.local/bin/omp -r": "omp",
+ "bunx @oh-my-pi/pi-coding-agent": "omp",
+ "npx @oh-my-pi/pi-coding-agent@1.2.3": "omp",
+ } {
+ if got := agentOf(command); got != want {
+ t.Errorf("agentOf(%q) = %q, want %q", command, got, want)
+ }
+ }
+}
diff --git a/internal/box/phoneui/app.js b/internal/box/phoneui/app.js
index f0686d7c..a8a9f579 100644
--- a/internal/box/phoneui/app.js
+++ b/internal/box/phoneui/app.js
@@ -71,8 +71,8 @@ const GROUPS = [
["finished", "Done"],
["idle", "Ready"],
];
-const AGENTS = { claude: "C", codex: "X", opencode: "O", gemini: "G", cursor: "U" };
-const AGENT_NAMES = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", cursor: "Cursor" };
+const AGENTS = { claude: "C", codex: "X", opencode: "O", gemini: "G", omp: "P", cursor: "U" };
+const AGENT_NAMES = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
// A worktree by its display name when it was given one (worktree_title).
function where(s) {
diff --git a/internal/box/services.go b/internal/box/services.go
index cf782079..7fb173fd 100644
--- a/internal/box/services.go
+++ b/internal/box/services.go
@@ -99,7 +99,7 @@ func (b *Box) handleServices(w http.ResponseWriter, r *http.Request) error {
// Tools reports which agent CLIs this box has.
func Tools() []string {
var have []string
- for _, t := range []string{"claude", "codex", "opencode", "gemini", "pi"} {
+ for _, t := range []string{"claude", "codex", "opencode", "gemini", "pi", "omp"} {
if _, ok := agentFound(t); ok {
have = append(have, t)
}
diff --git a/internal/box/tasks.go b/internal/box/tasks.go
index 1512f8f5..84ab44c5 100644
--- a/internal/box/tasks.go
+++ b/internal/box/tasks.go
@@ -44,7 +44,8 @@ type AgentPreset struct {
// The model and effort flags are each CLI's own, from its --help: Claude
// Code's --model takes aliases (opus, sonnet, haiku) and --effort low to max;
// Codex takes -m/--model and its reasoning effort as the config key
-// model_reasoning_effort; OpenCode and Cursor Agent take --model.
+// model_reasoning_effort; OpenCode and Cursor Agent take --model; oh-my-pi
+// takes --model and --thinking.
var builtinAgents = []AgentPreset{
{ID: "claude", Name: "Claude Code", Command: "claude", ModelFlag: "--model", EffortFlag: "--effort",
Models: []string{"opus", "sonnet", "haiku"}, Efforts: []string{"low", "medium", "high", "xhigh", "max"}},
@@ -53,6 +54,8 @@ var builtinAgents = []AgentPreset{
{ID: "opencode", Name: "OpenCode", Command: "opencode", PromptFlag: "--prompt", ModelFlag: "--model", ACP: "acp"},
{ID: "gemini", Name: "Gemini CLI", Command: "gemini", PromptFlag: "-i", ACP: "--acp"},
{ID: "cursor", Name: "Cursor Agent", Command: "cursor-agent", ModelFlag: "--model", ACP: "acp"},
+ {ID: "omp", Name: "oh-my-pi", Command: "omp", ModelFlag: "--model", EffortFlag: "--thinking",
+ Efforts: []string{"low", "medium", "high", "xhigh", "max"}, ACP: "acp"},
}
// Presets are the built-in agents this box has, then the location's own from
@@ -214,6 +217,7 @@ var agentPackages = map[string]string{
"@openai/codex": "codex",
"@google/gemini-cli": "gemini",
"opencode-ai": "opencode",
+ "@oh-my-pi/pi-coding-agent": "omp",
}
// wrapperWord is a word that runs the command after it.
diff --git a/internal/box/tmux.go b/internal/box/tmux.go
index f755b62e..819d1836 100644
--- a/internal/box/tmux.go
+++ b/internal/box/tmux.go
@@ -176,6 +176,7 @@ var agentInstalls = map[string]string{
"opencode": "npm install -g opencode-ai",
"gemini": "npm install -g @google/gemini-cli",
"cursor": "curl https://cursor.com/install -fsS | bash",
+ "omp": "bun install -g @oh-my-pi/pi-coding-agent",
}
// tmuxRequirement is whether tmux is here and, when it isn't, how to get it.
diff --git a/internal/boxcmd/boxcmd.go b/internal/boxcmd/boxcmd.go
index d014c3df..b47c1e95 100644
--- a/internal/boxcmd/boxcmd.go
+++ b/internal/boxcmd/boxcmd.go
@@ -548,7 +548,7 @@ func Run(ctx context.Context, c *box.Client, args []string, out io.Writer) error
}
return show(out, *asJSON, i.Agents, func() {
if len(i.Agents) == 0 {
- fmt.Fprintln(out, "No agent CLIs found on this box (claude, codex, opencode, gemini, cursor-agent).")
+ fmt.Fprintln(out, "No agent CLIs found on this box (claude, codex, opencode, gemini, cursor-agent, omp).")
return
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
diff --git a/internal/integrations/adapters/adapters.go b/internal/integrations/adapters/adapters.go
index a7ce3e97..b58af025 100644
--- a/internal/integrations/adapters/adapters.go
+++ b/internal/integrations/adapters/adapters.go
@@ -63,7 +63,7 @@ func For(name string) (*Adapter, bool) {
// All lists the adapters, for the API and docs.
func All() []*Adapter {
- return []*Adapter{Claude, Codex, Cursor, Gemini, OpenCode, Screen}
+ return []*Adapter{Claude, Codex, Cursor, Gemini, OpenCode, Omp, Screen}
}
// CapsFor is an agent's capabilities: the screen's for anything unknown.
diff --git a/internal/integrations/adapters/omp.go b/internal/integrations/adapters/omp.go
new file mode 100644
index 00000000..960f0ca1
--- /dev/null
+++ b/internal/integrations/adapters/omp.go
@@ -0,0 +1,64 @@
+package adapters
+
+// Omp reports through a small extension berth installs into oh-my-pi, which
+// runs the hook for the events below with {"cwd", "session_id"}.
+var Omp = register(&Adapter{
+ Name: "omp",
+ Caps: Caps{Ready: true, Started: true, Waiting: true, Finished: true, Via: "extension"},
+ Translate: func(hook string, in Payload) (string, map[string]any, bool) {
+ d := map[string]any{"path": in.Str("cwd"), "agent_session_id": in.Str("session_id")}
+ switch hook {
+ case "session_start":
+ return Ready, d, true
+ case "agent_start":
+ d["signal"] = "prompt"
+ return Started, d, true
+ case "tool_approval_requested":
+ d["reason"] = "permission"
+ return Waiting, d, true
+ case "tool_approval_resolved":
+ d["signal"] = "tool"
+ return Started, d, true
+ case "agent_end":
+ // An agent_end that will continue (a queued message, a retry)
+ // is not the turn's end.
+ if b, _ := in["will_continue"].(bool); b {
+ return "", nil, false
+ }
+ return Finished, d, true
+ case "session_shutdown":
+ return Exited, d, true
+ }
+ return "", nil, false
+ },
+})
+
+// OmpExtension is the extension file, for the berth binary at bin. oh-my-pi
+// loads every file in ~/.omp/agent/extensions. A subagent has no UI and is
+// not a session someone watches, so it reports nothing.
+func OmpExtension(bin string) string {
+ return `// Installed by berth: tells berth (hook omp) when an oh-my-pi turn starts, needs you, or ends.
+// Only the session ID and the working directory leave oh-my-pi.
+// @ts-nocheck
+const BERTH = ` + jsString(bin) + `;
+
+export default function (pi) {
+ const report = (event, ctx, extra) => {
+ if (!ctx || !ctx.hasUI) return;
+ let session_id = "";
+ try { session_id = ctx.sessionManager.getSessionId() || ""; } catch {}
+ const payload = JSON.stringify({ cwd: ctx.cwd || process.cwd(), session_id, ...extra });
+ try {
+ const proc = Bun.spawn({ cmd: [BERTH, "hook", "omp", event, payload], stdin: "ignore", stdout: "ignore", stderr: "ignore" });
+ proc.unref?.();
+ } catch {}
+ };
+ pi.on("session_start", (_e, ctx) => report("session_start", ctx));
+ pi.on("agent_start", (_e, ctx) => report("agent_start", ctx));
+ pi.on("tool_approval_requested", (_e, ctx) => report("tool_approval_requested", ctx));
+ pi.on("tool_approval_resolved", (_e, ctx) => report("tool_approval_resolved", ctx));
+ pi.on("agent_end", (e, ctx) => report("agent_end", ctx, { will_continue: !!(e && e.willContinue) }));
+ pi.on("session_shutdown", (_e, ctx) => report("session_shutdown", ctx));
+}
+`
+}
diff --git a/internal/integrations/command.go b/internal/integrations/command.go
index afbc4aa5..3de70c68 100644
--- a/internal/integrations/command.go
+++ b/internal/integrations/command.go
@@ -19,7 +19,7 @@ const Usage = `Integrations
%[1]s integrations [status]
Where Shipyard's hooks and skills are, per agent
and per Claude Code or Codex account
- %[1]s integrations install claude|cursor|codex|gemini|opencode|all|present
+ %[1]s integrations install claude|cursor|codex|gemini|opencode|omp|all|present
Install Shipyard's skills and agent hooks for a
tool, in every account folder it has
%[1]s hook TOOL EVENT [PAYLOAD]
@@ -79,7 +79,7 @@ func Install(args []string, bin string, out io.Writer) error {
return nil
}
if len(args) < 2 || args[0] != "install" {
- return errors.New("usage: integrations [status] | integrations install claude|cursor|codex|gemini|opencode|all|present")
+ return errors.New("usage: integrations [status] | integrations install claude|cursor|codex|gemini|opencode|omp|all|present")
}
tools := args[1:]
if len(tools) == 1 && tools[0] == "all" {
@@ -136,8 +136,23 @@ func InstallTool(home, tool, bin string, out io.Writer) error {
return err
}
fmt.Fprintf(out, "OpenCode: plugin %s at %s\n", verb(changed), plugin)
+ case "omp":
+ ext := filepath.Join(home, ".omp", "agent", "extensions", "berth.ts")
+ changed, err := InstallOmpExtension(ext, bin)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "oh-my-pi: extension %s at %s\n", verb(changed), ext)
+ if mcpBin(bin) {
+ mcp := filepath.Join(home, ".omp", "agent", "mcp.json")
+ changed, err := InstallMCP(mcp, bin, true)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintf(out, "oh-my-pi: MCP server %s in %s\n", verb(changed), mcp)
+ }
default:
- return fmt.Errorf("unknown tool %q; use claude, cursor, codex, gemini, opencode, or all", tool)
+ return fmt.Errorf("unknown tool %q; use claude, cursor, codex, gemini, opencode, omp, or all", tool)
}
return nil
}
@@ -191,7 +206,7 @@ func verb(changed bool) string {
}
// AllTools are the agents `integrations install all` covers.
-var AllTools = []string{"claude", "cursor", "codex", "gemini", "opencode"}
+var AllTools = []string{"claude", "cursor", "codex", "gemini", "opencode", "omp"}
var validSession = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$`)
diff --git a/internal/integrations/detect.go b/internal/integrations/detect.go
index faa668f2..0b099094 100644
--- a/internal/integrations/detect.go
+++ b/internal/integrations/detect.go
@@ -40,6 +40,8 @@ var Tools = []Tool{
hookFile: filepath.Join(".gemini", "settings.json"), hookMarker: "hook gemini AfterAgent"},
{ID: "opencode", Name: "OpenCode", Command: "opencode", configDir: filepath.Join(".config", "opencode"),
hookFile: filepath.Join(".config", "opencode", "plugin", "berth.js"), hookMarker: "hook opencode"},
+ {ID: "omp", Name: "oh-my-pi", Command: "omp", configDir: filepath.Join(".omp", "agent"),
+ hookFile: filepath.Join(".omp", "agent", "extensions", "berth.ts"), hookMarker: "hook omp"},
}
// ToolByID finds a tool by its ID.
diff --git a/internal/integrations/detect_test.go b/internal/integrations/detect_test.go
index 0d7d8994..02c35c87 100644
--- a/internal/integrations/detect_test.go
+++ b/internal/integrations/detect_test.go
@@ -40,7 +40,7 @@ func TestDetectFindsToolsOnPathAndInHome(t *testing.T) {
os.MkdirAll(filepath.Join(home, ".cursor"), 0o755)
present, missing := Detect(home)
- if ids(present) != "claude codex" || ids(missing) != "cursor gemini opencode" {
+ if ids(present) != "claude codex" || ids(missing) != "cursor gemini opencode omp" {
t.Fatalf("present %q, missing %q", ids(present), ids(missing))
}
}
@@ -66,7 +66,7 @@ func TestInstallDetectedInstallsOnlyWhatIsThereAndOnlyOnce(t *testing.T) {
"Claude Code: hooks and 7 skills in ~/.claude\n hooks added in ~/.claude/settings.json",
"Codex: hooks in ~/.codex; 7 skills in ~/.agents/skills",
"notify added in ~/.codex/config.toml",
- "Not found: Cursor Agent, Gemini CLI, OpenCode. After installing one, run: berthd integrations install cursor|gemini|opencode",
+ "Not found: Cursor Agent, Gemini CLI, OpenCode, oh-my-pi. After installing one, run: berthd integrations install cursor|gemini|opencode|omp",
} {
if !strings.Contains(first, want) {
t.Errorf("output missing %q:\n%s", want, first)
@@ -107,8 +107,8 @@ func TestInstallDetectedWithNoAgentsSaysHowToAddThemLater(t *testing.T) {
if err := InstallDetected(home, "/usr/local/bin/berthd", "/usr/local/bin/berthd", &out); err != nil {
t.Fatal(err)
}
- if !strings.Contains(out.String(), "No agent CLIs found (claude, codex, cursor-agent, gemini, opencode)") ||
- !strings.Contains(out.String(), "/usr/local/bin/berthd integrations install claude|codex|cursor|gemini|opencode") {
+ if !strings.Contains(out.String(), "No agent CLIs found (claude, codex, cursor-agent, gemini, opencode, omp)") ||
+ !strings.Contains(out.String(), "/usr/local/bin/berthd integrations install claude|codex|cursor|gemini|opencode|omp") {
t.Fatalf("output:\n%s", out.String())
}
if entries, _ := os.ReadDir(home); len(entries) != 0 {
diff --git a/internal/integrations/install.go b/internal/integrations/install.go
index 29a92ada..1fe5a792 100644
--- a/internal/integrations/install.go
+++ b/internal/integrations/install.go
@@ -102,6 +102,19 @@ func InstallOpenCodePlugin(path, bin string) (bool, error) {
return true, statefile.Write(path, want)
}
+// InstallOmpExtension writes berth's oh-my-pi extension. It is berth's own
+// file, so it is replaced when it differs.
+func InstallOmpExtension(path, bin string) (bool, error) {
+ want := []byte(adapters.OmpExtension(bin))
+ if have, err := os.ReadFile(path); err == nil && string(have) == string(want) {
+ return false, nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return false, err
+ }
+ return true, statefile.Write(path, want)
+}
+
// InstallCursorHooks appends berth's hook to Cursor's hooks file, after any
// hooks other tools (such as Orca) already registered there.
func InstallCursorHooks(hooksPath, bin string) (bool, error) {
diff --git a/internal/integrations/install_test.go b/internal/integrations/install_test.go
index 275e0595..abe48d9b 100644
--- a/internal/integrations/install_test.go
+++ b/internal/integrations/install_test.go
@@ -1,6 +1,7 @@
package integrations
import (
+ "bytes"
"encoding/json"
"os"
"path/filepath"
@@ -140,3 +141,69 @@ func TestNewAgentHooksInstallOnceAndKeepOtherSettings(t *testing.T) {
}
}
}
+
+func TestInstallOmpExtensionWritesBerthsFileOnce(t *testing.T) {
+ ext := filepath.Join(t.TempDir(), "agent", "extensions", "berth.ts")
+ if changed, err := InstallOmpExtension(ext, "/opt/berthd"); !changed || err != nil {
+ t.Fatalf("first install: %v %v", changed, err)
+ }
+ if changed, _ := InstallOmpExtension(ext, "/opt/berthd"); changed {
+ t.Fatal("extension rewritten unchanged")
+ }
+ b, _ := os.ReadFile(ext)
+ for _, want := range []string{`"/opt/berthd"`, "hook omp", "agent_start", "agent_end", "tool_approval_requested", "hasUI"} {
+ if !strings.Contains(string(b), want) {
+ t.Errorf("extension missing %q:\n%s", want, b)
+ }
+ }
+ if changed, _ := InstallOmpExtension(ext, "/usr/bin/berthd"); !changed {
+ t.Fatal("extension kept a stale berth path")
+ }
+}
+
+func TestInstallToolOmpAddsTheMCPServerAndKeepsOthers(t *testing.T) {
+ home := t.TempDir()
+ mcp := filepath.Join(home, ".omp", "agent", "mcp.json")
+ os.MkdirAll(filepath.Dir(mcp), 0o755)
+ os.WriteFile(mcp, []byte(`{"mcpServers":{"jev":{"type":"stdio","command":"jev"}},"disabledServers":["x"]}`), 0o600)
+ var out bytes.Buffer
+ if err := InstallTool(home, "omp", "/opt/berthd", &out); err != nil {
+ t.Fatal(err)
+ }
+ root := readJSON(t, mcp)
+ servers := root["mcpServers"].(map[string]any)
+ berth, _ := servers["berth"].(map[string]any)
+ if berth["command"] != "/opt/berthd" || berth["type"] != "stdio" || servers["jev"] == nil || root["disabledServers"] == nil {
+ t.Fatalf("mcp.json = %v", root)
+ }
+ out.Reset()
+ InstallTool(home, "omp", "/opt/berthd", &out)
+ if !strings.Contains(out.String(), "MCP server already present") {
+ t.Fatalf("second install changed the file: %s", out.String())
+ }
+}
+
+func TestInstallToolOmpSkipsTheMCPServerForTheLaptopBinary(t *testing.T) {
+ home := t.TempDir()
+ if err := InstallTool(home, "omp", "/opt/berth", &bytes.Buffer{}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(home, ".omp", "agent", "mcp.json")); err == nil {
+ t.Fatal("wrote an MCP server the laptop binary cannot serve")
+ }
+}
+
+func TestInstallToolOmpPutsTheExtensionInOmpsAgentFolder(t *testing.T) {
+ home := t.TempDir()
+ var out bytes.Buffer
+ if err := InstallTool(home, "omp", "/opt/berthd", &out); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(filepath.Join(home, ".omp", "agent", "extensions", "berth.ts")); err != nil {
+ t.Fatalf("extension not written: %v", err)
+ }
+ omp, _ := ToolByID("omp")
+ if !omp.Hooked(home) || !omp.Current(home) {
+ t.Fatal("omp is not hooked after install")
+ }
+}
diff --git a/internal/integrations/toolhook_test.go b/internal/integrations/toolhook_test.go
index 138bfd3e..7ec24390 100644
--- a/internal/integrations/toolhook_test.go
+++ b/internal/integrations/toolhook_test.go
@@ -144,6 +144,12 @@ func TestEveryAgentAdapterMapsItsTurn(t *testing.T) {
{"opencode", "session.busy", `{"cwd":"/w","session_id":"o"}`, "agent.started"},
{"opencode", "permission.updated", `{"cwd":"/w"}`, "agent.waiting"},
{"opencode", "session.idle", `{"cwd":"/w"}`, "agent.finished"},
+ {"omp", "session_start", `{"cwd":"/w","session_id":"p"}`, "agent.ready"},
+ {"omp", "agent_start", `{"cwd":"/w","session_id":"p"}`, "agent.started"},
+ {"omp", "tool_approval_requested", `{"cwd":"/w"}`, "agent.waiting"},
+ {"omp", "tool_approval_resolved", `{"cwd":"/w"}`, "agent.started"},
+ {"omp", "agent_end", `{"cwd":"/w"}`, "agent.finished"},
+ {"omp", "session_shutdown", `{"cwd":"/w"}`, "agent.exited"},
} {
e, ok := Translate(tc[0], tc[1], []byte(tc[2]))
if !ok || e.Type != tc[3] || e.Data["path"] != "/w" {
@@ -151,3 +157,12 @@ func TestEveryAgentAdapterMapsItsTurn(t *testing.T) {
}
}
}
+
+func TestOmpAgentEndThatContinuesIsNotATurnEnd(t *testing.T) {
+ if _, ok := Translate("omp", "agent_end", []byte(`{"cwd":"/w","will_continue":true}`)); ok {
+ t.Fatal("agent_end with will_continue was announced as finished")
+ }
+ if _, ok := Translate("omp", "tool_execution_start", []byte(`{"cwd":"/w"}`)); ok {
+ t.Fatal("an event berth does not use was announced")
+ }
+}
diff --git a/plugins/activity/src/index.tsx b/plugins/activity/src/index.tsx
index bf391b4d..94dc7e60 100644
--- a/plugins/activity/src/index.tsx
+++ b/plugins/activity/src/index.tsx
@@ -62,7 +62,7 @@ function where(e: BerthEvent, names?: Names): string {
return path ? path.split("/").filter(Boolean).slice(-1)[0] : "";
}
-const AGENTS: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", cursor: "Cursor" };
+const AGENTS: Record = { claude: "Claude", codex: "Codex", opencode: "OpenCode", gemini: "Gemini", omp: "oh-my-pi", cursor: "Cursor" };
const agentName = (a: string) => AGENTS[a] ?? (a || "An agent");
interface Line {