From 66ebca98faed72cb53faed34344e3aca413abdd7 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 13 Oct 2026 17:48:55 -0400 Subject: [PATCH 1/2] feat(box): oh-my-pi runs as a built-in agent, over ACP, with hooks that say when it needs you Adds omp to the agent presets (model and thinking flags, acp), the box's agent lookup, the integrations installer (an extension in ~/.omp/agent/extensions that reports session, turn and approval events) and the app's agent names. --- app/src/components/agent-glyph.tsx | 2 + app/src/lib/derive.ts | 4 +- app/src/lib/session-open.ts | 2 +- app/src/views/automations/flows/model.ts | 2 +- app/src/views/settings/agents-section.tsx | 2 + app/src/views/settings/labs-section.tsx | 2 +- docs/changelog.mdx | 10 ++++ docs/concepts/sessions-and-agents.mdx | 6 +- docs/guides/agent-integrations.mdx | 5 +- docs/guides/labs.mdx | 4 +- docs/guides/project-config.mdx | 1 + docs/reference/berthd.mdx | 2 +- docs/reference/cli.mdx | 2 +- docs/reference/config.mdx | 4 +- internal/agentcli/agentcli.go | 3 + internal/agentpath/agentpath.go | 2 +- internal/box/agentpaths.go | 2 +- internal/box/omp_test.go | 32 +++++++++++ internal/box/phoneui/app.js | 4 +- internal/box/services.go | 2 +- internal/box/tasks.go | 6 +- internal/box/tmux.go | 1 + internal/boxcmd/boxcmd.go | 2 +- internal/integrations/adapters/adapters.go | 2 +- internal/integrations/adapters/omp.go | 64 ++++++++++++++++++++++ internal/integrations/command.go | 15 +++-- internal/integrations/detect.go | 2 + internal/integrations/detect_test.go | 8 +-- internal/integrations/install.go | 13 +++++ internal/integrations/install_test.go | 35 ++++++++++++ internal/integrations/toolhook_test.go | 15 +++++ plugins/activity/src/index.tsx | 2 +- 32 files changed, 227 insertions(+), 31 deletions(-) create mode 100644 internal/box/omp_test.go create mode 100644 internal/integrations/adapters/omp.go 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 52d65388..06be5e38 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -9,6 +9,16 @@ 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; a box that has omp gets it + with the other agents' hooks. + ## v0.3.17 - **Labs: OpenCode, Gemini CLI and Cursor Agent run structured too.** With 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..697b06e5 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,9 @@ 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. ### 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 6fa80c3e..287f9f75 100644 --- a/docs/reference/berthd.mdx +++ b/docs/reference/berthd.mdx @@ -236,7 +236,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 and Gemini CLI. 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 ba7168bb..1d0848cd 100644 --- a/internal/boxcmd/boxcmd.go +++ b/internal/boxcmd/boxcmd.go @@ -547,7 +547,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..adf0685c 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,15 @@ 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) 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 +198,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..9c0ee704 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,37 @@ 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 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 { From 3ca591f82ebf78162e254238e6db914f9a2c5fc3 Mon Sep 17 00:00:00 2001 From: Samuel Reed Date: Tue, 13 Oct 2026 23:28:06 -0400 Subject: [PATCH 2/2] feat(box): oh-my-pi gets the berth MCP server, so it can drive other sessions omp reads user MCP servers from ~/.omp/agent/mcp.json, in the same mcpServers shape as Claude Code. integrations install omp now adds berthd mcp there on a box, keeping servers already in the file, and leaves it alone for the laptop binary. --- docs/changelog.mdx | 5 +++-- docs/guides/agent-integrations.mdx | 4 +++- docs/reference/berthd.mdx | 4 ++-- internal/integrations/command.go | 8 +++++++ internal/integrations/install_test.go | 32 +++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 06be5e38..dc42b6f7 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -16,8 +16,9 @@ again. 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; a box that has omp gets it - with the other agents' hooks. + 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. ## v0.3.17 diff --git a/docs/guides/agent-integrations.mdx b/docs/guides/agent-integrations.mdx index 697b06e5..51e8a16a 100644 --- a/docs/guides/agent-integrations.mdx +++ b/docs/guides/agent-integrations.mdx @@ -46,7 +46,9 @@ with the command that installs them. 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. + 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/reference/berthd.mdx b/docs/reference/berthd.mdx index 287f9f75..cdb10615 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), @@ -236,7 +236,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\|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 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/internal/integrations/command.go b/internal/integrations/command.go index adf0685c..3de70c68 100644 --- a/internal/integrations/command.go +++ b/internal/integrations/command.go @@ -143,6 +143,14 @@ func InstallTool(home, tool, bin string, out io.Writer) error { 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, omp, or all", tool) } diff --git a/internal/integrations/install_test.go b/internal/integrations/install_test.go index 9c0ee704..abe48d9b 100644 --- a/internal/integrations/install_test.go +++ b/internal/integrations/install_test.go @@ -161,6 +161,38 @@ func TestInstallOmpExtensionWritesBerthsFileOnce(t *testing.T) { } } +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