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 {