diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f0aeb69..60dbd565b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +- Added the OMP (`omp`) harness to agent-manager, CLI start, Tmux runtimes, and the Rust daemon; profile aliases are represented as repeatable structured `--arg` values rather than shell-evaluated aliases. - [3f90d45](https://github.com/codeaholicguy/ai-devkit/commit/3f90d45) Added the built-in `dev-retro` skill: an evidence-based retrospective that turns friction into durable fixes, automation first. `dev-lifecycle` and `dev-debug` offer it and run it only if the user agrees. - [fdbfb3a](https://github.com/codeaholicguy/ai-devkit/commit/fdbfb3a) Wired `dev-smoke` into other skills: `verify` requires it for "feature works" claims on user-facing changes, `dev-refactor` and `dev-simplify` compare runs before and after, `dev-debug` uses it as the repro, and `dev-lifecycle` runs it before code review. diff --git a/README.md b/README.md index be78061dc..97be6d144 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ ai-devkit agent send "review this branch for release risk" --group reviewers # Pipe a session through Telegram — operate your agent from your phone ai-devkit channel start telegram --agent --daemon +# Start an OMP profile with structured arguments (no shell alias evaluation) +ai-devkit agent start --type omp --arg --profile --arg work ``` Use this when work spans long-running agents, multiple providers, scheduled checks, review loops, or remote control from another channel. @@ -213,6 +215,7 @@ One `.ai-devkit.json` configures all of them. Add a new agent to your team witho | [Grok Build CLI](https://x.ai/cli) | yes | yes (`grok_cli`) | | [opencode](https://opencode.ai/) | yes | yes (`opencode`) | | [Pi](https://pi.dev) | yes | yes (`pi`) | +| [oh-my-pi](https://github.com/can1357/oh-my-pi) | yes | yes (`omp`) | | [GitHub Copilot](https://github.com/features/copilot) | yes | yes (`copilot`) | | [Kiro CLI](https://kiro.dev/cli/) | yes | yes (`kiro`) | | [Devin](https://devin.ai/) | yes | yes (`devin`) | diff --git a/docs/ai/design/feature-omp-adapter.md b/docs/ai/design/feature-omp-adapter.md new file mode 100644 index 000000000..3f3e7b469 --- /dev/null +++ b/docs/ai/design/feature-omp-adapter.md @@ -0,0 +1,83 @@ +--- +phase: design +title: "OMP Agent Harness Support - Design" +feature: omp-adapter +description: Architecture for OMP process detection, session mapping, profile arguments, and daemon parity +--- + +# Design: OMP Agent Harness Support + +## Architecture Overview + +```mermaid +graph TD + CLI[agent start --type omp --arg ...] --> Runtime[local or devkitd runtime] + Runtime --> Tmux[Tmux command: omp --profile name] + AgentManager --> OmpAdapter[OmpAdapter] + Devkitd --> RustOmp[Rust OMP adapter] + OmpAdapter --> Sessions[~/.omp/agent/sessions/**/*.jsonl] + RustOmp --> Sessions + Sessions --> SharedParser[Pi-compatible JSONL parser] + SharedParser --> AgentInfo +``` + +OMP uses the Pi-compatible JSONL message envelope but has an independent storage root and project-directory encoding. The adapter reuses the existing parser/matching primitives where their contracts fit, while keeping `type: "omp"` and OMP paths explicit. + +## Data Model + +- Add `"omp"` to `AGENT_TYPES` and `AgentType`. +- OMP session root: `~/.omp/agent/sessions` by default. +- OMP project bucket: the observed form is `-` (for example with `$HOME=/home/me`, `/home/me/repo` → `-repo`). Matching compares encoded live CWD values; it does not lossy-decode bucket names. +- `AgentInfo.type` is `"omp"`; `sessionFilePath` points to the JSONL file; `sessionId`, project path, summary, status, and timestamps follow the shared Pi-compatible record semantics. + +## TypeScript Components + +- `OmpAdapter`: precise OMP process matching, live session matching, process-only fallback, historical listing, and conversation access. +- Reuse `PiSessionLocator` with injected OMP root/encoder and reuse the Pi parser; this avoids duplicating schema and incremental-scan logic while keeping OMP paths explicit. +- `runtimeProfiles.ts`: add the OMP command, runtime kind, discovery aliases, and Bun/CLI matcher. +- `ManagedAgentRuntime`: pass structured args to the runtime and compose a quoted Tmux command. +- CLI: add repeatable `--arg ` and forward it through daemon and local paths. + +## Rust Components + +- `devkit-harness::omp`: mirror process matching, session root, locator, parser mapping, and process-only fallback. +- Register OMP in `default_registry`. +- Add an OMP `StartProfile` with a matcher that recognizes the OMP Bun CLI path. +- `devkitd` Tmux runtime composes profile command plus validated argv arguments using the same deterministic shell quoting contract as TypeScript. + +## Profile Launch Contract + +An alias such as: + +```bash +alias omp-work='omp --profile work' +``` + +is represented as: + +```text +--type omp --arg --profile --arg work +``` + +AI DevKit MUST NOT invoke `bash -ic` or evaluate alias text. This avoids dependence on interactive shell startup files and avoids arbitrary shell injection through the vendor configuration. + +## Matching Contract + +- Accept the direct `omp` executable. +- Accept Bun commands whose argv contains the OMP CLI entry path and `omp`/`pi-coding-agent` package marker. +- Reject unrelated `bun`, `node`, and arbitrary scripts. +- Start-profile matching and discovery matching use the same OMP identity rule. + +## Design Decisions + +- OMP is a first-class built-in type, not a generic arbitrary-vendor registry. +- Structured arguments are additive and generic enough for OMP profiles; shell aliases are documentation-only conveniences. +- Durable mode is explicitly excluded until OMP print/resume semantics are verified. +- The Rust daemon mirrors the TypeScript adapter because daemon results suppress local adapter discovery when non-empty. + +## Security and Performance + +- Session files are read only below the configured/default OMP root. +- JSONL parsing skips malformed records and bounds head reads using existing utilities. +- Tmux arguments are quoted as argv values; no user-provided shell fragment is evaluated. +- Discovery remains bounded to OMP `*.jsonl` files and uses existing incremental summaries. diff --git a/docs/ai/implementation/feature-omp-adapter.md b/docs/ai/implementation/feature-omp-adapter.md new file mode 100644 index 000000000..c89b3e3e1 --- /dev/null +++ b/docs/ai/implementation/feature-omp-adapter.md @@ -0,0 +1,38 @@ +--- +phase: implementation +title: "OMP Agent Harness Support - Implementation" +feature: omp-adapter +description: Implementation notes, decisions, deviations, and verification evidence for OMP support +--- + +# Implementation: OMP Agent Harness Support + +- Phase: Check Implementation +- Branch: `feature-omp-adapter` +- Worktree: `/tmp/ai-devkit-omp-worktree` +- Current task: align implementation records with the completed TypeScript/Rust changes and verification queue. + +## Changed Files + +- Agent-manager: OMP adapter, Pi locator parameterization, type/registry/runtime/readiness integration, Tmux argument quoting, Bun `/bin/omp` launcher detection, and focused tests. +- CLI: `agent start --arg`, TUI structured argument forwarding, labels, status/session/start tests. +- Rust: OMP adapter/registry/start profile, Bun `/bin/omp` launcher detection, and daemon Tmux argument quoting/tests. +- Project docs: README, CHANGELOG, and readiness fixtures. + +## Decisions + +- Structured OMP profile arguments replace shell alias execution. +- Rust `devkitd` support is required because non-empty daemon discovery is authoritative. + +- OMP project buckets use the observed home-relative encoding; the default root is `~/.omp/agent/sessions`. +- Registry session paths are accepted only when they remain below the OMP sessions root. +- OMP durable mode remains out of scope; arbitrary shell aliases are never evaluated. + +## Verification Evidence + +- Preflight: `npx ai-devkit@latest lint` and `npx ai-devkit@latest lint --feature omp-adapter` passed. +- Setup/build: `npm ci`, `npm run build`, `npm run lint`, and TypeScript formatting passed; the local rustup toolchain lacked an applicable `rustfmt` component for the final Rust format check. +- Tests: full Nx test targets and `cargo test --workspace` passed after the launcher-shape fix. +- Smoke: installed OMP 18.12.6 launches as `bun .../bin/omp`; the cockpit now recognizes that real process shape. +- Cockpit smoke: daemon-backed `agent start --type omp` succeeded with a configured model, `agent list --json` reported `type: "omp"`, and `agent kill` stopped the OMP PID. `agent detail` is unavailable for a sessionless OMP process, and kill leaves the empty tmux shell until the daemon refreshes/cleanup path handles it. +- Initial baseline before the build had one Pi live-discovery failure; the final full suite passed after the launcher-shape fix. diff --git a/docs/ai/planning/feature-omp-adapter.md b/docs/ai/planning/feature-omp-adapter.md new file mode 100644 index 000000000..c53e8e12c --- /dev/null +++ b/docs/ai/planning/feature-omp-adapter.md @@ -0,0 +1,54 @@ +--- +phase: planning +title: "OMP Agent Harness Support - Plan" +feature: omp-adapter +description: Ordered implementation plan for OMP support +--- + +# Plan: OMP Agent Harness Support + +## Milestones + +- [x] M1: OMP requirements/design and red tests established. +- [x] M2: TypeScript and Rust OMP discovery implemented. +- [x] M3: Structured profile arguments work through CLI, local Tmux, and devkitd. +- [x] M4: Verification complete and upstream pull request opened. + +## Task Breakdown + +### Foundation + +- [x] Add failing TypeScript tests for type registration, process matching, session mapping, and start arguments. +- [x] Add failing Rust tests for registry/start-profile parity. + +### Core Features + +- [x] Add TypeScript OMP adapter and exports/registration. +- [x] Add OMP runtime profile and labels/type filters. +- [x] Implement structured `--arg` propagation and deterministic Tmux quoting. +- [x] Add Rust OMP adapter, registry registration, and start profile. + +### Integration and Polish + +- [x] Update tests for CLI, console action forwarding, daemon wire arguments, and Rust runtime behavior. +- [x] Update README/CHANGELOG and implementation/testing records. +- [x] Run targeted tests, environment smoke tests, full relevant checks, review, commit, push, and open PR. + +## Dependencies + +- OMP executable is installed for environment smoke testing; CI uses fixtures. +- Rust daemon must be rebuilt before daemon smoke testing. +- Existing Pi parser behavior must remain green. + +## Risks and Mitigations + +- Bun command ambiguity: require OMP CLI path/package marker, not basename `bun` alone. +- Alias availability: model aliases as executable plus argv, never shell-evaluate aliases. +- TS/Rust drift: add matching fixtures and parity assertions in both test suites. +- Existing flaky Pi live-discovery test: record separately; do not widen OMP scope to fix it. + +- Queue update 2: production implementation and focused regression fixes complete; broad verification and PR remain. +- Current queue: Foundation `done`; Core Features `done`; Integration `in-progress`. +- Baseline: preflight lint/build passed; baseline full tests had one existing Pi live-discovery failure. +- Queue update 3: targeted/full verification, formatting, lint, and OMP smoke checks complete; commit and PR submission are the remaining actions. +- Queue update 4: commit `f95df21` pushed; PR #422 opened against `codeaholicguy/ai-devkit:main`. diff --git a/docs/ai/requirements/feature-omp-adapter.md b/docs/ai/requirements/feature-omp-adapter.md new file mode 100644 index 000000000..17ac3670c --- /dev/null +++ b/docs/ai/requirements/feature-omp-adapter.md @@ -0,0 +1,53 @@ +--- +phase: requirements +title: "OMP Agent Harness Support - Requirements" +feature: omp-adapter +description: Requirements for managing oh-my-pi (OMP) sessions and structured profile launches in AI DevKit +--- + +# Requirements: OMP Agent Harness Support + +## Problem Statement + +AI DevKit can manage Pi-compatible agents, but oh-my-pi (OMP) is a separate executable with a different session root and Bun-launched process shape. OMP profile aliases such as `omp-work='omp --profile work'` are also not reliable launch identities inside non-interactive Tmux or daemon shells. + +## Goals + +- Detect and manage OMP interactive processes through the same agent-manager surface as other harnesses. +- Discover OMP JSONL sessions from the default `~/.omp/agent/sessions` tree. +- Support profile launches as structured executable arguments, without evaluating shell aliases. +- Keep TypeScript local discovery and Rust `devkitd` discovery behavior aligned. +- Preserve existing Pi behavior and public APIs except for additive OMP/type support. + +## Non-goals + +- Executing arbitrary shell aliases or shell command strings. +- OMP durable/print-mode support in this change. +- OMP protocol changes or modifying the OMP package. +- A general third-party vendor plugin registry. + +## User Stories + +- As a user, I can list running OMP sessions with `agent list`. +- As a user, I can inspect OMP conversation history and historical sessions. +- As a user, I can send a prompt, wait for completion, focus, and stop an OMP agent. +- As a user, I can start a profile using `agent start --type omp --arg --profile --arg work` or the console's structured arguments field. +- As a user, OMP agents remain visible when `devkitd` is authoritative. + +## Acceptance Criteria + +1. `AgentType` and all exhaustive built-in type tables include `omp`. +2. OMP matching accepts the OMP CLI/Bun process shape and rejects unrelated Bun/Node processes. +3. OMP sessions are read only below the resolved OMP sessions root; malformed records do not fail discovery. +4. OMP summaries, status, conversation detail, and historical listing work for the installed session format. +5. Local and daemon discovery produce the same OMP agent type and session mapping. +6. Structured arguments reach both local Tmux and daemon Tmux launches without shell alias evaluation. +7. Tests cover positive/negative process matching, session mapping, argument propagation, and Rust/TypeScript parity. +8. Existing supported harness behavior remains unchanged. + +## Constraints and Risks + +- OMP is Bun-launched, so matching must identify the OMP CLI path rather than every Bun process. +- OMP's default storage root is `~/.omp/agent`; `PI_CODING_AGENT_DIR` may override it. This change supports the default root and documents the environment-root limitation if the existing adapter contract cannot safely inject it. +- Tmux receives a shell command string; arguments must be quoted deterministically. +- The daemon's authoritative result means Rust support is required, not optional. diff --git a/docs/ai/testing/feature-omp-adapter.md b/docs/ai/testing/feature-omp-adapter.md new file mode 100644 index 000000000..a98acbe21 --- /dev/null +++ b/docs/ai/testing/feature-omp-adapter.md @@ -0,0 +1,48 @@ +--- +phase: testing +title: "OMP Agent Harness Support - Testing" +feature: omp-adapter +description: Test strategy and evidence for OMP detection, sessions, profile args, and daemon parity +--- + +# Testing: OMP Agent Harness Support + +## Coverage Goals + +- Cover all new OMP branches that affect process ownership, path safety, session mapping, and status. +- Cover structured arguments from CLI/console to both local and daemon Tmux runtimes. +- Keep TypeScript and Rust process matching behavior equivalent. +- Use fixtures for CI; use the installed OMP binary only for no-model smoke checks. + +## Unit Tests + +- [x] `omp` is present in exhaustive agent type lists, labels, adapters, and session filters. +- [x] OMP matcher accepts direct CLI, Bun-launched `dist/cli.js`, and the installed Bun `/bin/omp` shim. +- [x] OMP matcher rejects unrelated Bun/Node processes and paths containing only incidental `omp` text. +- [x] OMP session locator maps the observed project bucket and strict CWD. +- [x] OMP parser maps user/assistant messages, summaries, timestamps, and status through the Pi-compatible parser. +- [x] OMP adapter returns process-only agents when sessions are unavailable. +- [x] Tmux command quoting preserves each structured argument. +- [x] Rust registry/start profile agrees with TypeScript OMP identity rules. + +## Integration and E2E + +- [x] Agent-manager focused/package tests pass. +- [x] CLI focused/package tests pass, including daemon and local start argument forwarding. +- [x] Rust workspace and daemon tests pass. +- [x] Isolated HOME smoke lists a fixture OMP session. +- [x] Installed `omp --version`/`omp --help` smoke confirms the environment's executable and profile flag without invoking a model. +- [x] Live cockpit smoke starts a model-configured OMP in tmux, discovers it as `type: "omp"`, and stops its process through `agent kill`. + +## Known Baseline + +- Sessionless OMP processes are intentionally process-only; `agent detail` requires an OMP JSONL session file. +- Daemon-backed `agent kill` terminates the OMP PID, but the managed tmux shell remains until explicit cleanup. This is runtime lifecycle behavior shared by the daemon start path, not OMP process detection. +- After building workspace packages, the baseline test suite had one existing failure in `PiDiscoveryIO.test.ts` where a newly added Pi session was not rechecked. This is tracked separately from OMP work. + +- Focused TypeScript: agent-manager OMP/readiness/runtime/Tmux tests and CLI command/status/TUI/session tests passed. +- Full Nx test run: all 8 project test targets passed after the installed-launcher matcher fix; agent-manager reported 93 files/1380 tests and CLI reported 111 files/1350 tests. +- Rust: `cargo test --workspace` passed after the installed-launcher matcher fix; the repository's `cargo fmt` check was unavailable because the local rustup toolchain lacks an applicable `rustfmt` component. +- Smoke: built CLI `agent list --json` returned an OMP entry; installed `omp/18.12.6` exposes `--profile`; managed cockpit start/list/kill passed for a configured OMP process. + +## Evidence Log diff --git a/fixtures/readiness/degraded.json b/fixtures/readiness/degraded.json index 1f2eb3179..df41f349b 100644 --- a/fixtures/readiness/degraded.json +++ b/fixtures/readiness/degraded.json @@ -564,6 +564,39 @@ ] }, "status": "fail" + }, + "omp": { + "type": "omp", + "executable": { + "command": "omp", + "path": null, + "status": "fail", + "errors": [ + "omp was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.omp", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": null, + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "fail" } } } diff --git a/fixtures/readiness/healthy.json b/fixtures/readiness/healthy.json index f9c7b7170..0473bdce3 100644 --- a/fixtures/readiness/healthy.json +++ b/fixtures/readiness/healthy.json @@ -523,6 +523,39 @@ "errors": [] }, "status": "pass" + }, + "omp": { + "type": "omp", + "executable": { + "command": "omp", + "path": null, + "status": "fail", + "errors": [ + "omp was not found on PATH" + ] + }, + "globalConfig": { + "path": "~/.omp", + "present": false, + "readable": false, + "status": "fail", + "errors": [ + "global configuration directory is unavailable" + ] + }, + "builtInSkills": { + "path": null, + "required": 3, + "present": 0, + "missing": [ + "agent-management", + "memory", + "verify" + ], + "status": "info", + "errors": [] + }, + "status": "fail" } } } diff --git a/packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts b/packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts new file mode 100644 index 000000000..fef1e24f4 --- /dev/null +++ b/packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts @@ -0,0 +1,156 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentStatus, type ProcessInfo } from "../../../adapters/AgentAdapter.js"; +import { OmpAdapter } from "../../../harnesses/omp/OmpAdapter.js"; +import { AgentRegistry } from "../../../utils/AgentRegistry.js"; + +describe("OmpAdapter", () => { + let home: string; + let cwd: string; + let sessionsDir: string; + let previousHome: string | undefined; + beforeEach(() => { + previousHome = process.env.HOME; + home = fs.mkdtempSync(path.join(os.tmpdir(), "omp-adapter-")); + cwd = path.join(home, "Documents", "repo"); + sessionsDir = path.join(home, ".omp", "agent", "sessions", "-Documents-repo"); + fs.mkdirSync(sessionsDir, { recursive: true }); + process.env.HOME = home; + }); + + afterEach(() => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("exposes the omp type", () => { + expect(new OmpAdapter(new AgentRegistry(path.join(home, "registry.db"))).type).toBe("omp"); + }); + + it("matches direct and Bun-launched OMP commands but rejects unrelated runtimes", () => { + const adapter = new OmpAdapter(new AgentRegistry(path.join(home, "registry.db"))); + expect(adapter.canHandle(makeProcess("omp --profile work"))).toBe(true); + expect( + adapter.canHandle( + makeProcess( + "/home/me/.bun/bin/bun /home/me/.npm/@oh-my-pi/pi-coding-agent/dist/cli.js --profile work", + ), + ), + ).toBe(true); + expect( + adapter.canHandle( + makeProcess( + "bun /home/me/.nvm/versions/node/v24.15.0/bin/omp --no-session --mode rpc --no-ui", + ), + ), + ).toBe(true); + expect(adapter.canHandle(makeProcess("bun /repo/tools/cli.js"))).toBe(false); + expect(adapter.canHandle(makeProcess("node /repo/tool.js omp"))).toBe(false); + expect(adapter.canHandle(makeProcess("node /repo/omp-helper.js"))).toBe(false); + }); + + it("lists and reads OMP sessions from the home-relative project bucket", async () => { + const sessionPath = path.join( + sessionsDir, + "2026-10-27T08-50-14-737Z_01a17c8d-9051-7198-8fbd-3a2e507ce9a4.jsonl", + ); + fs.writeFileSync( + sessionPath, + [ + JSON.stringify({ type: "session", version: 3, id: "omp-session", cwd }), + JSON.stringify({ + type: "message", + message: { role: "user", content: [{ type: "text", text: "profile task" }] }, + }), + JSON.stringify({ + type: "message", + message: { role: "assistant", content: [{ type: "text", text: "done" }] }, + }), + ].join("\n") + "\n", + ); + + const adapter = new OmpAdapter(new AgentRegistry(path.join(home, "registry.db"))); + const sessions = await adapter.listSessions({ cwd }); + expect(sessions).toHaveLength(1); + expect(sessions[0]).toMatchObject({ + type: "omp", + cwd, + sessionId: "omp-session", + firstUserMessage: "profile task", + }); + expect(adapter.getConversation(sessionPath)).toEqual([ + expect.objectContaining({ role: "user", content: "profile task" }), + expect.objectContaining({ role: "assistant", content: "done" }), + ]); + }); + + it("maps a running OMP process through its registered session", async () => { + const sessionPath = path.join( + sessionsDir, + "2026-10-27T08-50-14-737Z_01a17c8d-9051-7198-8fbd-3a2e507ce9a4.jsonl", + ); + fs.writeFileSync( + sessionPath, + `${JSON.stringify({ type: "session", version: 3, id: "omp-session", cwd })}\n${JSON.stringify({ type: "message", message: { role: "user", content: "hello" } })}\n`, + ); + const registry = new AgentRegistry(path.join(home, "registry.db")); + registry.register({ + name: "omp-work", + type: "omp", + pid: 42, + runtime: "tmux", + runtimeRef: { session: "omp-work" }, + cwd, + startedAt: new Date().toISOString(), + sessionId: "omp-session", + sessionFilePath: sessionPath, + pinned: false, + }); + + const adapter = new OmpAdapter(registry); + const agents = await adapter.detectAgents({ + processes: [makeProcess("omp --profile work", 42)], + }); + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + type: "omp", + pid: 42, + status: AgentStatus.RUNNING, + sessionId: "omp-session", + }); + }); + it("does not trust a registered session path outside the OMP root", async () => { + const outsidePath = path.join(home, "outside.jsonl"); + fs.writeFileSync(outsidePath, "{}\n"); + const registry = new AgentRegistry(path.join(home, "registry.db")); + registry.register({ + name: "omp-work", + type: "omp", + pid: 43, + runtime: "tmux", + runtimeRef: { session: "omp-work" }, + cwd, + startedAt: new Date().toISOString(), + sessionId: "outside-session", + sessionFilePath: outsidePath, + pinned: false, + }); + + const agents = await new OmpAdapter(registry).detectAgents({ + processes: [makeProcess("omp --profile work", 43)], + }); + expect(agents[0]).toMatchObject({ + type: "omp", + pid: 43, + summary: "OMP process running", + }); + expect(agents[0].sessionFilePath).toBeUndefined(); + }); + + function makeProcess(command: string, pid = 1): ProcessInfo { + return { pid, command, cwd, tty: "pts/0", startTime: new Date() }; + } +}); diff --git a/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts b/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts index 39ed9a45d..e88e937fd 100644 --- a/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts @@ -20,6 +20,7 @@ const skillRoots: Record = { antigravity_cli: ".gemini/config/skills", opencode: ".config/opencode/skill", pi: ".pi/agent/skills", + omp: ".omp/agent/skills", }; function fixture(overrides: Partial = {}) { @@ -70,6 +71,7 @@ function fixture(overrides: Partial = {}) { ".config/devin", ".config/muse", ".pi", + ".omp", ]) { files[path.join(homeDir, directory)] = ""; } @@ -128,6 +130,7 @@ describe("agent readiness", () => { "opencode", "copilot", "pi", + "omp", "devin", "muse", ]); diff --git a/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts b/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts index a3634c85a..b89d28b91 100644 --- a/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts @@ -31,6 +31,27 @@ describe("harness runtime profiles", () => { ).toBe(true); }); + it("recognizes OMP's direct and Bun-launched CLI identities", () => { + expect(HARNESS_RUNTIME_PROFILES.omp).toMatchObject({ + command: "omp", + runtimeKind: "omp", + discoveryAliases: ["omp"], + }); + expect(HARNESS_RUNTIME_PROFILES.omp.matches("omp --profile work")).toBe(true); + expect( + HARNESS_RUNTIME_PROFILES.omp.matches( + "/home/me/.bun/bin/bun /home/me/.npm/@oh-my-pi/pi-coding-agent/dist/cli.js --profile work", + ), + ).toBe(true); + expect( + HARNESS_RUNTIME_PROFILES.omp.matches( + "bun /home/me/.nvm/versions/node/v24.15.0/bin/omp --no-session --mode rpc --no-ui", + ), + ).toBe(true); + expect(HARNESS_RUNTIME_PROFILES.omp.matches("bun /repo/tools/cli.js")).toBe(false); + expect(HARNESS_RUNTIME_PROFILES.omp.matches("node /repo/tool.js omp")).toBe(false); + }); + it("matches runtime discovery names through the owning harness profile", () => { expect(runtimeAgentMatchesHarness("kiro-cli", "kiro")).toBe(true); expect(runtimeAgentMatchesHarness("gemini", "gemini_cli")).toBe(true); diff --git a/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts index af1d48bb2..d3a81282f 100644 --- a/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts +++ b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts @@ -415,6 +415,23 @@ describe("startAgent", () => { ); }); + it("passes structured harness arguments to the runtime", async () => { + const runtime = makeRuntime(); + const registry = makeRegistry(); + + await startAgent( + { ...startOpts, type: "omp", args: ["--profile", "work"], runtimeProvider: "herdr" }, + { runtime, registry }, + ); + + expect(runtime.startAgent).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "omp", + args: ["--profile", "work"], + }), + ); + }); + it("fails Herdr starts when the configured runtime is unavailable", async () => { const runtime = makeRuntime({ isAvailable: vi.fn().mockResolvedValue({ diff --git a/packages/agent-manager/src/__tests__/terminal/TmuxManager.test.ts b/packages/agent-manager/src/__tests__/terminal/TmuxManager.test.ts index c502f065b..69a262eb1 100644 --- a/packages/agent-manager/src/__tests__/terminal/TmuxManager.test.ts +++ b/packages/agent-manager/src/__tests__/terminal/TmuxManager.test.ts @@ -74,6 +74,16 @@ describe("TmuxManager", () => { expect.any(Function), ); }); + + it("quotes structured arguments without evaluating shell fragments", async () => { + setExecFileHandler(() => ""); + await tmux.sendKeys("foo", "omp", ["--profile", "work profile"]); + expect(mockedExecFile).toHaveBeenCalledWith( + "tmux", + ["send-keys", "-t", "foo", "omp --profile 'work profile'", "Enter"], + expect.any(Function), + ); + }); }); describe("killSession", () => { diff --git a/packages/agent-manager/src/adapters/AgentAdapter.ts b/packages/agent-manager/src/adapters/AgentAdapter.ts index 0c60d3af5..cee3f483d 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -16,6 +16,7 @@ export const AGENT_TYPES = [ "opencode", "copilot", "pi", + "omp", "devin", "muse", ] as const; diff --git a/packages/agent-manager/src/harnesses/index.ts b/packages/agent-manager/src/harnesses/index.ts index d87b3993c..1af16d1a7 100644 --- a/packages/agent-manager/src/harnesses/index.ts +++ b/packages/agent-manager/src/harnesses/index.ts @@ -8,6 +8,7 @@ import { GrokCliAdapter } from "./grok/GrokCliAdapter.js"; import { KiroAdapter } from "./kiro/KiroAdapter.js"; import { OpenCodeAdapter } from "./opencode/OpenCodeAdapter.js"; import { PiAdapter } from "./pi/PiAdapter.js"; +import { OmpAdapter } from "./omp/OmpAdapter.js"; import { DevinAdapter } from "./devin/DevinAdapter.js"; import { MuseAdapter } from "./muse/MuseAdapter.js"; @@ -26,6 +27,7 @@ export function createBuiltinAdapters(): AgentAdapter[] { new AntigravityCliAdapter(), new OpenCodeAdapter(), new PiAdapter(), + new OmpAdapter(), new DevinAdapter(), new MuseAdapter(), ]; diff --git a/packages/agent-manager/src/harnesses/omp/OmpAdapter.ts b/packages/agent-manager/src/harnesses/omp/OmpAdapter.ts new file mode 100644 index 000000000..aa2c231f8 --- /dev/null +++ b/packages/agent-manager/src/harnesses/omp/OmpAdapter.ts @@ -0,0 +1,214 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { + AgentAdapter, + AgentDetectionContext, + AgentInfo, + ConversationMessage, + ConversationOptions, + ListSessionsOptions, + ProcessInfo, + SessionSummary, +} from "../../adapters/AgentAdapter.js"; +import { executableBasename } from "../../utils/process.js"; +import { AgentRegistry } from "../../utils/AgentRegistry.js"; +import { findHarnessProcesses, homeDir, processOnlyAgent } from "../shared.js"; +import { PiSessionLocator } from "../pi/PiSessionLocator.js"; +import { PiSessionParser, type PiSession } from "../pi/PiSessionParser.js"; +import { generateAgentName } from "../../utils/matching.js"; + +interface SessionFileMatch { + process: ProcessInfo; + filePath: string; +} + +interface MappedAgentResult { + agents: AgentInfo[]; + fallback: ProcessInfo[]; +} + +/** Adapter for oh-my-pi (OMP), whose transcript format is Pi-compatible. */ +export class OmpAdapter implements AgentAdapter { + readonly type = "omp" as const; + readonly processNames = ["omp", "bun", "node"] as const; + + private readonly sessionsDir: string; + private readonly registry: AgentRegistry; + private readonly parser: PiSessionParser; + private readonly liveLocator: PiSessionLocator; + + constructor(registry: AgentRegistry = AgentRegistry.default()) { + this.sessionsDir = path.join(homeDir(), ".omp", "agent", "sessions"); + this.registry = registry; + this.parser = new PiSessionParser(); + this.liveLocator = this.createLocator(); + } + + canHandle(processInfo: ProcessInfo): boolean { + return this.isOmpExecutable(processInfo.command); + } + + async detectAgents(context?: AgentDetectionContext): Promise { + try { + const processes = (await findHarnessProcesses(this, context)).processes; + if (processes.length === 0) return []; + + const cacheResult = this.mapRegistryCache(processes); + const locatorResult = this.liveLocator.matchRunningProcesses(cacheResult.fallback); + const legacyResult = this.mapLegacyMatches(locatorResult.legacyMatches); + const unmatchedProcesses = this.findUnmatchedProcesses( + locatorResult.fallback, + legacyResult.agents, + ); + + return [ + ...cacheResult.agents, + ...legacyResult.agents, + ...unmatchedProcesses.map((processInfo) => + processOnlyAgent("omp", processInfo, { summary: "OMP process running" }), + ), + ]; + } finally { + this.parser.pruneSessionCache(); + } + } + + getConversation(sessionFilePath: string, options?: ConversationOptions): ConversationMessage[] { + return this.parser.getConversation(sessionFilePath, options); + } + + async listSessions(opts?: ListSessionsOptions): Promise { + const summaries: SessionSummary[] = []; + for (const filePath of this.createLocator().discoverHistoricalSessionFiles()) { + const summary = this.parser.fileToSessionSummary(filePath); + if (!summary || (opts?.cwd !== undefined && summary.cwd !== opts.cwd)) continue; + summaries.push({ ...summary, type: "omp" }); + } + return summaries; + } + + async findSessionsById(sessionId: string): Promise { + const matches: SessionSummary[] = []; + for (const filePath of this.createLocator().findHistoricalSessionFilesById(sessionId)) { + const summary = this.parser.fileToSessionSummary(filePath); + if (summary?.sessionId === sessionId) matches.push({ ...summary, type: "omp" }); + } + return matches.length > 0 + ? matches + : (await this.listSessions()).filter((s) => s.sessionId === sessionId); + } + + private createLocator(): PiSessionLocator { + return new PiSessionLocator( + { sessionsDir: this.sessionsDir, encodeProjectDir: encodeOmpProjectDir }, + this.parser, + ); + } + + private mapRegistryCache(processes: ProcessInfo[]): MappedAgentResult { + const matches: SessionFileMatch[] = []; + const fallback: ProcessInfo[] = []; + const byPid = new Map(this.registry.list().map((entry) => [entry.pid, entry])); + + for (const processInfo of processes) { + const entry = byPid.get(processInfo.pid); + if ( + !entry || + entry.type !== this.type || + !entry.sessionFilePath || + !this.isSessionFile(entry.sessionFilePath) || + !fs.existsSync(entry.sessionFilePath) + ) { + fallback.push(processInfo); + continue; + } + matches.push({ process: processInfo, filePath: entry.sessionFilePath }); + } + + return this.mapSessionFileMatches(matches, fallback); + } + + private mapLegacyMatches( + matches: Array<{ process: ProcessInfo; session: { filePath: string } }>, + ): MappedAgentResult { + return this.mapSessionFileMatches( + matches.map((match) => ({ process: match.process, filePath: match.session.filePath })), + [], + ); + } + + private mapSessionFileMatches( + matches: SessionFileMatch[], + fallback: ProcessInfo[], + ): MappedAgentResult { + const agents: AgentInfo[] = []; + const unresolved = [...fallback]; + for (const match of matches) { + const session = this.parser.readSessionIncremental(match.filePath, match.process.cwd); + if (session) agents.push(this.mapSessionToAgent(session, match.process, match.filePath)); + else unresolved.push(match.process); + } + return { agents, fallback: unresolved }; + } + + private mapSessionToAgent( + session: PiSession, + processInfo: ProcessInfo, + filePath: string, + ): AgentInfo { + const projectPath = session.projectPath || processInfo.cwd || ""; + return { + name: generateAgentName(projectPath, processInfo.pid), + type: "omp", + status: this.parser.determineStatus(session), + summary: session.summary === "Pi session active" ? "OMP session active" : session.summary, + pid: processInfo.pid, + projectPath, + sessionId: session.sessionId, + lastActive: session.lastActive, + sessionFilePath: filePath, + }; + } + + private findUnmatchedProcesses(processes: ProcessInfo[], agents: AgentInfo[]): ProcessInfo[] { + const matchedPids = new Set(agents.map((agent) => agent.pid)); + return processes.filter((processInfo) => !matchedPids.has(processInfo.pid)); + } + + private isOmpExecutable(command: string): boolean { + const tokens = command.replaceAll("\\", "/").trim().split(/\s+/); + const [argv0, ...args] = tokens; + const argv0Base = executableBasename(argv0 ?? ""); + if (argv0Base === "omp" || argv0Base === "omp.exe" || argv0Base === "omp.js") return true; + if (argv0Base !== "bun" && argv0Base !== "bun.exe") return false; + return ( + args.some( + (token) => + (token.endsWith("/bin/omp") || token.endsWith("/bin/omp.exe")) && + (executableBasename(token) === "omp" || executableBasename(token) === "omp.exe"), + ) || + args.some( + (token) => + executableBasename(token) === "cli.js" && + token.includes("/@oh-my-pi/pi-coding-agent/") && + token.endsWith("/dist/cli.js"), + ) + ); + } + private isSessionFile(filePath: string): boolean { + const root = path.resolve(this.sessionsDir); + const candidate = path.resolve(filePath); + return candidate.startsWith(`${root}${path.sep}`); + } +} + +function encodeOmpProjectDir(cwd: string): string { + const normalized = path.resolve(cwd); + const home = path.resolve(homeDir()); + const relative = path.relative(home, normalized); + const value = + relative && !relative.startsWith("..") && !path.isAbsolute(relative) + ? relative + : normalized.replace(/^[/\\]+/, ""); + return `-${value.replaceAll(path.sep, "-").replaceAll("/", "-")}`; +} diff --git a/packages/agent-manager/src/harnesses/pi/PiSessionLocator.ts b/packages/agent-manager/src/harnesses/pi/PiSessionLocator.ts index 7ec8d357c..b83e91f2e 100644 --- a/packages/agent-manager/src/harnesses/pi/PiSessionLocator.ts +++ b/packages/agent-manager/src/harnesses/pi/PiSessionLocator.ts @@ -20,6 +20,7 @@ import { homeDir } from "../shared.js"; export interface PiSessionLocatorOptions { sessionsDir?: string; + encodeProjectDir?: (cwd: string) => string; } export interface PiProcessSessionMatches { @@ -67,6 +68,7 @@ interface NoMatchEntry { */ export class PiSessionLocator { private readonly sessionsDir: string; + private readonly encodeProjectDirFn: (cwd: string) => string; private readonly heads = new Map(); private readonly noMatchDirs = new Map(); private generation = 0; @@ -76,6 +78,7 @@ export class PiSessionLocator { private readonly parser: PiSessionParser = new PiSessionParser(), ) { this.sessionsDir = options.sessionsDir ?? path.join(homeDir(), ".pi", "agent", "sessions"); + this.encodeProjectDirFn = options.encodeProjectDir ?? ((cwd) => this.encodeProjectDir(cwd)); } matchRunningProcesses(processes: ProcessInfo[]): PiProcessSessionMatches { @@ -134,7 +137,7 @@ export class PiSessionLocator { for (const proc of processes) { if (!proc.cwd || !proc.startTime) continue; - const projectDir = path.join(this.sessionsDir, this.encodeProjectDir(proc.cwd)); + const projectDir = path.join(this.sessionsDir, this.encodeProjectDirFn(proc.cwd)); const scan = scans.get(projectDir); if (scan) { scan.processes.push(proc); diff --git a/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts b/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts index 56f0bb682..13cfb2a70 100644 --- a/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts +++ b/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts @@ -31,6 +31,7 @@ const READINESS_PROFILES: Record = opencode: openCodeReadiness, copilot: copilotReadiness, pi: piReadiness, + omp: { configDir: ".omp" }, devin: devinReadiness, muse: museReadiness, }; @@ -54,13 +55,11 @@ async function agentReadiness( profile.auth?.(runtime), profile.integration?.(runtime), ]); - return { + const report: AgentReadinessReport = { type: agent, executable, globalConfig, builtInSkills, - auth, - integration, status: worstReadinessStatus([ executable.status, globalConfig.status, @@ -68,6 +67,9 @@ async function agentReadiness( ...(integration ? [integration.status] : []), ]), }; + if (auth) report.auth = auth; + if (integration) report.integration = integration; + return report; } export async function getAgentReadinessReports( diff --git a/packages/agent-manager/src/harnesses/runtimeProfiles.ts b/packages/agent-manager/src/harnesses/runtimeProfiles.ts index 5e7c29959..ebc9e39f5 100644 --- a/packages/agent-manager/src/harnesses/runtimeProfiles.ts +++ b/packages/agent-manager/src/harnesses/runtimeProfiles.ts @@ -20,6 +20,7 @@ export const HARNESS_RUNTIME_PROFILES: Record boolean { return test(argv0) || args.some(test); }; } + +function matchOmp(psCommand: string): boolean { + const tokens = psCommand.replaceAll("\\", "/").trim().split(/\s+/); + const [argv0, ...args] = tokens; + const argv0Base = basenameOf(argv0 ?? ""); + if (argv0Base === "omp" || argv0Base === "omp.exe" || argv0Base === "omp.js") return true; + if (argv0Base !== "bun" && argv0Base !== "bun.exe") return false; + return ( + args.some( + (token) => + (token.endsWith("/bin/omp") || token.endsWith("/bin/omp.exe")) && + (basenameOf(token) === "omp" || basenameOf(token) === "omp.exe"), + ) || + args.some( + (token) => + basenameOf(token) === "cli.js" && + token.includes("/@oh-my-pi/pi-coding-agent/") && + token.endsWith("/dist/cli.js"), + ) + ); +} diff --git a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts index 318e3f43e..9496a7bed 100644 --- a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts +++ b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts @@ -32,6 +32,7 @@ export interface StartAgentOptions { type: StartableAgentType; name: string; cwd: string; + args?: string[]; runtimeProvider?: AgentRuntimeProvider; pollIntervalMs?: number; pollTimeoutMs?: number; @@ -120,7 +121,7 @@ export async function startAgent( name: opts.name, cwd: opts.cwd, kind: profile.runtimeKind, - args: [], + args: opts.args ?? [], timeoutMs, ...(provider === "tmux" ? { diff --git a/packages/agent-manager/src/runtime/tmux/TmuxAgentRuntime.ts b/packages/agent-manager/src/runtime/tmux/TmuxAgentRuntime.ts index 0301c680b..355d72b29 100644 --- a/packages/agent-manager/src/runtime/tmux/TmuxAgentRuntime.ts +++ b/packages/agent-manager/src/runtime/tmux/TmuxAgentRuntime.ts @@ -49,7 +49,11 @@ export class TmuxAgentRuntime implements InteractiveAgentRuntime { } await this.tmux.createSession(input.name, input.cwd); - await this.tmux.sendKeys(input.name, input.command); + if (input.args.length > 0) { + await this.tmux.sendKeys(input.name, input.command, input.args); + } else { + await this.tmux.sendKeys(input.name, input.command); + } const pid = await pollForPid( this.tmux, diff --git a/packages/agent-manager/src/runtime/tmux/TmuxManager.ts b/packages/agent-manager/src/runtime/tmux/TmuxManager.ts index 328d02745..aaa78e10a 100644 --- a/packages/agent-manager/src/runtime/tmux/TmuxManager.ts +++ b/packages/agent-manager/src/runtime/tmux/TmuxManager.ts @@ -3,6 +3,11 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value; + return `'${value.replaceAll("'", "'\\''")}'`; +} + export class TmuxManager { async isAvailable(): Promise { try { @@ -26,8 +31,9 @@ export class TmuxManager { await execFileAsync("tmux", ["new-session", "-d", "-s", name, "-c", cwd]); } - async sendKeys(session: string, keys: string): Promise { - await execFileAsync("tmux", ["send-keys", "-t", session, keys, "Enter"]); + async sendKeys(session: string, command: string, args: string[] = []): Promise { + const commandLine = [command, ...args].map(shellQuote).join(" "); + await execFileAsync("tmux", ["send-keys", "-t", session, commandLine, "Enter"]); } async killSession(name: string): Promise { diff --git a/packages/cli/src/__tests__/commands/agent.test.ts b/packages/cli/src/__tests__/commands/agent.test.ts index 6b6e9f030..75ea399f9 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -815,6 +815,7 @@ describe("agent command", () => { type: "claude", name: "agent1", cwd: process.cwd(), + args: [], runtime: "tmux", }); expect(mockStartAgent).not.toHaveBeenCalled(); @@ -822,6 +823,43 @@ describe("agent command", () => { expect(ui.text).toHaveBeenCalledWith("Attach: tmux attach -t agent1"); expect(client.close).toHaveBeenCalled(); }); + it("forwards repeated structured arguments to an OMP daemon start", async () => { + daemonUp({ + name: "omp-work", + type: "omp", + pid: 4242, + runtime: "tmux", + runtimeRef: { session: "omp-work" }, + cwd: process.cwd(), + startedAt: "2026-10-17T00:00:00.000Z", + warnings: [], + }); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync([ + "node", + "test", + "agent", + "start", + "--type", + "omp", + "--name", + "omp-work", + "--arg", + "--profile", + "--arg", + "work", + ]); + + expect(mockDaemonStartAgent).toHaveBeenCalledWith({ + type: "omp", + name: "omp-work", + cwd: process.cwd(), + args: ["--profile", "work"], + runtime: "tmux", + }); + }); it("forwards daemon warnings", async () => { daemonUp({ diff --git a/packages/cli/src/__tests__/services/status/status.service.test.ts b/packages/cli/src/__tests__/services/status/status.service.test.ts index 3f4ef5070..693a45e52 100644 --- a/packages/cli/src/__tests__/services/status/status.service.test.ts +++ b/packages/cli/src/__tests__/services/status/status.service.test.ts @@ -216,6 +216,7 @@ describe("getStatusReport", () => { "opencode", "copilot", "pi", + "omp", "devin", "muse", ]); diff --git a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts index 1976dc8b7..acaaedde9 100644 --- a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts @@ -30,6 +30,7 @@ describe("StartAgentPane helpers", () => { "antigravity_cli", "opencode", "pi", + "omp", "kiro", "devin", "muse", @@ -41,7 +42,8 @@ describe("StartAgentPane helpers", () => { expect(nextStartAgentType("codex")).toBe("copilot"); expect(nextStartAgentType("copilot")).toBe("gemini_cli"); expect(nextStartAgentType("opencode")).toBe("pi"); - expect(nextStartAgentType("pi")).toBe("kiro"); + expect(nextStartAgentType("pi")).toBe("omp"); + expect(nextStartAgentType("omp")).toBe("kiro"); expect(nextStartAgentType("kiro")).toBe("devin"); expect(nextStartAgentType("devin")).toBe("muse"); expect(nextStartAgentType("muse")).toBe("claude"); @@ -51,7 +53,8 @@ describe("StartAgentPane helpers", () => { expect(previousStartAgentType("copilot")).toBe("codex"); expect(previousStartAgentType("gemini_cli")).toBe("copilot"); expect(previousStartAgentType("pi")).toBe("opencode"); - expect(previousStartAgentType("kiro")).toBe("pi"); + expect(previousStartAgentType("omp")).toBe("pi"); + expect(previousStartAgentType("kiro")).toBe("omp"); expect(previousStartAgentType("devin")).toBe("kiro"); expect(previousStartAgentType("muse")).toBe("devin"); expect(previousStartAgentType("claude")).toBe("muse"); diff --git a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts index 33295c031..ccc15fa4e 100644 --- a/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts +++ b/packages/cli/src/__tests__/tui/console/actions/runAction.test.ts @@ -119,7 +119,7 @@ describe("runAction", () => { args: ["--debug", "--foo", "bar"], }); const [, argv] = vi.mocked(spawn).mock.calls[0]; - expect(argv.slice(-3)).toEqual(["--debug", "--foo", "bar"]); + expect(argv.slice(-6)).toEqual(["--arg", "--debug", "--arg", "--foo", "--arg", "bar"]); }); it("passes correct argv for kill action", async () => { diff --git a/packages/cli/src/__tests__/util/sessions.test.ts b/packages/cli/src/__tests__/util/sessions.test.ts index a819d7b33..5b30973ea 100644 --- a/packages/cli/src/__tests__/util/sessions.test.ts +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -54,6 +54,7 @@ describe("sessions util", () => { "opencode", "copilot", "pi", + "omp", "devin", ] as const) { const result = resolveListSessionsOptions({ all: true, type }); @@ -63,7 +64,7 @@ describe("sessions util", () => { it("throws on an invalid --type", () => { expect(() => resolveListSessionsOptions({ all: true, type: "wrong" })).toThrow( - 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, grok_cli, kiro, antigravity_cli, opencode, copilot, pi, devin, muse.', + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, grok_cli, kiro, antigravity_cli, opencode, copilot, pi, omp, devin, muse.', ); }); diff --git a/packages/cli/src/commands/agent/start.command.ts b/packages/cli/src/commands/agent/start.command.ts index 598e97531..0a674a16a 100644 --- a/packages/cli/src/commands/agent/start.command.ts +++ b/packages/cli/src/commands/agent/start.command.ts @@ -24,6 +24,10 @@ import { createDurableAgentService } from "./factory.js"; import { formatCwd, formatPrintProvider } from "./render.js"; import { DaemonRpcError, ensureDaemon } from "@ai-devkit/daemon-client"; +function collectArg(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + interface StartedEntry { name: string; type: string; @@ -57,6 +61,7 @@ async function tryDaemonStart(input: { agentType: StartableAgentType; agentName: string; cwd: string; + args: string[]; runtimeProvider: string; }): Promise<"done" | "fallback" | "failed"> { const daemon = await ensureDaemon({ onDebug: debug }); @@ -71,6 +76,7 @@ async function tryDaemonStart(input: { name: input.agentName, cwd: input.cwd, runtime: input.runtimeProvider, + args: input.args, }); for (const warning of result.warnings ?? []) ui.warning(warning); renderStartedAgent({ @@ -161,17 +167,23 @@ export function registerAgentStartCommand(agentCommand: Command): void { "Human-readable name for the agent (lowercase alphanumeric + hyphens, 2-64 chars; default: {folder}-{timestamp})", ) .option("--cwd ", "Working directory for the agent (default: current directory)") + .option( + "--arg ", + "Argument passed to the harness; repeat for multiple arguments", + collectArg, + [], + ) .option("--debug", "Enable debug logging") .action( withErrorHandler("start agent", async (options) => { if (options.debug) { enableDebug(); } + const args = (options.arg as string[] | undefined) ?? []; const agentType = options.type as string; const mode = options.mode as string; const cwd = path.resolve(options.cwd ?? process.cwd()); const agentName = (options.name as string | undefined) ?? generateAgentName(cwd); - if (!(agentType in HARNESS_RUNTIME_PROFILES)) { ui.error( `Unsupported agent type "${agentType}". Supported: ${Object.keys(HARNESS_RUNTIME_PROFILES).join(", ")}.`, @@ -218,6 +230,7 @@ export function registerAgentStartCommand(agentCommand: Command): void { agentType: agentType as StartableAgentType, agentName, cwd, + args, runtimeProvider, }); if (routed === "done") return; @@ -231,6 +244,7 @@ export function registerAgentStartCommand(agentCommand: Command): void { type: agentType as StartableAgentType, name: agentName, cwd, + args, runtimeProvider, }, { onWarning: (msg: string) => ui.warning(msg) }, diff --git a/packages/cli/src/tui/console/actions/runAction.ts b/packages/cli/src/tui/console/actions/runAction.ts index 0905d54ef..99ce7bc01 100644 --- a/packages/cli/src/tui/console/actions/runAction.ts +++ b/packages/cli/src/tui/console/actions/runAction.ts @@ -34,7 +34,7 @@ export async function runAction( "--cwd", action.cwd, ...(action.mode ? ["--mode", action.mode] : []), - ...(action.args ?? []), + ...(action.args ?? []).flatMap((arg) => ["--arg", arg]), ]; case "kill": return [...baseArgs, "agent", "kill", action.agentName]; diff --git a/packages/cli/src/util/agent.ts b/packages/cli/src/util/agent.ts index bea37f70b..8528e4d9f 100644 --- a/packages/cli/src/util/agent.ts +++ b/packages/cli/src/util/agent.ts @@ -11,6 +11,7 @@ export const AGENT_TYPE_LABELS: Record = { antigravity_cli: "Antigravity CLI", opencode: "OpenCode", pi: "Pi", + omp: "OMP", devin: "Devin", muse: "Muse Code", other: "Other", @@ -31,6 +32,7 @@ export const AGENT_TYPE_LABELS_COMPACT: Record = { antigravity_cli: "antigravity", opencode: "opencode", pi: "pi", + omp: "omp", devin: "devin", muse: "muse", other: "other", diff --git a/rust/crates/devkit-harness/src/lib.rs b/rust/crates/devkit-harness/src/lib.rs index a19421566..1456040a3 100644 --- a/rust/crates/devkit-harness/src/lib.rs +++ b/rust/crates/devkit-harness/src/lib.rs @@ -16,17 +16,18 @@ pub mod gemini; pub mod grok; pub mod kiro; pub mod muse; +pub mod omp; pub mod opencode; pub mod pi; pub mod shared; pub mod start_profiles; - /// Build a registry with every ported adapter for `home` registered. pub fn default_registry(home: &std::path::Path) -> Registry { let mut r = Registry::new(); r.register(Box::new(claude::ClaudeAdapter::new(home))); r.register(Box::new(codex::CodexAdapter::new(home))); r.register(Box::new(pi::PiAdapter::new(home))); + r.register(Box::new(omp::OmpAdapter::new(home))); r.register(Box::new(gemini::GeminiAdapter::new(home))); r.register(Box::new(copilot::CopilotAdapter::new(home))); r.register(Box::new(grok::GrokAdapter::new(home))); diff --git a/rust/crates/devkit-harness/src/omp/mod.rs b/rust/crates/devkit-harness/src/omp/mod.rs new file mode 100644 index 000000000..9aacd4128 --- /dev/null +++ b/rust/crates/devkit-harness/src/omp/mod.rs @@ -0,0 +1,191 @@ +//! OMP adapter port for devkitd discovery. +//! +//! OMP writes the same JSONL message envelope as Pi, but uses `~/.omp/agent` +//! and a home-relative project bucket. The Pi parser is reused because the +//! record contract is intentionally compatible. + +use crate::pi::parser; +use crate::shared::{self, generate_agent_name, process_only_agent, SessionFile}; +use crate::start_profiles::MatchSpec; +use crate::{HarnessAdapter, SweepContext}; +use devkit_core::agent::EnrichedAgent; +use devkit_core::discover::AgentProc; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +pub struct OmpAdapter { + sessions_dir: PathBuf, + home: PathBuf, +} + +impl OmpAdapter { + pub fn new(home: &Path) -> Self { + Self { + sessions_dir: home.join(".omp/agent/sessions"), + home: home.to_path_buf(), + } + } + + fn discover_sessions(&self, processes: &[&AgentProc]) -> Vec { + let mut dirs = HashMap::::new(); + for proc in processes { + let (Some(cwd), Some(_start)) = (proc.cwd.as_deref(), proc.start_time_ms) else { + continue; + }; + let dir = self.sessions_dir.join(encode_project_dir(&self.home, cwd)); + if dir.is_dir() { + dirs.entry(dir.to_string_lossy().into_owned()) + .or_insert_with(|| cwd.to_string()); + } + } + + let mut sessions = Vec::new(); + for (dir, fallback_cwd) in dirs { + for name in shared::list_dir_names(Path::new(&dir)) { + if !name.ends_with(".jsonl") { + continue; + } + let path = Path::new(&dir).join(&name); + let Some(birthtime_ms) = shared::birthtime_ms(&path.to_string_lossy()) else { + continue; + }; + let head = + parser::read_session_head(&path.to_string_lossy(), parser::HEAD_MAX_BYTES); + sessions.push(SessionFile { + session_id: head + .as_ref() + .and_then(|h| h.session_id.clone()) + .unwrap_or_else(|| parser::session_id_from_file(&path.to_string_lossy())), + file_path: path.to_string_lossy().into_owned(), + project_dir: dir.clone(), + birthtime_ms, + resolved_cwd: head + .and_then(|h| h.project_path) + .unwrap_or(fallback_cwd.clone()), + }); + } + } + sessions + } +} + +impl HarnessAdapter for OmpAdapter { + fn type_id(&self) -> &'static str { + "omp" + } + + fn can_handle(&self, proc: &AgentProc) -> bool { + proc.command + .as_deref() + .map(|command| MatchSpec::Omp.matches(command)) + .unwrap_or(false) + } + + fn detect(&self, ctx: &SweepContext) -> Vec { + let processes: Vec<&AgentProc> = ctx + .processes + .iter() + .filter(|proc| self.can_handle(proc)) + .collect(); + if processes.is_empty() { + return Vec::new(); + } + + let sessions = self.discover_sessions(&processes); + let matches = shared::match_processes_to_sessions(&processes, &sessions); + let mut matched_pids = HashSet::new(); + let mut agents = Vec::new(); + + for matched in matches { + let proc = matched.proc; + let session_file = &sessions[matched.session_idx]; + if let Some(session) = + parser::read_session(&session_file.file_path, proc.cwd.as_deref().unwrap_or("")) + { + matched_pids.insert(proc.pid); + let status = parser::determine_status(&session, ctx.now); + let summary = if session.summary == "Pi session active" { + "OMP session active".to_string() + } else { + session.summary + }; + let project_path = if session.project_path.is_empty() { + proc.cwd.clone().unwrap_or_default() + } else { + session.project_path.clone() + }; + agents.push(EnrichedAgent { + name: generate_agent_name(&project_path, proc.pid), + agent_type: "omp".into(), + status: status.into(), + summary, + pid: proc.pid as u64, + project_path, + session_id: session_file.session_id.clone(), + last_active: shared::iso_utc(session.last_active_ms), + pinned: None, + session_file_path: Some(session_file.file_path.clone()), + }); + } + } + + for proc in processes { + if !matched_pids.contains(&proc.pid) { + agents.push(process_only_agent( + "omp", + proc, + "OMP process running", + None, + ctx.now, + )); + } + } + agents + } +} + +fn encode_project_dir(home: &Path, cwd: &str) -> String { + let cwd_path = Path::new(cwd); + let value = cwd_path + .strip_prefix(home) + .ok() + .filter(|relative| !relative.as_os_str().is_empty()) + .map(|relative| relative.to_string_lossy().into_owned()) + .unwrap_or_else(|| cwd.trim_start_matches('/').to_string()); + format!("-{}", value.replace(['/', '\\'], "-")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn omp_commands_are_precise() { + let proc = AgentProc { + pid: 1, + ppid: None, + command: Some( + "/home/me/.bun/bin/bun /home/me/.npm/@oh-my-pi/pi-coding-agent/dist/cli.js".into(), + ), + cwd: None, + session_file: None, + tty: None, + start_time_ms: None, + }; + assert!(OmpAdapter::new(Path::new("/home/me")).can_handle(&proc)); + assert!( + !OmpAdapter::new(Path::new("/home/me")).can_handle(&AgentProc { + command: Some("bun /repo/tools/cli.js".into()), + ..proc + }) + ); + } + + #[test] + fn omp_project_bucket_is_home_relative() { + assert_eq!( + encode_project_dir(Path::new("/home/me"), "/home/me/Documents/repo"), + "-Documents-repo" + ); + } +} diff --git a/rust/crates/devkit-harness/src/pi/mod.rs b/rust/crates/devkit-harness/src/pi/mod.rs index ca7cd3b1f..5ef6c0c29 100644 --- a/rust/crates/devkit-harness/src/pi/mod.rs +++ b/rust/crates/devkit-harness/src/pi/mod.rs @@ -1,8 +1,8 @@ //! PiAdapter port — registry cache, sessions.json tracker, project-dir //! locator matching, process-only fallback. -mod locator; -mod parser; +pub(crate) mod locator; +pub(crate) mod parser; use crate::shared; use crate::{HarnessAdapter, SweepContext}; diff --git a/rust/crates/devkit-harness/src/start_profiles.rs b/rust/crates/devkit-harness/src/start_profiles.rs index 53f13188e..ffa06250b 100644 --- a/rust/crates/devkit-harness/src/start_profiles.rs +++ b/rust/crates/devkit-harness/src/start_profiles.rs @@ -14,6 +14,8 @@ pub enum MatchSpec { Argv0Contains(&'static str), /// Any token's basename is one of the names (`matchAnyBasename`). AnyBasename(&'static [&'static str]), + /// Direct OMP executable or the Bun-launched OMP CLI entry point. + Omp, } /// What a start needs beyond discovery: the command to run, the kind passed @@ -82,6 +84,7 @@ pub fn start_profile(agent_type: &str) -> Option { ), "devin" => p("devin", "devin", &["devin"], MatchSpec::Argv0("devin")), "muse" => p("muse", "muse", &["muse"], MatchSpec::Argv0Contains("muse")), + "omp" => p("omp", "omp", &["omp"], MatchSpec::Omp), _ => None, } } @@ -124,10 +127,34 @@ impl MatchSpec { let (argv0, args) = split_argv0(ps_command); test(&argv0) || args.iter().any(|a| test(a)) } + MatchSpec::Omp => is_omp_command(ps_command), } } } +fn is_omp_command(ps_command: &str) -> bool { + let tokens = ps_command.replace('\\', "/"); + let mut parts = tokens.split_whitespace(); + let Some(argv0) = parts.next() else { + return false; + }; + let base = basename_of(argv0); + if base == "omp" || base == "omp.exe" || base == "omp.js" { + return true; + } + if base != "bun" && base != "bun.exe" { + return false; + } + parts.any(|token| { + let base = basename_of(token); + (token.ends_with("/bin/omp") || token.ends_with("/bin/omp.exe")) + && (base == "omp" || base == "omp.exe") + || (base == "cli.js" + && token.contains("/@oh-my-pi/pi-coding-agent/") + && token.ends_with("/dist/cli.js")) + }) +} + fn tokens_of(ps_command: &str) -> Vec<&str> { ps_command.split_whitespace().collect() } @@ -267,6 +294,23 @@ mod tests { assert_eq!(k.runtime_kind, "kiro"); } + #[test] + fn omp_profile_matches_direct_and_bun_cli() { + let omp = profile("omp"); + assert_eq!(omp.command, "omp"); + assert_eq!(omp.runtime_kind, "omp"); + assert_eq!(omp.discovery_aliases, &["omp"]); + assert!(omp.matcher.matches("omp --profile work")); + assert!(omp.matcher.matches( + "/home/me/.bun/bin/bun /home/me/.npm/@oh-my-pi/pi-coding-agent/dist/cli.js --profile work" + )); + assert!(omp.matcher.matches( + "bun /home/me/.nvm/versions/node/v24.15.0/bin/omp --no-session --mode rpc --no-ui" + )); + assert!(!omp.matcher.matches("bun /repo/tools/cli.js")); + assert!(!omp.matcher.matches("node /repo/tool.js omp")); + } + #[test] fn all_startable_types_have_profiles() { for t in [ @@ -281,6 +325,7 @@ mod tests { "kiro", "devin", "muse", + "omp", ] { assert!(start_profile(t).is_some(), "missing profile for {t}"); } diff --git a/rust/crates/devkitd/src/runtime/tmux.rs b/rust/crates/devkitd/src/runtime/tmux.rs index f4c9b479d..bf446eebd 100644 --- a/rust/crates/devkitd/src/runtime/tmux.rs +++ b/rust/crates/devkitd/src/runtime/tmux.rs @@ -15,6 +15,16 @@ const REQUIRED_STABLE_POLLS: u32 = 5; /// Binary paths are injectable so tests can point at fixture scripts instead /// of real tmux/pgrep/ps. +fn shell_quote(value: &str) -> String { + if value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_./:@%+=,-".contains(&b)) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', "'\\''")) +} + pub struct TmuxRuntime { tmux: String, pgrep: String, @@ -166,7 +176,12 @@ impl AgentRuntime for TmuxRuntime { code: None, message: format!("tmux new-session failed: {e}"), })?; - self.run(&["send-keys", "-t", input.name, input.command, "Enter"]) + let command_line = std::iter::once(input.command) + .chain(input.args.iter().map(String::as_str)) + .map(shell_quote) + .collect::>() + .join(" "); + self.run(&["send-keys", "-t", input.name, &command_line, "Enter"]) .map_err(|e| StartError::Failed { code: None, message: format!("tmux send-keys failed: {e}"), @@ -235,6 +250,25 @@ mod tests { assert!(calls.contains("new-session -d -s agent-one -c /tmp")); assert!(calls.contains("send-keys -t agent-one claude Enter")); } + #[test] + fn start_quotes_structured_omp_arguments() { + let f = FixtureDir::new("omp-args"); + f.set("pane_pid", "9000"); + f.set("children-9000", "9001"); + f.set("cmd-9001", "/usr/bin/omp --profile 'work profile'"); + let matcher = MatchSpec::Omp; + let args = vec!["--profile".to_string(), "work profile".to_string()]; + let base = input("omp-work", &matcher, 5000); + let start = StartInput { + command: "omp", + args: &args, + ..base + }; + f.tmux().start(&start).unwrap(); + assert!(f + .log("tmux-calls.log") + .contains("send-keys -t omp-work omp --profile 'work profile' Enter")); + } #[test] fn existing_session_is_replaced() {