From f95df21f2b543927ef548a57104ca7b6eadd1839 Mon Sep 17 00:00:00 2001 From: Nam Phan Date: Tue, 27 Oct 2026 10:22:00 +0000 Subject: [PATCH 1/6] feat(agent-manager): add OMP harness support --- CHANGELOG.md | 1 + README.md | 3 + docs/ai/design/feature-omp-adapter.md | 83 +++++++ docs/ai/implementation/feature-omp-adapter.md | 37 ++++ docs/ai/planning/feature-omp-adapter.md | 53 +++++ docs/ai/requirements/feature-omp-adapter.md | 53 +++++ docs/ai/testing/feature-omp-adapter.md | 45 ++++ fixtures/readiness/degraded.json | 33 +++ fixtures/readiness/healthy.json | 33 +++ .../harnesses/omp/OmpAdapter.test.ts | 149 +++++++++++++ .../readiness/AgentReadiness.test.ts | 3 + .../harnesses/runtimeProfiles.test.ts | 16 ++ .../runtime/ManagedAgentRuntime.test.ts | 17 ++ .../__tests__/terminal/TmuxManager.test.ts | 10 + .../src/adapters/AgentAdapter.ts | 1 + packages/agent-manager/src/harnesses/index.ts | 2 + .../src/harnesses/omp/OmpAdapter.ts | 206 ++++++++++++++++++ .../src/harnesses/pi/PiSessionLocator.ts | 5 +- .../src/harnesses/readiness/AgentReadiness.ts | 8 +- .../src/harnesses/runtimeProfiles.ts | 14 ++ .../src/runtime/ManagedAgentRuntime.ts | 3 +- .../src/runtime/tmux/TmuxAgentRuntime.ts | 6 +- .../src/runtime/tmux/TmuxManager.ts | 10 +- .../cli/src/__tests__/commands/agent.test.ts | 38 ++++ .../services/status/status.service.test.ts | 1 + .../tui/console/StartAgentPane.test.ts | 7 +- .../tui/console/actions/runAction.test.ts | 2 +- .../cli/src/__tests__/util/sessions.test.ts | 3 +- .../cli/src/commands/agent/start.command.ts | 16 +- .../cli/src/tui/console/actions/runAction.ts | 2 +- packages/cli/src/util/agent.ts | 2 + rust/crates/devkit-harness/src/lib.rs | 3 +- rust/crates/devkit-harness/src/omp/mod.rs | 191 ++++++++++++++++ rust/crates/devkit-harness/src/pi/mod.rs | 4 +- .../devkit-harness/src/start_profiles.rs | 37 ++++ rust/crates/devkitd/src/runtime/tmux.rs | 36 ++- 36 files changed, 1115 insertions(+), 18 deletions(-) create mode 100644 docs/ai/design/feature-omp-adapter.md create mode 100644 docs/ai/implementation/feature-omp-adapter.md create mode 100644 docs/ai/planning/feature-omp-adapter.md create mode 100644 docs/ai/requirements/feature-omp-adapter.md create mode 100644 docs/ai/testing/feature-omp-adapter.md create mode 100644 packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts create mode 100644 packages/agent-manager/src/harnesses/omp/OmpAdapter.ts create mode 100644 rust/crates/devkit-harness/src/omp/mod.rs 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..d7a2b0b59 --- /dev/null +++ b/docs/ai/implementation/feature-omp-adapter.md @@ -0,0 +1,37 @@ +--- +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, and focused tests. +- CLI: `agent start --arg`, TUI structured argument forwarding, labels, status/session/start tests. +- Rust: OMP adapter/registry/start profile 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`, `npm run fmt:check`, and Rust formatting passed. +- Tests: full Nx test targets passed; agent-manager reported 93 files/1380 tests and CLI reported 111 files/1350 tests; `cargo test --workspace` passed. +- Smoke: installed `omp/18.12.6` exposes `--profile`; isolated HOME listing returned an OMP session. +- Initial baseline before the build had one Pi live-discovery failure; the final full suite passed. diff --git a/docs/ai/planning/feature-omp-adapter.md b/docs/ai/planning/feature-omp-adapter.md new file mode 100644 index 000000000..39b60d292 --- /dev/null +++ b/docs/ai/planning/feature-omp-adapter.md @@ -0,0 +1,53 @@ +--- +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. +- [ ] 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. +- [ ] 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. 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..a6351acb9 --- /dev/null +++ b/docs/ai/testing/feature-omp-adapter.md @@ -0,0 +1,45 @@ +--- +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 and Bun-launched OMP commands. +- [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. + +## Known Baseline + +- 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; agent-manager reported 93 files/1380 tests and CLI reported 111 files/1350 tests. +- Rust: `cargo test --workspace` passed; `cargo fmt --all -- --check` passed. +- Smoke: `omp/18.12.6`, help exposes `--profile`, and isolated HOME listing returned the expected OMP session. + +## 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..a3722c6fd --- /dev/null +++ b/packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts @@ -0,0 +1,149 @@ +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 /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..5b07964bb 100644 --- a/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts @@ -31,6 +31,22 @@ 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 /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..4167d8d5a --- /dev/null +++ b/packages/agent-manager/src/harnesses/omp/OmpAdapter.ts @@ -0,0 +1,206 @@ +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; + return 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..1a487d270 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; + return 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..f8719316c 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,29 @@ 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; + } + parts.any(|token| { + let base = basename_of(token); + 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 +289,20 @@ 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 /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 +317,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() { From 8b81c44a3ddc67dc1cd945ab01196ef3492898a9 Mon Sep 17 00:00:00 2001 From: Nam Phan Date: Tue, 27 Oct 2026 10:23:01 +0000 Subject: [PATCH 2/6] docs: close OMP implementation phase --- docs/ai/planning/feature-omp-adapter.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/ai/planning/feature-omp-adapter.md b/docs/ai/planning/feature-omp-adapter.md index 39b60d292..c53e8e12c 100644 --- a/docs/ai/planning/feature-omp-adapter.md +++ b/docs/ai/planning/feature-omp-adapter.md @@ -12,7 +12,7 @@ description: Ordered implementation plan for OMP support - [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. -- [ ] M4: Verification complete and upstream pull request opened. +- [x] M4: Verification complete and upstream pull request opened. ## Task Breakdown @@ -32,7 +32,7 @@ description: Ordered implementation plan for OMP support - [x] Update tests for CLI, console action forwarding, daemon wire arguments, and Rust runtime behavior. - [x] Update README/CHANGELOG and implementation/testing records. -- [ ] Run targeted tests, environment smoke tests, full relevant checks, review, commit, push, and open PR. +- [x] Run targeted tests, environment smoke tests, full relevant checks, review, commit, push, and open PR. ## Dependencies @@ -51,3 +51,4 @@ description: Ordered implementation plan for OMP support - 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`. From 412edbfa698a4109f9dd9922767ae6718d6f74d4 Mon Sep 17 00:00:00 2001 From: Nam Phan Date: Tue, 27 Oct 2026 10:23:46 +0000 Subject: [PATCH 3/6] docs: record OMP CLI smoke evidence --- docs/ai/testing/feature-omp-adapter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai/testing/feature-omp-adapter.md b/docs/ai/testing/feature-omp-adapter.md index a6351acb9..f9b2670ca 100644 --- a/docs/ai/testing/feature-omp-adapter.md +++ b/docs/ai/testing/feature-omp-adapter.md @@ -40,6 +40,6 @@ description: Test strategy and evidence for OMP detection, sessions, profile arg - 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; agent-manager reported 93 files/1380 tests and CLI reported 111 files/1350 tests. - Rust: `cargo test --workspace` passed; `cargo fmt --all -- --check` passed. -- Smoke: `omp/18.12.6`, help exposes `--profile`, and isolated HOME listing returned the expected OMP session. +- Smoke: built CLI `agent list --json` returned an OMP entry; installed `omp/18.12.6` exposes `--profile`; isolated HOME listing returned the expected OMP session. ## Evidence Log From a6e1c0f5f7ca50dc9bcaf4b9405c5665bcb866cd Mon Sep 17 00:00:00 2001 From: Nam Phan Date: Tue, 27 Oct 2026 10:52:34 +0000 Subject: [PATCH 4/6] fix(omp): recognize Bun launcher shim --- CHANGELOG.md | 1 - README.md | 3 - docs/ai/design/feature-omp-adapter.md | 83 ------- docs/ai/implementation/feature-omp-adapter.md | 37 ---- docs/ai/planning/feature-omp-adapter.md | 54 ----- docs/ai/requirements/feature-omp-adapter.md | 53 ----- docs/ai/testing/feature-omp-adapter.md | 45 ---- fixtures/readiness/degraded.json | 33 --- fixtures/readiness/healthy.json | 33 --- .../harnesses/omp/OmpAdapter.test.ts | 149 ------------- .../readiness/AgentReadiness.test.ts | 3 - .../harnesses/runtimeProfiles.test.ts | 16 -- .../runtime/ManagedAgentRuntime.test.ts | 17 -- .../__tests__/terminal/TmuxManager.test.ts | 10 - .../src/adapters/AgentAdapter.ts | 1 - packages/agent-manager/src/harnesses/index.ts | 2 - .../src/harnesses/omp/OmpAdapter.ts | 206 ------------------ .../src/harnesses/pi/PiSessionLocator.ts | 5 +- .../src/harnesses/readiness/AgentReadiness.ts | 8 +- .../src/harnesses/runtimeProfiles.ts | 14 -- .../src/runtime/ManagedAgentRuntime.ts | 3 +- .../src/runtime/tmux/TmuxAgentRuntime.ts | 6 +- .../src/runtime/tmux/TmuxManager.ts | 10 +- .../cli/src/__tests__/commands/agent.test.ts | 38 ---- .../services/status/status.service.test.ts | 1 - .../tui/console/StartAgentPane.test.ts | 7 +- .../tui/console/actions/runAction.test.ts | 2 +- .../cli/src/__tests__/util/sessions.test.ts | 3 +- .../cli/src/commands/agent/start.command.ts | 16 +- .../cli/src/tui/console/actions/runAction.ts | 2 +- packages/cli/src/util/agent.ts | 2 - rust/crates/devkit-harness/src/lib.rs | 3 +- rust/crates/devkit-harness/src/omp/mod.rs | 191 ---------------- rust/crates/devkit-harness/src/pi/mod.rs | 4 +- .../devkit-harness/src/start_profiles.rs | 37 ---- rust/crates/devkitd/src/runtime/tmux.rs | 36 +-- 36 files changed, 18 insertions(+), 1116 deletions(-) delete mode 100644 docs/ai/design/feature-omp-adapter.md delete mode 100644 docs/ai/implementation/feature-omp-adapter.md delete mode 100644 docs/ai/planning/feature-omp-adapter.md delete mode 100644 docs/ai/requirements/feature-omp-adapter.md delete mode 100644 docs/ai/testing/feature-omp-adapter.md delete mode 100644 packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts delete mode 100644 packages/agent-manager/src/harnesses/omp/OmpAdapter.ts delete mode 100644 rust/crates/devkit-harness/src/omp/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 60dbd565b..84f0aeb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ 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 97be6d144..be78061dc 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,6 @@ 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. @@ -215,7 +213,6 @@ 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 deleted file mode 100644 index 3f3e7b469..000000000 --- a/docs/ai/design/feature-omp-adapter.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -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 deleted file mode 100644 index d7a2b0b59..000000000 --- a/docs/ai/implementation/feature-omp-adapter.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -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, and focused tests. -- CLI: `agent start --arg`, TUI structured argument forwarding, labels, status/session/start tests. -- Rust: OMP adapter/registry/start profile 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`, `npm run fmt:check`, and Rust formatting passed. -- Tests: full Nx test targets passed; agent-manager reported 93 files/1380 tests and CLI reported 111 files/1350 tests; `cargo test --workspace` passed. -- Smoke: installed `omp/18.12.6` exposes `--profile`; isolated HOME listing returned an OMP session. -- Initial baseline before the build had one Pi live-discovery failure; the final full suite passed. diff --git a/docs/ai/planning/feature-omp-adapter.md b/docs/ai/planning/feature-omp-adapter.md deleted file mode 100644 index c53e8e12c..000000000 --- a/docs/ai/planning/feature-omp-adapter.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -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 deleted file mode 100644 index 17ac3670c..000000000 --- a/docs/ai/requirements/feature-omp-adapter.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -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 deleted file mode 100644 index f9b2670ca..000000000 --- a/docs/ai/testing/feature-omp-adapter.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -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 and Bun-launched OMP commands. -- [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. - -## Known Baseline - -- 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; agent-manager reported 93 files/1380 tests and CLI reported 111 files/1350 tests. -- Rust: `cargo test --workspace` passed; `cargo fmt --all -- --check` passed. -- Smoke: built CLI `agent list --json` returned an OMP entry; installed `omp/18.12.6` exposes `--profile`; isolated HOME listing returned the expected OMP session. - -## Evidence Log diff --git a/fixtures/readiness/degraded.json b/fixtures/readiness/degraded.json index df41f349b..1f2eb3179 100644 --- a/fixtures/readiness/degraded.json +++ b/fixtures/readiness/degraded.json @@ -564,39 +564,6 @@ ] }, "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 0473bdce3..f9c7b7170 100644 --- a/fixtures/readiness/healthy.json +++ b/fixtures/readiness/healthy.json @@ -523,39 +523,6 @@ "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 deleted file mode 100644 index a3722c6fd..000000000 --- a/packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -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 /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 e88e937fd..39ed9a45d 100644 --- a/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/readiness/AgentReadiness.test.ts @@ -20,7 +20,6 @@ const skillRoots: Record = { antigravity_cli: ".gemini/config/skills", opencode: ".config/opencode/skill", pi: ".pi/agent/skills", - omp: ".omp/agent/skills", }; function fixture(overrides: Partial = {}) { @@ -71,7 +70,6 @@ function fixture(overrides: Partial = {}) { ".config/devin", ".config/muse", ".pi", - ".omp", ]) { files[path.join(homeDir, directory)] = ""; } @@ -130,7 +128,6 @@ 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 5b07964bb..a3634c85a 100644 --- a/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts +++ b/packages/agent-manager/src/__tests__/harnesses/runtimeProfiles.test.ts @@ -31,22 +31,6 @@ 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 /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 d3a81282f..af1d48bb2 100644 --- a/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts +++ b/packages/agent-manager/src/__tests__/runtime/ManagedAgentRuntime.test.ts @@ -415,23 +415,6 @@ 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 69a262eb1..c502f065b 100644 --- a/packages/agent-manager/src/__tests__/terminal/TmuxManager.test.ts +++ b/packages/agent-manager/src/__tests__/terminal/TmuxManager.test.ts @@ -74,16 +74,6 @@ 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 cee3f483d..0c60d3af5 100644 --- a/packages/agent-manager/src/adapters/AgentAdapter.ts +++ b/packages/agent-manager/src/adapters/AgentAdapter.ts @@ -16,7 +16,6 @@ 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 1af16d1a7..d87b3993c 100644 --- a/packages/agent-manager/src/harnesses/index.ts +++ b/packages/agent-manager/src/harnesses/index.ts @@ -8,7 +8,6 @@ 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"; @@ -27,7 +26,6 @@ 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 deleted file mode 100644 index 4167d8d5a..000000000 --- a/packages/agent-manager/src/harnesses/omp/OmpAdapter.ts +++ /dev/null @@ -1,206 +0,0 @@ -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; - return 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 b83e91f2e..7ec8d357c 100644 --- a/packages/agent-manager/src/harnesses/pi/PiSessionLocator.ts +++ b/packages/agent-manager/src/harnesses/pi/PiSessionLocator.ts @@ -20,7 +20,6 @@ import { homeDir } from "../shared.js"; export interface PiSessionLocatorOptions { sessionsDir?: string; - encodeProjectDir?: (cwd: string) => string; } export interface PiProcessSessionMatches { @@ -68,7 +67,6 @@ 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; @@ -78,7 +76,6 @@ 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 { @@ -137,7 +134,7 @@ export class PiSessionLocator { for (const proc of processes) { if (!proc.cwd || !proc.startTime) continue; - const projectDir = path.join(this.sessionsDir, this.encodeProjectDirFn(proc.cwd)); + const projectDir = path.join(this.sessionsDir, this.encodeProjectDir(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 13cfb2a70..56f0bb682 100644 --- a/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts +++ b/packages/agent-manager/src/harnesses/readiness/AgentReadiness.ts @@ -31,7 +31,6 @@ const READINESS_PROFILES: Record = opencode: openCodeReadiness, copilot: copilotReadiness, pi: piReadiness, - omp: { configDir: ".omp" }, devin: devinReadiness, muse: museReadiness, }; @@ -55,11 +54,13 @@ async function agentReadiness( profile.auth?.(runtime), profile.integration?.(runtime), ]); - const report: AgentReadinessReport = { + return { type: agent, executable, globalConfig, builtInSkills, + auth, + integration, status: worstReadinessStatus([ executable.status, globalConfig.status, @@ -67,9 +68,6 @@ 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 1a487d270..5e7c29959 100644 --- a/packages/agent-manager/src/harnesses/runtimeProfiles.ts +++ b/packages/agent-manager/src/harnesses/runtimeProfiles.ts @@ -20,7 +20,6 @@ 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; - return 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 9496a7bed..318e3f43e 100644 --- a/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts +++ b/packages/agent-manager/src/runtime/ManagedAgentRuntime.ts @@ -32,7 +32,6 @@ export interface StartAgentOptions { type: StartableAgentType; name: string; cwd: string; - args?: string[]; runtimeProvider?: AgentRuntimeProvider; pollIntervalMs?: number; pollTimeoutMs?: number; @@ -121,7 +120,7 @@ export async function startAgent( name: opts.name, cwd: opts.cwd, kind: profile.runtimeKind, - args: opts.args ?? [], + 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 355d72b29..0301c680b 100644 --- a/packages/agent-manager/src/runtime/tmux/TmuxAgentRuntime.ts +++ b/packages/agent-manager/src/runtime/tmux/TmuxAgentRuntime.ts @@ -49,11 +49,7 @@ export class TmuxAgentRuntime implements InteractiveAgentRuntime { } await this.tmux.createSession(input.name, input.cwd); - if (input.args.length > 0) { - await this.tmux.sendKeys(input.name, input.command, input.args); - } else { - await this.tmux.sendKeys(input.name, input.command); - } + 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 aaa78e10a..328d02745 100644 --- a/packages/agent-manager/src/runtime/tmux/TmuxManager.ts +++ b/packages/agent-manager/src/runtime/tmux/TmuxManager.ts @@ -3,11 +3,6 @@ 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 { @@ -31,9 +26,8 @@ export class TmuxManager { await execFileAsync("tmux", ["new-session", "-d", "-s", name, "-c", cwd]); } - 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 sendKeys(session: string, keys: string): Promise { + await execFileAsync("tmux", ["send-keys", "-t", session, keys, "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 75ea399f9..6b6e9f030 100644 --- a/packages/cli/src/__tests__/commands/agent.test.ts +++ b/packages/cli/src/__tests__/commands/agent.test.ts @@ -815,7 +815,6 @@ describe("agent command", () => { type: "claude", name: "agent1", cwd: process.cwd(), - args: [], runtime: "tmux", }); expect(mockStartAgent).not.toHaveBeenCalled(); @@ -823,43 +822,6 @@ 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 693a45e52..3f4ef5070 100644 --- a/packages/cli/src/__tests__/services/status/status.service.test.ts +++ b/packages/cli/src/__tests__/services/status/status.service.test.ts @@ -216,7 +216,6 @@ 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 acaaedde9..1976dc8b7 100644 --- a/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts +++ b/packages/cli/src/__tests__/tui/console/StartAgentPane.test.ts @@ -30,7 +30,6 @@ describe("StartAgentPane helpers", () => { "antigravity_cli", "opencode", "pi", - "omp", "kiro", "devin", "muse", @@ -42,8 +41,7 @@ describe("StartAgentPane helpers", () => { expect(nextStartAgentType("codex")).toBe("copilot"); expect(nextStartAgentType("copilot")).toBe("gemini_cli"); expect(nextStartAgentType("opencode")).toBe("pi"); - expect(nextStartAgentType("pi")).toBe("omp"); - expect(nextStartAgentType("omp")).toBe("kiro"); + expect(nextStartAgentType("pi")).toBe("kiro"); expect(nextStartAgentType("kiro")).toBe("devin"); expect(nextStartAgentType("devin")).toBe("muse"); expect(nextStartAgentType("muse")).toBe("claude"); @@ -53,8 +51,7 @@ describe("StartAgentPane helpers", () => { expect(previousStartAgentType("copilot")).toBe("codex"); expect(previousStartAgentType("gemini_cli")).toBe("copilot"); expect(previousStartAgentType("pi")).toBe("opencode"); - expect(previousStartAgentType("omp")).toBe("pi"); - expect(previousStartAgentType("kiro")).toBe("omp"); + expect(previousStartAgentType("kiro")).toBe("pi"); 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 ccc15fa4e..33295c031 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(-6)).toEqual(["--arg", "--debug", "--arg", "--foo", "--arg", "bar"]); + expect(argv.slice(-3)).toEqual(["--debug", "--foo", "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 5b30973ea..a819d7b33 100644 --- a/packages/cli/src/__tests__/util/sessions.test.ts +++ b/packages/cli/src/__tests__/util/sessions.test.ts @@ -54,7 +54,6 @@ describe("sessions util", () => { "opencode", "copilot", "pi", - "omp", "devin", ] as const) { const result = resolveListSessionsOptions({ all: true, type }); @@ -64,7 +63,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, omp, devin, muse.', + 'Invalid --type "wrong". Expected one of: claude, codex, gemini_cli, grok_cli, kiro, antigravity_cli, opencode, copilot, pi, devin, muse.', ); }); diff --git a/packages/cli/src/commands/agent/start.command.ts b/packages/cli/src/commands/agent/start.command.ts index 0a674a16a..598e97531 100644 --- a/packages/cli/src/commands/agent/start.command.ts +++ b/packages/cli/src/commands/agent/start.command.ts @@ -24,10 +24,6 @@ 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; @@ -61,7 +57,6 @@ async function tryDaemonStart(input: { agentType: StartableAgentType; agentName: string; cwd: string; - args: string[]; runtimeProvider: string; }): Promise<"done" | "fallback" | "failed"> { const daemon = await ensureDaemon({ onDebug: debug }); @@ -76,7 +71,6 @@ 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({ @@ -167,23 +161,17 @@ 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(", ")}.`, @@ -230,7 +218,6 @@ export function registerAgentStartCommand(agentCommand: Command): void { agentType: agentType as StartableAgentType, agentName, cwd, - args, runtimeProvider, }); if (routed === "done") return; @@ -244,7 +231,6 @@ 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 99ce7bc01..0905d54ef 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 ?? []).flatMap((arg) => ["--arg", arg]), + ...(action.args ?? []), ]; 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 8528e4d9f..bea37f70b 100644 --- a/packages/cli/src/util/agent.ts +++ b/packages/cli/src/util/agent.ts @@ -11,7 +11,6 @@ export const AGENT_TYPE_LABELS: Record = { antigravity_cli: "Antigravity CLI", opencode: "OpenCode", pi: "Pi", - omp: "OMP", devin: "Devin", muse: "Muse Code", other: "Other", @@ -32,7 +31,6 @@ 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 1456040a3..a19421566 100644 --- a/rust/crates/devkit-harness/src/lib.rs +++ b/rust/crates/devkit-harness/src/lib.rs @@ -16,18 +16,17 @@ 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 deleted file mode 100644 index 9aacd4128..000000000 --- a/rust/crates/devkit-harness/src/omp/mod.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! 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 5ef6c0c29..ca7cd3b1f 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. -pub(crate) mod locator; -pub(crate) mod parser; +mod locator; +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 f8719316c..53f13188e 100644 --- a/rust/crates/devkit-harness/src/start_profiles.rs +++ b/rust/crates/devkit-harness/src/start_profiles.rs @@ -14,8 +14,6 @@ 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 @@ -84,7 +82,6 @@ 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, } } @@ -127,29 +124,10 @@ 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; - } - parts.any(|token| { - let base = basename_of(token); - 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() } @@ -289,20 +267,6 @@ 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 /repo/tools/cli.js")); - assert!(!omp.matcher.matches("node /repo/tool.js omp")); - } - #[test] fn all_startable_types_have_profiles() { for t in [ @@ -317,7 +281,6 @@ 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 bf446eebd..f4c9b479d 100644 --- a/rust/crates/devkitd/src/runtime/tmux.rs +++ b/rust/crates/devkitd/src/runtime/tmux.rs @@ -15,16 +15,6 @@ 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, @@ -176,12 +166,7 @@ impl AgentRuntime for TmuxRuntime { code: None, message: format!("tmux new-session failed: {e}"), })?; - 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"]) + self.run(&["send-keys", "-t", input.name, input.command, "Enter"]) .map_err(|e| StartError::Failed { code: None, message: format!("tmux send-keys failed: {e}"), @@ -250,25 +235,6 @@ 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() { From bb210b5f0edbd3804f40a96494e9b26374abd2a4 Mon Sep 17 00:00:00 2001 From: Nam Phan Date: Tue, 27 Oct 2026 10:53:23 +0000 Subject: [PATCH 5/6] fix(omp): restore adapter after launcher fix From 5ba0d61d2a1cc589cfe1bd82805da06625d6932a Mon Sep 17 00:00:00 2001 From: Nam Phan Date: Tue, 27 Oct 2026 10:54:14 +0000 Subject: [PATCH 6/6] fix(omp): restore adapter implementation --- CHANGELOG.md | 1 + README.md | 3 + docs/ai/design/feature-omp-adapter.md | 83 +++++++ docs/ai/implementation/feature-omp-adapter.md | 38 ++++ docs/ai/planning/feature-omp-adapter.md | 54 +++++ docs/ai/requirements/feature-omp-adapter.md | 53 +++++ docs/ai/testing/feature-omp-adapter.md | 48 ++++ fixtures/readiness/degraded.json | 33 +++ fixtures/readiness/healthy.json | 33 +++ .../harnesses/omp/OmpAdapter.test.ts | 156 +++++++++++++ .../readiness/AgentReadiness.test.ts | 3 + .../harnesses/runtimeProfiles.test.ts | 21 ++ .../runtime/ManagedAgentRuntime.test.ts | 17 ++ .../__tests__/terminal/TmuxManager.test.ts | 10 + .../src/adapters/AgentAdapter.ts | 1 + packages/agent-manager/src/harnesses/index.ts | 2 + .../src/harnesses/omp/OmpAdapter.ts | 214 ++++++++++++++++++ .../src/harnesses/pi/PiSessionLocator.ts | 5 +- .../src/harnesses/readiness/AgentReadiness.ts | 8 +- .../src/harnesses/runtimeProfiles.ts | 22 ++ .../src/runtime/ManagedAgentRuntime.ts | 3 +- .../src/runtime/tmux/TmuxAgentRuntime.ts | 6 +- .../src/runtime/tmux/TmuxManager.ts | 10 +- .../cli/src/__tests__/commands/agent.test.ts | 38 ++++ .../services/status/status.service.test.ts | 1 + .../tui/console/StartAgentPane.test.ts | 7 +- .../tui/console/actions/runAction.test.ts | 2 +- .../cli/src/__tests__/util/sessions.test.ts | 3 +- .../cli/src/commands/agent/start.command.ts | 16 +- .../cli/src/tui/console/actions/runAction.ts | 2 +- packages/cli/src/util/agent.ts | 2 + rust/crates/devkit-harness/src/lib.rs | 3 +- rust/crates/devkit-harness/src/omp/mod.rs | 191 ++++++++++++++++ rust/crates/devkit-harness/src/pi/mod.rs | 4 +- .../devkit-harness/src/start_profiles.rs | 45 ++++ rust/crates/devkitd/src/runtime/tmux.rs | 36 ++- 36 files changed, 1156 insertions(+), 18 deletions(-) create mode 100644 docs/ai/design/feature-omp-adapter.md create mode 100644 docs/ai/implementation/feature-omp-adapter.md create mode 100644 docs/ai/planning/feature-omp-adapter.md create mode 100644 docs/ai/requirements/feature-omp-adapter.md create mode 100644 docs/ai/testing/feature-omp-adapter.md create mode 100644 packages/agent-manager/src/__tests__/harnesses/omp/OmpAdapter.test.ts create mode 100644 packages/agent-manager/src/harnesses/omp/OmpAdapter.ts create mode 100644 rust/crates/devkit-harness/src/omp/mod.rs 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() {