diff --git a/configuration/context-layers.mdx b/configuration/context-layers.mdx index 2496371d94b..af1afb883c9 100644 --- a/configuration/context-layers.mdx +++ b/configuration/context-layers.mdx @@ -83,6 +83,7 @@ harness; spawn-time plumbing delivers it: | **Claude Code** | managed region of `~/.claude/CLAUDE.md` | Claude Code natively reads `~/.claude/CLAUDE.md` at session start | | **Codex** | `~/.overdeck/context/codex-global.md` | At spawn, Overdeck seeds the agent's `$CODEX_HOME/AGENTS.md` from this file — Codex natively reads `AGENTS.md`, never the render artifact itself | | **Pi** | `~/.overdeck/context/pi-global.md` | Injected into the session's system-prompt context at spawn. Pi does natively support a global `~/.pi/agent/AGENTS.md`; Overdeck deliberately does not write there — the global layer is delivered per-spawn instead, leaving `~/.pi/agent/` entirely yours | +| **Prime Agent** | `~/.overdeck/context/prime-agent-global.md` | Injected into the root RPC session's supplemental system prompt at spawn; Overdeck never writes `~/.prime/agent` | The `pi-global.md` / `codex-global.md` files are **Overdeck-owned render artifacts** — the harnesses do not know about them. They exist so the global @@ -133,7 +134,8 @@ Write completion markers via `/pan-done` when ready for review. {{/harness:pi}} ``` -Blocks may be stacked to target several harnesses; `{{#harness:codex}}` is +Use `{{#harness:prime-agent}}...{{/harness:prime-agent}}` for Prime-only +content. Blocks may be stacked to target several harnesses; `{{#harness:codex}}` is also recognized. ## CLI diff --git a/configuration/harnesses.mdx b/configuration/harnesses.mdx index ae636054363..379ad43e16f 100644 --- a/configuration/harnesses.mdx +++ b/configuration/harnesses.mdx @@ -12,7 +12,7 @@ that runs in tmux. The **model** is what the binary calls. They are picked independently per spawn. -**Supported today:** `claude-code` (default), `ohmypi`, `codex`, `acp`, and `kimi-code`. For the wider +**Supported today:** `claude-code` (default), `ohmypi`, `codex`, `acp`, `kimi-code`, and `prime-agent`. For the wider field of coding-agent harnesses Overdeck could adopt next — and how each one handles skills, MCP, and AGENTS.md — see the [Harness Landscape](/reference/harness-landscape). @@ -24,6 +24,7 @@ skills, MCP, and AGENTS.md — see the [Harness Landscape](/reference/harness-la | `codex` | [OpenAI Codex CLI](https://github.com/openai/codex) (`codex`) | OpenAI-model work | Uses persistent `codex app-server` sessions by default; requires Codex CLI `0.144.0` or newer. | | `acp` | [Kimi Code CLI](https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html) (`kimi`) | Opt-in Kimi work | Uses the open Agent Client Protocol over a persistent authenticated host; Kimi is the first wired ACP agent. | | `kimi-code` | [Kimi Code CLI](https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html) (`kimi`) | Kimi models (built-in default) | Drives Kimi's own native terminal UI directly — no ACP host, no Anthropic-compatibility endpoint. Kimi models only. Set `providerHarnesses.kimi` to `acp` or `claude-code` to opt back out. | +| `prime-agent` | Prime Agent (`prime-agent`) | Opt-in multi-provider RPC work and conversations | Overdeck owns one persistent root RPC process per session; supported versions are `>=0.1.0 <1.0.0`. | ## Composer commands across harnesses @@ -60,6 +61,39 @@ Managed Codex sessions use an isolated `CODEX_HOME`. On initialization and resum receive neither override, so Claude Code's native context behavior remains in control. +### Prime Agent RPC (alternative) + +Install a compatible Prime Agent release with the upstream installer, then verify it: + +```bash +curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh +prime-agent --version # must be >=0.1.0 and <1.0.0 +prime-agent --help # must list --mode rpc +pan doctor +``` + +If the binary is outside `PATH`, set `primeAgent.binaryPath` in +`~/.overdeck/config.yaml`. Configure the provider's API credentials through +the provider's normal environment variables or Overdeck provider settings. +Overdeck maps Anthropic models to `anthropic`, OpenAI models to `openai`, and +Google models to `google`; an unknown provider/model pair fails instead of +silently switching harnesses. + +Overdeck owns the persistent root RPC process, session directory, resume, +delivery, abort, and termination. A detached session resumes only when its +recorded model, provider, and workspace match. Otherwise Overdeck starts a +fresh session or reports a recovery error. Prime schedules, heartbeats, +observation, autonomous continuation, and cross-agent messaging are disabled +because Cloister owns that lifecycle. RLM children remain internal tool work +attributed to the root session and cannot claim Overdeck tasks. + +Prime's worker/kernel separation is an execution boundary, not a security +sandbox. Use the same workspace and credential isolation you use for other +coding harnesses. Overdeck writes managed context only under +`~/.overdeck/context`; it never changes `~/.prime/agent`. To uninstall, stop +Prime sessions, remove `primeAgent` from the Overdeck config, and remove the +upstream `prime-agent` package with the package manager that installed it. + ### oh-my-pi / ohmypi (alternative) - Adds RPC mode (`omp --mode rpc`) so Overdeck can write structured commands to diff --git a/docs/HARNESSES.md b/docs/HARNESSES.md index 743316de226..2fb27c89f71 100644 --- a/docs/HARNESSES.md +++ b/docs/HARNESSES.md @@ -6,6 +6,6 @@ | Topic | Where it lives now | | --- | --- | -| Operational guide — supported harnesses (`claude-code`, `pi`), install, picker locations, ToS rules, troubleshooting | [`configuration/harnesses.mdx`](../configuration/harnesses.mdx) | +| Operational guide — supported harnesses, including managed Prime Agent RPC sessions, install, picker locations, security rules, and troubleshooting | [`configuration/harnesses.mdx`](../configuration/harnesses.mdx) | | Planning survey — 13 coding-agent harnesses, extensibility mechanisms, adoption implications | [`reference/harness-landscape.mdx`](../reference/harness-landscape.mdx) | | Proposal — loading curated skill bundles into a single conversation | [`reference/template-conversations.mdx`](../reference/template-conversations.mdx) | diff --git a/docs/MODEL_ROUTING.md b/docs/MODEL_ROUTING.md index 6e867424e04..47046a93f84 100644 --- a/docs/MODEL_ROUTING.md +++ b/docs/MODEL_ROUTING.md @@ -29,6 +29,15 @@ When Overdeck needs to make an LLM call, it resolves the model through this chai Overdeck supports seven LLM providers: +### Prime Agent provider mapping + +The `prime-agent` harness receives an explicit provider and model on every +launch. Anthropic routes to `anthropic`, OpenAI routes to `openai`, and Google +routes to `google`. Unsupported mappings fail loudly; Overdeck does not pick a +different model or harness. Provider authentication remains provider-owned. +Using Claude Pro or Max subscription credentials through Prime Agent bills as +extra API usage and does not consume the Claude plan quota. + | Provider | Auth Method | Notes | |----------|-------------|-------| | **Anthropic** | Claude Code subscription login (default, always enabled) | Opus 4.6, Sonnet 4.6, Haiku 4.5 | diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 83c36bd3bfc..7914f359d38 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -79,7 +79,7 @@ the public contract and lists every allowed value. | `duration_bucket` | `under_100ms`, `100ms-999ms`, `1s-9s`, `10s+` | | `forge` | `github`, `gitlab` | | `fork_kind` | `summary`, `handoff`, `plain` | -| `harness` | `claude-code`, `ohmypi`, `codex`, `acp`, `kimi-code` | +| `harness` | `claude-code`, `ohmypi`, `codex`, `acp`, `kimi-code`, `prime-agent` | | `merge_kind` | `pipeline` | | `model_family` | `claude`, `gpt`, `gemini`, `kimi`, `minimax`, `glm`, `mimo`, `other` | | `pipeline_stage` | `work_done`, `review_passed`, `verification_passed`, `merged`, `closed_out` | diff --git a/docs/evidence/pan-3668/prime-agent-runtime-picker.png b/docs/evidence/pan-3668/prime-agent-runtime-picker.png new file mode 100644 index 00000000000..d9b0ee91312 Binary files /dev/null and b/docs/evidence/pan-3668/prime-agent-runtime-picker.png differ diff --git a/docs/prime-agent-no-loss-audit.md b/docs/prime-agent-no-loss-audit.md new file mode 100644 index 00000000000..9eafb43ae35 --- /dev/null +++ b/docs/prime-agent-no-loss-audit.md @@ -0,0 +1,20 @@ +# Prime Agent No-Loss Audit + +Every existing harness remains present while `prime-agent` gains an explicit home. The mechanical test reads each canonical surface below. + +| # | Surface | Canonical location | Prime disposition | Existing harnesses preserved | Verified | +|---|---|---|---|---|---| +| S1 | Harness union and known set | `packages/contracts/src/types.ts` | explicit literal | yes | [x] | +| S2 | Behavior map | `packages/contracts/src/harness-behavior.ts` | `PRIME_AGENT_BEHAVIOR` | yes | [x] | +| S3 | Config harness schema | `src/lib/config-yaml/schema.ts` | accepted role/provider harness | yes | [x] | +| S4 | CLI help and composer manifest | `src/cli/index.ts`, `packages/contracts/src/composer-commands.generated.ts` | listed | yes | [x] | +| S5 | Provider resolution | `src/lib/prime-agent/provider-map.ts` | strict table, no fallback | unchanged | [x] | +| S6 | Runtime registry | `src/lib/runtimes/index.ts` | registered adapter | yes | [x] | +| S7 | Artifact, telemetry, flywheel, context preview | `packages/contracts/src/{artifacts,telemetry,flywheel,context-layers}.ts` | explicit literal | yes | [x] | +| S8 | Transcript registry | `src/lib/conversations/transcript-adapter.ts` | Prime adapter | yes | [x] | +| S9 | Cost source | `src/lib/overdeck/cost.ts` | `prime_agent` source | yes | [x] | +| S10 | Context renderer | `src/lib/context-layers/harness.ts` | Prime marker and artifact | yes | [x] | +| S11 | Work/conversation lifecycle | `src/lib/agents/runtime-command.ts`, `src/lib/overdeck/conversation-runtime.ts` | persistent RPC | yes | [x] | +| S12 | Delivery and recovery | `src/lib/prime-agent/{session-controller,session-resume}.ts` | correlated RPC and strict resume | unchanged | [x] | +| S13 | Dashboard API and frontend | `src/dashboard/` | identity, activity, usage, picker, badges, filters | yes | [x] | +| S14 | Operator documentation | `configuration/harnesses.mdx`, `configuration/context-layers.mdx` | setup and boundary documented | yes | [x] | diff --git a/docs/prime-agent-verification.md b/docs/prime-agent-verification.md new file mode 100644 index 00000000000..c60f2c0bcdd --- /dev/null +++ b/docs/prime-agent-verification.md @@ -0,0 +1,35 @@ +# Prime Agent verification + +Verified on 2026-08-12 for PAN-3668 with Prime Agent 0.7.2 and Node.js 22.22.0. + +## Automated gates + +| Gate | Result | +| --- | --- | +| `npm run build` | Passed, including contracts, CLI, dashboard server, dashboard frontend, and dist-externals checks. | +| `npm run typecheck` | Passed. The dashboard ratchet fell from 30 known errors to 28 after the Prime runtime types were propagated. | +| `npm run lint` | Passed through the focused rerun after removing the new Prime transcript-adapter cycle. | +| `npm test` | 13,664 tests passed and 51 skipped. Two unrelated 5-second setup hooks timed out under full-suite load; both files passed immediately in a focused rerun (5/5 tests). | +| Prime focused and no-loss tests | Passed, including the live smoke, runtime registry, parser, policy, picker, transcript, artifact, telemetry, and canonical-harness matrices. | + +The opt-in live test is `tests/integration/prime-agent-smoke.test.ts`. Run it with `OVERDECK_PRIME_AGENT_LIVE=1 npx vitest run tests/integration/prime-agent-smoke.test.ts`. Provider and model overrides are available through `OVERDECK_PRIME_AGENT_PROVIDER` and `OVERDECK_PRIME_AGENT_MODEL`. + +## Live Prime Agent smoke + +The smoke used a disposable directory and the installed `prime-agent` binary. It verified: + +1. RPC launch with an explicit provider, model, working directory, and session directory. +2. Correlated `get_state`, `prompt`, `steer`, `get_session_stats`, `abort`, `get_messages`, and resumed-session requests. +3. A real Bash tool execution that wrote the expected marker inside the disposable directory. +4. Non-zero token accounting, non-negative cost accounting, graceful stop, and resume from the saved session file. +5. The freshly built doctor diagnostic: `Prime Agent: v0.7.2 (RPC mode available)`. + +## Dashboard boot and UI + +The production dashboard bundle booted under Node.js 22 on isolated port 33668 with an isolated `OVERDECK_HOME`, `OVERDECK_DISABLE_DEACON=1`, and `OVERDECK_NO_RESUME=1`. `/api/health` returned `status: ok`, `mode: peer`, and the feature branch build metadata. The log order confirmed the HTTP listener before boot-cache completion, and Cloister stayed disabled. + +Playwright opened the isolated dashboard, enabled the throwaway harness-permutation setting, opened the runtime picker, and confirmed that `Prime Agent` was visible with the description `Managed persistent Prime RPC session`. The inspected screenshot is [the Prime Agent runtime picker](evidence/pan-3668/prime-agent-runtime-picker.png). + +## Release decision + +The Prime Agent path is verified for managed launch, RPC delivery, tool use, transcript persistence, cost reporting, stop/resume, diagnostics, and operator-facing selection. The only full-suite exceptions were two unrelated setup-hook timeouts that passed outside the saturated run. diff --git a/packages/contracts/src/artifacts.ts b/packages/contracts/src/artifacts.ts index 97ca3804b4a..10c3733d337 100644 --- a/packages/contracts/src/artifacts.ts +++ b/packages/contracts/src/artifacts.ts @@ -12,7 +12,7 @@ export type ArtifactHash = typeof ArtifactHash.Type export const ArtifactAgentRole = Schema.Literals(["plan", "work", "review", "test", "ship", "flywheel", "user"]) export type ArtifactAgentRole = typeof ArtifactAgentRole.Type -export const ArtifactAgentHarness = Schema.Literals(["claude-code", "pi", "ohmypi", "codex", "acp", "kimi-code", "user"]) +export const ArtifactAgentHarness = Schema.Literals(["claude-code", "pi", "ohmypi", "codex", "acp", "kimi-code", "prime-agent", "user"]) export type ArtifactAgentHarness = typeof ArtifactAgentHarness.Type export const ArtifactLifecycleState = Schema.Literals([ diff --git a/packages/contracts/src/composer-commands.generated.ts b/packages/contracts/src/composer-commands.generated.ts index 48f6a47931e..dedd7240131 100644 --- a/packages/contracts/src/composer-commands.generated.ts +++ b/packages/contracts/src/composer-commands.generated.ts @@ -33,7 +33,7 @@ export const COMPOSER_COMMAND_MANIFEST: ComposerCommandManifestEntry[] = [ {"id":"pan-backlog","path":["backlog"],"display":"/pan backlog","description":"Backlog sequencer management","args":[],"options":[],"aliases":[],"category":"Lifecycle"}, {"id":"pan-backlog-write-sequence","path":["backlog","write-sequence"],"display":"/pan backlog write-sequence","description":"Validate a SequenceDoc JSON file and write it to .pan/backlog/sequence.md (triggers auto-commit)","args":[{"name":"file","required":true,"variadic":false}],"options":[{"flags":"--project-root ","description":"Project root (default: cwd)","required":true,"valueHint":"path"}],"aliases":[],"category":"Lifecycle"}, {"id":"pan-backlog-forecast","path":["backlog","forecast"],"display":"/pan backlog forecast","description":"Pickup forecast (waves / needs-planning / cohort / stats) from sequence.md + SQLite — no HTTP (sandbox-safe)","args":[],"options":[{"flags":"-n, --n ","description":"Wave size (default 5)","required":true,"valueHint":"n"}],"aliases":[],"category":"Lifecycle"}, - {"id":"pan-plan","path":["plan"],"display":"/pan plan","description":"Planning lifecycle commands","args":[{"name":"id","required":false,"variadic":false}],"options":[{"flags":"--auto","description":"Run non-interactive planning; inferred choices are recorded in plan.autoDecisions[]","required":false,"valueHint":null},{"flags":"--auto-start","description":"[deprecated: use pan start ] After planning completes, automatically start the work agent — used by autonomous orchestrators","required":false,"valueHint":null},{"flags":"--probe","description":"Add an adversarial pre-finalize probe pass to the planning prompt","required":false,"valueHint":null},{"flags":"--model ","description":"Model to use for the planning role","required":true,"valueHint":"model"},{"flags":"--harness ","description":"Coding-agent harness: claude-code | pi | codex | acp | kimi-code (defaults to role/provider settings)","required":true,"valueHint":"harness"},{"flags":"--effort ","description":"Planning effort: low | medium | high","required":true,"valueHint":"level"},{"flags":"--remote","description":"Use remote planning workspace (Fly.io)","required":false,"valueHint":null},{"flags":"--local","description":"Use local planning workspace","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, + {"id":"pan-plan","path":["plan"],"display":"/pan plan","description":"Planning lifecycle commands","args":[{"name":"id","required":false,"variadic":false}],"options":[{"flags":"--auto","description":"Run non-interactive planning; inferred choices are recorded in plan.autoDecisions[]","required":false,"valueHint":null},{"flags":"--auto-start","description":"[deprecated: use pan start ] After planning completes, automatically start the work agent — used by autonomous orchestrators","required":false,"valueHint":null},{"flags":"--probe","description":"Add an adversarial pre-finalize probe pass to the planning prompt","required":false,"valueHint":null},{"flags":"--model ","description":"Model to use for the planning role","required":true,"valueHint":"model"},{"flags":"--harness ","description":"Coding-agent harness: claude-code | pi | codex | acp | kimi-code | prime-agent (defaults to role/provider settings)","required":true,"valueHint":"harness"},{"flags":"--effort ","description":"Planning effort: low | medium | high","required":true,"valueHint":"level"},{"flags":"--remote","description":"Use remote planning workspace (Fly.io)","required":false,"valueHint":null},{"flags":"--local","description":"Use local planning workspace","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, {"id":"pan-plan-finalize","path":["plan","finalize"],"display":"/pan plan finalize","description":"Mark the workspace plan as proposed and promote it to canonical state","args":[],"options":[{"flags":"-w, --workspace ","description":"Workspace path (defaults to cwd, walks up to find .pan/)","required":true,"valueHint":"path"},{"flags":"--json","description":"Emit JSON result","required":false,"valueHint":null},{"flags":"--no-promote","description":"Skip auto-promotion to main; leave spec at status=proposed for manual Done","required":false,"valueHint":null},{"flags":"--no-quality-lint","description":"Emergency bypass for xBRIEF quality lint during finalize","required":false,"valueHint":null},{"flags":"--no-prd","description":"Bypass the PRD-first gate for a genuinely trivial issue (loud; prefer writing the PRD)","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, {"id":"pan-plan-done","path":["plan","done"],"display":"/pan plan done","description":"Complete planning — promote the xBRIEF and transition the issue to Planned","args":[{"name":"id","required":true,"variadic":false}],"options":[{"flags":"--no-prd","description":"Bypass the PRD-first gate for a genuinely trivial issue (loud; prefer writing the PRD)","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, {"id":"pan-tell","path":["tell"],"display":"/pan tell","description":"Send message to running agent","args":[{"name":"id","required":true,"variadic":false},{"name":"message","required":true,"variadic":false}],"options":[],"aliases":[],"category":"Lifecycle"}, @@ -59,8 +59,8 @@ export const COMPOSER_COMMAND_MANIFEST: ComposerCommandManifestEntry[] = [ {"id":"pan-wipe","path":["wipe"],"display":"/pan wipe","description":"Destructive: removes workspace files, kills processes, deletes branches, clears review state, and resets tracker status","args":[{"name":"id","required":true,"variadic":false}],"options":[{"flags":"--force","description":"Skip confirmation","required":false,"valueHint":null},{"flags":"-y, --yes","description":"Skip confirmation","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, {"id":"pan-destroy","path":["destroy"],"display":"/pan destroy","description":"Alias for workspace destroy: remove the issue workspace worktree and branch","args":[{"name":"id","required":true,"variadic":false}],"options":[{"flags":"--force","description":"Force removal even with uncommitted changes","required":false,"valueHint":null},{"flags":"--project ","description":"Explicit project path (overrides registry)","required":true,"valueHint":"path"}],"aliases":[],"category":"Workspace"}, {"id":"pan-close","path":["close"],"display":"/pan close","description":"Verify, clean up, and close issue on tracker","args":[{"name":"id","required":true,"variadic":false}],"options":[{"flags":"--force","description":"Skip confirmation prompt","required":false,"valueHint":null},{"flags":"--json","description":"Output as JSON","required":false,"valueHint":null},{"flags":"--accept-review","description":"Accept a missed DoD row 1 (Review passed) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-tests","description":"Accept a missed DoD row 2 (Tests passed) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-verification","description":"Accept a missed DoD row 3 (Verification green on the branch) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-merged","description":"Accept a missed DoD row 4 (Merged to main) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-post-merge","description":"Accept a missed DoD row 5 (Post-merge lifecycle ran) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-main-verify","description":"Accept a missed DoD row 6 (Verified on main) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-ship","description":"Accept a missed DoD row 7 (Version strings propagated) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--accept-deploy","description":"Accept a missed DoD row 8 (Deployed) — recorded as an explicit override in the close-out record","required":false,"valueHint":null},{"flags":"--abandon ","description":"Record an abandoned disposition (PAN-3211): skip the DoD gate and close without landing evidence. Operator-conversation only; cannot be combined with --accept-* flags","required":true,"valueHint":"reason"},{"flags":"--residue ","description":"Record a residue disposition (PAN-3396): close stale convention PRs/MRs, skip the DoD gate with verified evidence, and mark terminal without merge claim. Operator-conversation only; cannot be combined with --abandon or --accept-* flags","required":true,"valueHint":"reason"}],"aliases":[],"category":"Lifecycle"}, - {"id":"pan-start","path":["start"],"display":"/pan start","description":"Create workspace and spawn agent for an issue","args":[{"name":"id","required":true,"variadic":false}],"options":[{"flags":"--model ","description":"Work model to use and persist for later respawns (defaults to Cloister config)","required":true,"valueHint":"model"},{"flags":"--swarm ","description":"Per-issue swarm policy: off | auto | always","required":true,"valueHint":"mode"},{"flags":"--review-mode ","description":"Per-issue review mode: quick | full | none","required":true,"valueHint":"mode"},{"flags":"--review-model ","description":"Per-issue review model override","required":true,"valueHint":"model"},{"flags":"--harness ","description":"Coding-agent harness: claude-code | pi | codex | acp | kimi-code (defaults to role/provider settings)","required":true,"valueHint":"harness"},{"flags":"--effort ","description":"Claude Code effort: low | medium | high | xhigh | max (defaults to roles.work.effort)","required":true,"valueHint":"level"},{"flags":"--tier ","description":"Remote workspace resiliency tier: ephemeral | durable (defaults to remote.resiliency_tier)","required":true,"valueHint":"tier"},{"flags":"--dry-run","description":"Show what would be created","required":false,"valueHint":null},{"flags":"--shadow","description":"Enable shadow mode","required":false,"valueHint":null},{"flags":"--no-shadow","description":"Disable shadow mode","required":false,"valueHint":null},{"flags":"--remote","description":"Use remote workspace (Fly.io)","required":false,"valueHint":null},{"flags":"--local","description":"Use local workspace (explicit override)","required":false,"valueHint":null},{"flags":"--plan ","description":"Planning depth when no plan exists yet: interactive | auto | skip (default: config planning.default_mode, shipped default auto)","required":true,"valueHint":"mode"},{"flags":"--plan-model ","description":"Planning model override when this start auto-plans (defaults to roles.plan.model); the work model stays --model","required":true,"valueHint":"model"},{"flags":"--auto","description":"[deprecated: use --plan skip] Skip planning agent by synthesizing a minimal xBRIEF from the issue title/body","required":false,"valueHint":null},{"flags":"--force","description":"Clear paused and pending-operator-decision gates and start anyway","required":false,"valueHint":null},{"flags":"--fresh","description":"Drop the saved Claude session (non-destructive) and start a new one — replaces a live session too, so it recovers an inert agent without a separate pan kill","required":false,"valueHint":null},{"flags":"--host","description":"Bypass workspace docker stack-health gate and spawn on the host","required":false,"valueHint":null},{"flags":"--yes","description":"Confirm --host in non-interactive contexts","required":false,"valueHint":null},{"flags":"--off-book","description":"Allow one work-agent dispatch outside the active order book and log the override","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, - {"id":"pan-strike","path":["strike"],"display":"/pan strike","description":"Spawn strike agent(s) — implement and push a strike branch for Deacon to land through the verified merge door. Bypasses plan/review/test/ship.","args":[{"name":"ids","required":true,"variadic":true}],"options":[{"flags":"--model ","description":"Model override (defaults to roles.strike.model from config)","required":true,"valueHint":"model"},{"flags":"--harness ","description":"Coding-agent harness: claude-code | pi | codex | acp | kimi-code (defaults to role/provider settings)","required":true,"valueHint":"harness"},{"flags":"--effort ","description":"Strike effort: low | medium | high | xhigh | max (default high)","required":true,"valueHint":"level"},{"flags":"--dry-run","description":"Print what would happen without spawning","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, + {"id":"pan-start","path":["start"],"display":"/pan start","description":"Create workspace and spawn agent for an issue","args":[{"name":"id","required":true,"variadic":false}],"options":[{"flags":"--model ","description":"Work model to use and persist for later respawns (defaults to Cloister config)","required":true,"valueHint":"model"},{"flags":"--swarm ","description":"Per-issue swarm policy: off | auto | always","required":true,"valueHint":"mode"},{"flags":"--review-mode ","description":"Per-issue review mode: quick | full | none","required":true,"valueHint":"mode"},{"flags":"--review-model ","description":"Per-issue review model override","required":true,"valueHint":"model"},{"flags":"--harness ","description":"Coding-agent harness: claude-code | pi | codex | acp | kimi-code | prime-agent (defaults to role/provider settings)","required":true,"valueHint":"harness"},{"flags":"--effort ","description":"Claude Code effort: low | medium | high | xhigh | max (defaults to roles.work.effort)","required":true,"valueHint":"level"},{"flags":"--tier ","description":"Remote workspace resiliency tier: ephemeral | durable (defaults to remote.resiliency_tier)","required":true,"valueHint":"tier"},{"flags":"--dry-run","description":"Show what would be created","required":false,"valueHint":null},{"flags":"--shadow","description":"Enable shadow mode","required":false,"valueHint":null},{"flags":"--no-shadow","description":"Disable shadow mode","required":false,"valueHint":null},{"flags":"--remote","description":"Use remote workspace (Fly.io)","required":false,"valueHint":null},{"flags":"--local","description":"Use local workspace (explicit override)","required":false,"valueHint":null},{"flags":"--plan ","description":"Planning depth when no plan exists yet: interactive | auto | skip (default: config planning.default_mode, shipped default auto)","required":true,"valueHint":"mode"},{"flags":"--plan-model ","description":"Planning model override when this start auto-plans (defaults to roles.plan.model); the work model stays --model","required":true,"valueHint":"model"},{"flags":"--auto","description":"[deprecated: use --plan skip] Skip planning agent by synthesizing a minimal xBRIEF from the issue title/body","required":false,"valueHint":null},{"flags":"--force","description":"Clear paused and pending-operator-decision gates and start anyway","required":false,"valueHint":null},{"flags":"--fresh","description":"Drop the saved Claude session (non-destructive) and start a new one — replaces a live session too, so it recovers an inert agent without a separate pan kill","required":false,"valueHint":null},{"flags":"--host","description":"Bypass workspace docker stack-health gate and spawn on the host","required":false,"valueHint":null},{"flags":"--yes","description":"Confirm --host in non-interactive contexts","required":false,"valueHint":null},{"flags":"--off-book","description":"Allow one work-agent dispatch outside the active order book and log the override","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, + {"id":"pan-strike","path":["strike"],"display":"/pan strike","description":"Spawn strike agent(s) — implement and push a strike branch for Deacon to land through the verified merge door. Bypasses plan/review/test/ship.","args":[{"name":"ids","required":true,"variadic":true}],"options":[{"flags":"--model ","description":"Model override (defaults to roles.strike.model from config)","required":true,"valueHint":"model"},{"flags":"--harness ","description":"Coding-agent harness: claude-code | pi | codex | acp | kimi-code | prime-agent (defaults to role/provider settings)","required":true,"valueHint":"harness"},{"flags":"--effort ","description":"Strike effort: low | medium | high | xhigh | max (default high)","required":true,"valueHint":"level"},{"flags":"--dry-run","description":"Print what would happen without spawning","required":false,"valueHint":null}],"aliases":[],"category":"Lifecycle"}, {"id":"pan-strike-ready","path":["strike-ready"],"display":"/pan strike-ready","description":"Persist readiness for a pushed strike branch so Deacon can land it","args":[{"name":"id","required":true,"variadic":false}],"options":[],"aliases":[],"category":"Lifecycle"}, {"id":"pan-knowledge","path":["knowledge"],"display":"/pan knowledge","description":"Maintain or open the project OKF bundle","args":[{"name":"id","required":false,"variadic":false}],"options":[{"flags":"--focus ","description":"Run /okf study for a focused topic before syncing","required":true,"valueHint":"topic"},{"flags":"--retro","description":"Run /okf retro before syncing","required":false,"valueHint":null},{"flags":"--model ","description":"Model override (defaults to roles.knowledge.model from config)","required":true,"valueHint":"model"},{"flags":"--effort ","description":"Knowledge effort: low | medium | high | xhigh | max","required":true,"valueHint":"level"}],"aliases":[],"category":"Data"}, {"id":"pan-knowledge-open","path":["knowledge","open"],"display":"/pan knowledge open","description":"Open the project OKF bundle in the local knowledge viewer","args":[],"options":[{"flags":"--no-install","description":"Do not install @inkeep/open-knowledge when the ok binary is missing","required":false,"valueHint":null},{"flags":"--no-browser","description":"Start or reuse the viewer without opening a browser","required":false,"valueHint":null}],"aliases":[],"category":"Data"}, @@ -266,7 +266,7 @@ export const COMPOSER_COMMAND_MANIFEST: ComposerCommandManifestEntry[] = [ {"id":"pan-merge","path":["merge"],"display":"/pan merge","description":"Merge workflow helpers","args":[],"options":[],"aliases":[],"category":"Review"}, {"id":"pan-merge-cancel","path":["merge","cancel"],"display":"/pan merge cancel","description":"Cancel a pending Flywheel auto-merge during its cooldown window","args":[{"name":"id","required":true,"variadic":false}],"options":[],"aliases":[],"category":"Review"}, {"id":"pan-artifacts","path":["artifacts"],"display":"/pan artifacts","description":"Create, publish, validate, inspect, and open shared HTML artifacts","args":[],"options":[],"aliases":[],"category":"Data"}, - {"id":"pan-artifacts-create","path":["artifacts","create"],"display":"/pan artifacts create","description":"Validate and publish an HTML artifact","args":[{"name":"file","required":true,"variadic":false}],"options":[{"flags":"--issue ","description":"Issue ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--workspace ","description":"Workspace ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--agent-role ","description":"Agent role: plan, work, review, test, ship, flywheel, or user","required":true,"valueHint":"role"},{"flags":"--agent-harness ","description":"Agent harness: claude-code, pi, ohmypi, codex, acp, kimi-code, or user","required":true,"valueHint":"harness"},{"flags":"--run-id ","description":"Run ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--session-id ","description":"Session ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--title ","description":"Artifact title","required":true,"valueHint":"title"},{"flags":"--description <text>","description":"Artifact description","required":true,"valueHint":"text"},{"flags":"--strict","description":"Enable strict validation warnings","required":false,"valueHint":null},{"flags":"--json","description":"Emit JSON result","required":false,"valueHint":null}],"aliases":[],"category":"Data"}, + {"id":"pan-artifacts-create","path":["artifacts","create"],"display":"/pan artifacts create","description":"Validate and publish an HTML artifact","args":[{"name":"file","required":true,"variadic":false}],"options":[{"flags":"--issue <id>","description":"Issue ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--workspace <id>","description":"Workspace ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--agent-role <role>","description":"Agent role: plan, work, review, test, ship, flywheel, or user","required":true,"valueHint":"role"},{"flags":"--agent-harness <harness>","description":"Agent harness: claude-code, pi, ohmypi, codex, acp, kimi-code, prime-agent, or user","required":true,"valueHint":"harness"},{"flags":"--run-id <id>","description":"Run ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--session-id <id>","description":"Session ID for artifact provenance","required":true,"valueHint":"id"},{"flags":"--title <title>","description":"Artifact title","required":true,"valueHint":"title"},{"flags":"--description <text>","description":"Artifact description","required":true,"valueHint":"text"},{"flags":"--strict","description":"Enable strict validation warnings","required":false,"valueHint":null},{"flags":"--json","description":"Emit JSON result","required":false,"valueHint":null}],"aliases":[],"category":"Data"}, {"id":"pan-artifacts-publish","path":["artifacts","publish"],"display":"/pan artifacts publish","description":"Revalidate and republish an existing artifact when changes are pending","args":[{"name":"file","required":true,"variadic":false}],"options":[{"flags":"--strict","description":"Enable strict validation warnings","required":false,"valueHint":null},{"flags":"--json","description":"Emit JSON result","required":false,"valueHint":null}],"aliases":[],"category":"Data"}, {"id":"pan-artifacts-unshare","path":["artifacts","unshare"],"display":"/pan artifacts unshare","description":"Disable an artifact URL without deleting source, snapshots, or metadata","args":[{"name":"file","required":true,"variadic":false}],"options":[{"flags":"--yes","description":"Confirm unsharing without an interactive prompt","required":false,"valueHint":null},{"flags":"--json","description":"Emit JSON result","required":false,"valueHint":null}],"aliases":[],"category":"Data"}, {"id":"pan-artifacts-share","path":["artifacts","share"],"display":"/pan artifacts share","description":"Share an artifact through an external tunnel","args":[{"name":"file","required":true,"variadic":false}],"options":[{"flags":"--tunnel","description":"Request tunnel-backed sharing","required":false,"valueHint":null},{"flags":"--json","description":"Emit JSON result","required":false,"valueHint":null}],"aliases":[],"category":"Data"}, diff --git a/packages/contracts/src/context-layers.test.ts b/packages/contracts/src/context-layers.test.ts index 71fff7bb155..c5644f1080f 100644 --- a/packages/contracts/src/context-layers.test.ts +++ b/packages/contracts/src/context-layers.test.ts @@ -96,7 +96,7 @@ describe("context dashboard contracts", () => { it("names harness previews with shared harness values and fullPrompt", () => { const harnesses: readonly Harness[] = CONTEXT_PREVIEW_HARNESSES - expect(harnesses).toEqual(["claude-code", "ohmypi", "codex", "acp", "kimi-code"]) + expect(harnesses).toEqual(["claude-code", "ohmypi", "codex", "acp", "kimi-code", "prime-agent"]) const parsed = decodePreviewResponse({ operation: "preview", @@ -106,6 +106,7 @@ describe("context dashboard contracts", () => { codex: "Codex rendered context", acp: "ACP rendered context", "kimi-code": "Kimi Code rendered context", + "prime-agent": "Prime Agent rendered context", fullPrompt: "Overdeck injected prompt audit", }, diagnostics: [], @@ -115,6 +116,7 @@ describe("context dashboard contracts", () => { expect(parsed.previews.ohmypi).toBe("oh-my-pi rendered context") expect(parsed.previews.acp).toBe("ACP rendered context") expect(parsed.previews["kimi-code"]).toBe("Kimi Code rendered context") + expect(parsed.previews["prime-agent"]).toBe("Prime Agent rendered context") expect(parsed.previews.fullPrompt).toBe("Overdeck injected prompt audit") }) diff --git a/packages/contracts/src/context-layers.ts b/packages/contracts/src/context-layers.ts index c1787c5bed5..fd54cbcb8c1 100644 --- a/packages/contracts/src/context-layers.ts +++ b/packages/contracts/src/context-layers.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import type { Harness } from "./types" export const CONTEXT_LAYER_KINDS = ["global", "project", "workspace"] as const -export const CONTEXT_PREVIEW_HARNESSES = ["claude-code", "ohmypi", "codex", "acp", "kimi-code"] as const satisfies readonly Harness[] +export const CONTEXT_PREVIEW_HARNESSES = ["claude-code", "ohmypi", "codex", "acp", "kimi-code", "prime-agent"] as const satisfies readonly Harness[] export const ContextLayerKind = Schema.Literals(CONTEXT_LAYER_KINDS) export type ContextLayerKind = typeof ContextLayerKind.Type @@ -141,6 +141,7 @@ export const ContextHarnessPreviews = Schema.Struct({ codex: Schema.String, acp: Schema.String, "kimi-code": Schema.String, + "prime-agent": Schema.String, fullPrompt: Schema.String, }) export type ContextHarnessPreviews = typeof ContextHarnessPreviews.Type diff --git a/packages/contracts/src/flywheel.ts b/packages/contracts/src/flywheel.ts index 61c1cad56d2..9fa9d29f5a4 100644 --- a/packages/contracts/src/flywheel.ts +++ b/packages/contracts/src/flywheel.ts @@ -6,7 +6,7 @@ export type FlywheelRunId = typeof FlywheelRunId.Type export const FlywheelHttpUrl = Schema.String.check(Schema.isPattern(/^https?:\/\/\S+$/i)) export type FlywheelHttpUrl = typeof FlywheelHttpUrl.Type -export const FlywheelHarness = Schema.Literals(["claude-code", "pi", "ohmypi", "codex", "acp", "kimi-code"]) +export const FlywheelHarness = Schema.Literals(["claude-code", "pi", "ohmypi", "codex", "acp", "kimi-code", "prime-agent"]) export interface FlywheelOrchestrator { harness: typeof FlywheelHarness.Type model: string diff --git a/packages/contracts/src/harness-behavior.test.ts b/packages/contracts/src/harness-behavior.test.ts new file mode 100644 index 00000000000..df9e8dbb776 --- /dev/null +++ b/packages/contracts/src/harness-behavior.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest" +import { getHarnessBehavior, getRuntimeBehavior, PRIME_AGENT_BEHAVIOR } from "./harness-behavior" + +describe("Prime Agent harness behavior", () => { + it("uses Prime-specific protocol discriminators", () => { + expect(PRIME_AGENT_BEHAVIOR).toMatchObject({ + displayName: "Prime Agent", + executableName: "prime-agent", + processNames: ["prime-agent"], + launchCommandKind: "prime-agent-rpc", + deliveryKind: "prime-agent-rpc", + readinessKind: "prime-agent-ready", + transcriptKind: "prime-agent-jsonl", + sessionIdSource: "prime-agent-session-id", + contextLayerKind: "prime-agent", + feedKind: "prime_agent", + workAgentMode: "prime-agent-rpc", + supportsPtySupervisor: false, + supportsChannelsBridge: false, + supportsConversationStreaming: true, + supportsPatchProjection: false, + usesRpcFifo: false, + usesCodexHome: false, + injectsPromptTimeMemory: true, + readyTimeoutSeconds: 120, + }) + }) + + it("resolves without falling back to Claude Code", () => { + expect(getHarnessBehavior("prime-agent")).toBe(PRIME_AGENT_BEHAVIOR) + expect(getRuntimeBehavior("prime-agent")).toBe(PRIME_AGENT_BEHAVIOR) + }) +}) diff --git a/packages/contracts/src/harness-behavior.ts b/packages/contracts/src/harness-behavior.ts index 50ca2f10f36..1bb56f2ab47 100644 --- a/packages/contracts/src/harness-behavior.ts +++ b/packages/contracts/src/harness-behavior.ts @@ -3,7 +3,7 @@ import type { Harness } from "./types" export type RuntimeName = Harness export type HarnessName = RuntimeName | "pi" -export type HarnessLaunchCommandKind = "claude-code" | "ohmypi-rpc" | "codex-work-tui" | "codex-app-server" | "acp-host" | "kimi-code-tui" +export type HarnessLaunchCommandKind = "claude-code" | "ohmypi-rpc" | "codex-work-tui" | "codex-app-server" | "acp-host" | "kimi-code-tui" | "prime-agent-rpc" export type HarnessDeliveryKind = | "pty-supervisor" | "rpc-fifo" @@ -11,6 +11,7 @@ export type HarnessDeliveryKind = | "codex-app-server-rpc" | "acp-host-rpc" | "tmux-paste" + | "prime-agent-rpc" export type HarnessReadinessKind = | "claude-session-signal" | "ohmypi-ready-file" @@ -18,10 +19,11 @@ export type HarnessReadinessKind = | "codex-app-server-ready" | "acp-host-ready" | "kimi-session-signal" -export type HarnessTranscriptKind = "claude-jsonl" | "ohmypi-jsonl" | "codex-rollout-jsonl" | "acp-jsonl" | "kimi-wire-jsonl" -export type HarnessSessionIdSource = "launcher-session-id" | "transcript-jsonl" | "codex-thread-id" | "acp-session-id" | "kimi-session-newest" -export type HarnessContextLayerKind = "claude" | "pi" | "codex" | "acp" | "kimi-code" -export type HarnessFeedKind = "claude_code" | "pi" | "codex" | "acp" | "kimi_code" + | "prime-agent-ready" +export type HarnessTranscriptKind = "claude-jsonl" | "ohmypi-jsonl" | "codex-rollout-jsonl" | "acp-jsonl" | "kimi-wire-jsonl" | "prime-agent-jsonl" +export type HarnessSessionIdSource = "launcher-session-id" | "transcript-jsonl" | "codex-thread-id" | "acp-session-id" | "kimi-session-newest" | "prime-agent-session-id" +export type HarnessContextLayerKind = "claude" | "pi" | "codex" | "acp" | "kimi-code" | "prime-agent" +export type HarnessFeedKind = "claude_code" | "pi" | "codex" | "acp" | "kimi_code" | "prime_agent" export interface HarnessNativeCommand { readonly name: string @@ -48,7 +50,7 @@ export interface HarnessBehavior { readonly usesRpcFifo: boolean readonly usesCodexHome: boolean readonly injectsPromptTimeMemory: boolean - readonly workAgentMode: "claude-code" | "ohmypi-rpc" | "codex-work-tui" | "codex-app-server" | "acp-host" | "kimi-code-tui" + readonly workAgentMode: "claude-code" | "ohmypi-rpc" | "codex-work-tui" | "codex-app-server" | "acp-host" | "kimi-code-tui" | "prime-agent-rpc" readonly readyTimeoutSeconds: number } @@ -176,12 +178,36 @@ export const KIMI_CODE_BEHAVIOR: HarnessBehavior = { readyTimeoutSeconds: 60, } +export const PRIME_AGENT_BEHAVIOR: HarnessBehavior = { + displayName: "Prime Agent", + nativeCommands: [], + executableName: "prime-agent", + processNames: ["prime-agent"], + launchCommandKind: "prime-agent-rpc", + deliveryKind: "prime-agent-rpc", + readinessKind: "prime-agent-ready", + transcriptKind: "prime-agent-jsonl", + sessionIdSource: "prime-agent-session-id", + contextLayerKind: "prime-agent", + feedKind: "prime_agent", + supportsPtySupervisor: false, + supportsChannelsBridge: false, + supportsConversationStreaming: true, + supportsPatchProjection: false, + usesRpcFifo: false, + usesCodexHome: false, + injectsPromptTimeMemory: true, + workAgentMode: "prime-agent-rpc", + readyTimeoutSeconds: 120, +} + const BEHAVIORS: Record<RuntimeName, HarnessBehavior> = { "claude-code": CLAUDE_CODE_BEHAVIOR, ohmypi: OHMYPI_BEHAVIOR, codex: CODEX_BEHAVIOR, acp: ACP_BEHAVIOR, "kimi-code": KIMI_CODE_BEHAVIOR, + "prime-agent": PRIME_AGENT_BEHAVIOR, } export function getHarnessBehavior(harness: HarnessName | undefined | null): HarnessBehavior { @@ -189,6 +215,7 @@ export function getHarnessBehavior(harness: HarnessName | undefined | null): Har if (harness === "codex") return CODEX_BEHAVIOR if (harness === "acp") return ACP_BEHAVIOR if (harness === "kimi-code") return KIMI_CODE_BEHAVIOR + if (harness === "prime-agent") return PRIME_AGENT_BEHAVIOR return CLAUDE_CODE_BEHAVIOR } diff --git a/packages/contracts/src/telemetry.ts b/packages/contracts/src/telemetry.ts index a6feb83d59e..017a7a5f4b5 100644 --- a/packages/contracts/src/telemetry.ts +++ b/packages/contracts/src/telemetry.ts @@ -82,7 +82,7 @@ export const TELEMETRY_CLI_VERBS = [ "other", ] as const -const TELEMETRY_HARNESSES = ["claude-code", "ohmypi", "codex", "acp", "kimi-code"] as const satisfies readonly Harness[] +const TELEMETRY_HARNESSES = ["claude-code", "ohmypi", "codex", "acp", "kimi-code", "prime-agent"] as const satisfies readonly Harness[] export const TELEMETRY_PROPERTY_DOMAINS = { agent_spawn_mode: ["spawn-and-send", "spawn-work-and-send"], diff --git a/packages/contracts/src/types.test.ts b/packages/contracts/src/types.test.ts index 84cb5284adf..f1d39569206 100644 --- a/packages/contracts/src/types.test.ts +++ b/packages/contracts/src/types.test.ts @@ -15,6 +15,10 @@ describe("getHarness", () => { expect(getHarness({ runtime: "kimi-code" })).toBe("kimi-code") }) + it("preserves the canonical prime-agent runtime literal", () => { + expect(getHarness({ runtime: "prime-agent" })).toBe("prime-agent") + }) + it("returns the existing target for every legacy harness string", () => { expect(getHarness({ runtime: "claude-code" })).toBe("claude-code") expect(getHarness({ runtime: "ohmypi" })).toBe("ohmypi") diff --git a/packages/contracts/src/types.ts b/packages/contracts/src/types.ts index 37d3f0c59fc..c4d17bc4924 100644 --- a/packages/contracts/src/types.ts +++ b/packages/contracts/src/types.ts @@ -84,9 +84,9 @@ export type VerificationStatusValue = typeof VerificationStatusValue.Type // PAN-1989 retired 'pi'; legacy DB rows read as 'ohmypi' via getHarness(). // All new write sites use canonical harness literals. -export type Harness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' +export type Harness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent' -export const KNOWN_HARNESSES: ReadonlySet<string> = new Set<Harness>(['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code']) +export const KNOWN_HARNESSES: ReadonlySet<string> = new Set<Harness>(['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code', 'prime-agent']) /** * Normalize a snapshot's runtime field to a known Harness value. diff --git a/scripts/dashboard-types-baseline.txt b/scripts/dashboard-types-baseline.txt index 64bb6b746dc..9902f17848a 100644 --- a/scripts/dashboard-types-baseline.txt +++ b/scripts/dashboard-types-baseline.txt @@ -1 +1 @@ -30 +28 diff --git a/scripts/file-size-allowlist.txt b/scripts/file-size-allowlist.txt index 96b7b27b774..3f636d8f0d6 100644 --- a/scripts/file-size-allowlist.txt +++ b/scripts/file-size-allowlist.txt @@ -14,7 +14,8 @@ 1259 src/lib/lifecycle/workflows.ts # PAN-3727 1049 src/lib/config-yaml/schema.ts # PAN-3410 1068 src/lib/config-yaml/schema.ts # PAN-3658 -1074 src/lib/config-yaml/schema.ts # PAN-3754 +1080 src/lib/config-yaml/schema.ts # PAN-3668 +1086 src/lib/config-yaml/schema.ts # PAN-3754 900 src/lib/launcher-generator.ts # PAN-1837 1186 src/lib/sync.ts # PAN-3450 1060 src/lib/agents/spawn.ts # PAN-1837 diff --git a/scripts/guard-agent-dir-removal.sh b/scripts/guard-agent-dir-removal.sh index d636bee78f9..dcfb680b6de 100755 --- a/scripts/guard-agent-dir-removal.sh +++ b/scripts/guard-agent-dir-removal.sh @@ -74,6 +74,10 @@ is_allowlisted() { src/lib/overdeck/conversation-runtime.ts) [[ "$content" == *'rm(sessionIdPath'* ]] ;; + src/lib/runtimes/prime-agent.ts) + [[ "$content" == *"'prime-agent-session-id'"* || + "$content" == *"'prime-agent-launch-error'"* ]] + ;; *) return 1 ;; diff --git a/src/cli/commands/artifacts.ts b/src/cli/commands/artifacts.ts index 1a4b7765828..f677903a3e1 100644 --- a/src/cli/commands/artifacts.ts +++ b/src/cli/commands/artifacts.ts @@ -281,7 +281,7 @@ export function registerArtifactCommands(program: Command): void { .option('--issue <id>', 'Issue ID for artifact provenance') .option('--workspace <id>', 'Workspace ID for artifact provenance') .option('--agent-role <role>', 'Agent role: plan, work, review, test, ship, flywheel, or user') - .option('--agent-harness <harness>', 'Agent harness: claude-code, pi, ohmypi, codex, acp, kimi-code, or user') + .option('--agent-harness <harness>', 'Agent harness: claude-code, pi, ohmypi, codex, acp, kimi-code, prime-agent, or user') .option('--run-id <id>', 'Run ID for artifact provenance') .option('--session-id <id>', 'Session ID for artifact provenance') .option('--title <title>', 'Artifact title') @@ -387,7 +387,7 @@ function parseAgentRole(value: string | undefined): ArtifactAgentRole | undefine export function parseAgentHarness(value: string | undefined): ArtifactAgentHarness | undefined { if (value === undefined) return undefined; - if (value === 'claude-code' || value === 'pi' || value === 'ohmypi' || value === 'codex' || value === 'acp' || value === 'kimi-code' || value === 'user') return value; + if (value === 'claude-code' || value === 'pi' || value === 'ohmypi' || value === 'codex' || value === 'acp' || value === 'kimi-code' || value === 'prime-agent' || value === 'user') return value; throw new Error(`Invalid --agent-harness: ${value}`); } diff --git a/src/cli/commands/doctor-prime-agent.ts b/src/cli/commands/doctor-prime-agent.ts new file mode 100644 index 00000000000..ecc567dcc10 --- /dev/null +++ b/src/cli/commands/doctor-prime-agent.ts @@ -0,0 +1,79 @@ +import { resolveHarnessBinary } from '../../lib/harness-binary.js'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { Effect } from 'effect'; +import { getClaudeAuthStatus } from '../../lib/claude-auth.js'; +import { getOpenAIAuthStatus } from '../../lib/openai-auth.js'; +import { loadConfigSync } from '../../lib/config-yaml.js'; +import { + checkSystemPrerequisite, + type PrerequisiteProbe, + type PrerequisiteResolver, +} from '../../lib/system-prerequisites.js'; + +export const SUPPORTED_PRIME_AGENT_VERSION_MIN = '0.1.0'; +export const SUPPORTED_PRIME_AGENT_VERSION_MAX_EXCLUSIVE = '1.0.0'; + +export interface PrimeAgentDoctorResult { + name: string; + status: 'ok' | 'warn' | 'error'; + message: string; + fix?: string; +} + +function compareSemver(a: string, b: string): number { + const left = a.split('.').map(Number); + const right = b.split('.').map(Number); + for (let index = 0; index < 3; index += 1) { + if ((left[index] ?? 0) !== (right[index] ?? 0)) return (left[index] ?? 0) - (right[index] ?? 0); + } + return 0; +} + +export async function checkPrimeAgent( + probe?: PrerequisiteProbe, + resolver?: PrerequisiteResolver, + credentialCheck?: () => Promise<boolean>, +): Promise<PrimeAgentDoctorResult[]> { + const productionProbe: PrerequisiteProbe = async (path, args) => (await promisify(execFile)(path, args, { encoding: 'utf8' })).stdout; + const activeProbe = probe ?? productionProbe; + const prime = await checkSystemPrerequisite('prime-agent', activeProbe, resolver); + if (!prime.found) return [{ name: prime.name, status: 'warn', message: 'Not installed (prime-agent harness unavailable)', fix: `Install: ${prime.install.linux}` }]; + + const version = prime.version?.match(/(\d+\.\d+\.\d+)/)?.[1] ?? null; + if (!version) return [{ name: prime.name, status: 'warn', message: 'Detected but `prime-agent --version` did not return a semantic version', fix: `Install a supported release >= ${SUPPORTED_PRIME_AGENT_VERSION_MIN} and < ${SUPPORTED_PRIME_AGENT_VERSION_MAX_EXCLUSIVE}` }]; + if (compareSemver(version, SUPPORTED_PRIME_AGENT_VERSION_MIN) < 0 || compareSemver(version, SUPPORTED_PRIME_AGENT_VERSION_MAX_EXCLUSIVE) >= 0) { + return [{ name: prime.name, status: 'warn', message: `v${version} is unsupported; requires >= ${SUPPORTED_PRIME_AGENT_VERSION_MIN} and < ${SUPPORTED_PRIME_AGENT_VERSION_MAX_EXCLUSIVE}`, fix: 'Install a compatible Prime Agent release; Overdeck will not fall back to another harness.' }]; + } + + const executable = await (resolver + ? resolver('prime-agent', { primeAgentHarness: true }) + : resolveHarnessBinary('prime-agent')); + try { + const help = executable ? await activeProbe(executable, ['--help']) : ''; + if (!/(?:--mode\b[^\n]*\brpc\b|--mode\s+<[^>]+>)/i.test(help)) { + return [{ name: prime.name, status: 'warn', message: `v${version} does not advertise the required --mode rpc capability`, fix: 'Install a Prime Agent build with RPC mode support.' }]; + } + } catch { + return [{ name: prime.name, status: 'warn', message: `v${version}; RPC capability probe failed`, fix: 'Run `prime-agent --help` and verify --mode rpc is available.' }]; + } + const results: PrimeAgentDoctorResult[] = [{ name: prime.name, status: 'ok', message: `v${version} (RPC mode available)` }]; + if (!probe || credentialCheck) { + let hasCredential: boolean; + if (credentialCheck) { + hasCredential = await credentialCheck(); + } else { + const { config } = loadConfigSync(); + const [claude, openai] = await Promise.all([ + Effect.runPromise(getClaudeAuthStatus()), + Effect.runPromise(getOpenAIAuthStatus()), + ]); + hasCredential = claude.loggedIn || claude.hasAnthropicApiKey || openai.loggedIn + || Object.values(config.apiKeys).some(Boolean); + } + results.push(hasCredential + ? { name: 'Prime Agent provider credentials', status: 'ok', message: 'At least one configured provider credential is available' } + : { name: 'Prime Agent provider credentials', status: 'warn', message: 'No provider credential is configured', fix: 'Configure credentials for the provider used by the selected Prime Agent model.' }); + } + return results; +} diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 95ba3ec7a6c..7e6d09ba6d3 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -36,6 +36,7 @@ import { checkInotify } from './doctor-inotify.js'; import { checkStateDivergence } from './doctor-state-divergence.js'; import { checkStateWorktrees } from './doctor-state-worktree.js'; import { checkDuplicateComposeStacks } from './doctor-duplicate-stacks.js'; +import { checkPrimeAgent } from './doctor-prime-agent.js'; import { assessBridgePoolPressure, bridgePoolLimitFromPools, @@ -745,10 +746,9 @@ export async function doctorCommand(options: DoctorOptions = {}): Promise<void> // Codex CLI (alternative harness — PAN-1574). Optional: missing → warn. for (const c of checkCodex()) checks.push(c); - // Kimi Code CLI (ACP harness). Resolve the same configured executable used at launch. for (const c of await checkKimi()) checks.push(c); - + for (const c of await checkPrimeAgent()) checks.push(c); // Check Overdeck directories const directories = [ { path: OVERDECK_HOME, name: 'Overdeck Home', fix: 'Run: pan init' }, diff --git a/src/cli/commands/flywheel.ts b/src/cli/commands/flywheel.ts index d77dc3b7456..b59989c7c52 100644 --- a/src/cli/commands/flywheel.ts +++ b/src/cli/commands/flywheel.ts @@ -95,7 +95,7 @@ interface FlywheelGateSnapshot { } interface ResolvedFlywheelRoleConfig { - harness: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; + harness: RuntimeName; model: string; effort: RoleEffort; minAgents: number; diff --git a/src/cli/commands/strike.ts b/src/cli/commands/strike.ts index a62265085d8..4ea94e87659 100644 --- a/src/cli/commands/strike.ts +++ b/src/cli/commands/strike.ts @@ -14,12 +14,13 @@ import { ACTIVITY_STALLED_MS } from '../../lib/agents/health.js'; import { resolveProjectFromIssueSync } from '../../lib/projects.js'; import { isHarnessProcessAlive, sessionExists } from '../../lib/tmux.js'; import type { RoleEffort } from '../../lib/config-yaml.js'; +import type { RuntimeName } from '../../lib/runtimes/types.js'; const execAsync = promisify(exec); export interface StrikeOptions { model?: string; - harness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; + harness?: RuntimeName; effort?: RoleEffort; dryRun?: boolean; } diff --git a/src/cli/index.ts b/src/cli/index.ts index 4655c46af35..fe622991ba5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -366,7 +366,7 @@ const planCmd = program .option('--auto-start', '[deprecated: use pan start <id>] After planning completes, automatically start the work agent — used by autonomous orchestrators') .option('--probe', 'Add an adversarial pre-finalize probe pass to the planning prompt') .option('--model <model>', 'Model to use for the planning role') - .option('--harness <harness>', 'Coding-agent harness: claude-code | pi | codex | acp | kimi-code (defaults to role/provider settings)') + .option('--harness <harness>', 'Coding-agent harness: claude-code | pi | codex | acp | kimi-code | prime-agent (defaults to role/provider settings)') .option('--effort <level>', 'Planning effort: low | medium | high') .option('--remote', 'Use remote planning workspace (Fly.io)') .option('--local', 'Use local planning workspace') @@ -510,7 +510,7 @@ program .command('start <id>') .description('Create workspace and spawn agent for an issue') .option('--model <model>', 'Work model to use and persist for later respawns (defaults to Cloister config)').option('--swarm <mode>', 'Per-issue swarm policy: off | auto | always').option('--review-mode <mode>', 'Per-issue review mode: quick | full | none').option('--review-model <model>', 'Per-issue review model override') - .option('--harness <harness>', 'Coding-agent harness: claude-code | pi | codex | acp | kimi-code (defaults to role/provider settings)') + .option('--harness <harness>', 'Coding-agent harness: claude-code | pi | codex | acp | kimi-code | prime-agent (defaults to role/provider settings)') .option('--effort <level>', 'Claude Code effort: low | medium | high | xhigh | max (defaults to roles.work.effort)') .option('--tier <tier>', 'Remote workspace resiliency tier: ephemeral | durable (defaults to remote.resiliency_tier)') .option('--dry-run', 'Show what would be created') @@ -531,7 +531,7 @@ program .command('strike <ids...>') .description('Spawn strike agent(s) — implement and push a strike branch for Deacon to land through the verified merge door. Bypasses plan/review/test/ship.') .option('--model <model>', 'Model override (defaults to roles.strike.model from config)') - .option('--harness <harness>', 'Coding-agent harness: claude-code | pi | codex | acp | kimi-code (defaults to role/provider settings)') + .option('--harness <harness>', 'Coding-agent harness: claude-code | pi | codex | acp | kimi-code | prime-agent (defaults to role/provider settings)') .option('--effort <level>', 'Strike effort: low | medium | high | xhigh | max (default high)') .option('--dry-run', 'Print what would happen without spawning') .action((ids: string[], options: { model?: string; harness?: RuntimeName; effort?: RoleEffort; dryRun?: boolean }) => strikeCommand(ids, options)); diff --git a/src/dashboard/frontend/src/components/CommandDeck/ConversationList.tsx b/src/dashboard/frontend/src/components/CommandDeck/ConversationList.tsx index 2cc2fdf1700..9c09cf20942 100644 --- a/src/dashboard/frontend/src/components/CommandDeck/ConversationList.tsx +++ b/src/dashboard/frontend/src/components/CommandDeck/ConversationList.tsx @@ -46,7 +46,7 @@ export interface Conversation { /** Model used for this conversation. Null until backfilled from session file. */ model?: string | null; /** Harness used to spawn this conversation. */ - harness?: 'claude-code' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | null; + harness?: 'claude-code' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent' | null; /** Effort level used when spawning this conversation. */ effort?: string | null; /** Async fork provisioning status. Null = not a fork or completed. */ diff --git a/src/dashboard/frontend/src/components/CommandDeck/ConversationRow.tsx b/src/dashboard/frontend/src/components/CommandDeck/ConversationRow.tsx index 644bcf9a212..04109f6e038 100644 --- a/src/dashboard/frontend/src/components/CommandDeck/ConversationRow.tsx +++ b/src/dashboard/frontend/src/components/CommandDeck/ConversationRow.tsx @@ -37,6 +37,7 @@ function shortHarness(harness: NonNullable<Conversation['harness']>): string { if (harness === 'codex') return 'Codex'; if (harness === 'acp') return 'ACP'; if (harness === 'kimi-code') return 'Kimi Code'; + if (harness === 'prime-agent') return 'Prime Agent'; // Unknown future harness — show the raw id rather than a wrong label. return harness; } diff --git a/src/dashboard/frontend/src/components/CommandDeck/useConversationMutations.ts b/src/dashboard/frontend/src/components/CommandDeck/useConversationMutations.ts index 2ff63c01a79..1c9d8462d31 100644 --- a/src/dashboard/frontend/src/components/CommandDeck/useConversationMutations.ts +++ b/src/dashboard/frontend/src/components/CommandDeck/useConversationMutations.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner'; import type { Conversation } from './ConversationList'; import { updateConversationTitle } from './ConversationList'; import { closeConversationPanes } from '../../lib/panesStore'; +import type { Harness } from '../Settings/types'; async function archiveConversation(name: string): Promise<void> { const res = await fetch(`/api/conversations/${encodeURIComponent(name)}/archive`, { method: 'POST' }); @@ -47,7 +48,7 @@ type ApiForkMode = 'summary' | 'plain' | 'handoff'; type ForkModeOption = ApiForkMode | 'fast-summary'; type HandoffAuthor = 'source' | 'external'; -async function summaryForkConversation(opts: { conv: Conversation; model: string; summaryModel: string; harness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; summaryHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; forkMode?: ApiForkMode; focus?: string; localSummaryOnly?: boolean; includeThinkingInSummary?: boolean; title?: string; handoffAuthor?: HandoffAuthor; handoffAuthorModel?: string; handoffAuthorHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; projectKey?: string }): Promise<void> { +async function summaryForkConversation(opts: { conv: Conversation; model: string; summaryModel: string; harness?: Harness; summaryHarness?: Harness; forkMode?: ApiForkMode; focus?: string; localSummaryOnly?: boolean; includeThinkingInSummary?: boolean; title?: string; handoffAuthor?: HandoffAuthor; handoffAuthorModel?: string; handoffAuthorHarness?: Harness; projectKey?: string }): Promise<void> { const res = await fetch(`/api/conversations/${encodeURIComponent(opts.conv.name)}/summary-fork`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -83,7 +84,7 @@ export interface ConversationMutations { toggleFavorite: (opts: { name: string; favorited: boolean }) => void; move: (opts: { name: string; projectKey: string; projectName: string }) => void; openForkModal: (conv: Conversation, options?: { mode?: ForkModeOption; focus?: string }) => void; - submitFork: (conv: Conversation, launchModel: string, summaryModel: string, forkMode: ApiForkMode, localSummaryOnly: boolean, includeThinkingInSummary: boolean, title?: string, launchHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code', summaryHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code', focus?: string, handoffAuthor?: HandoffAuthor, handoffAuthorModel?: string, handoffAuthorHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code', projectKey?: string) => void; + submitFork: (conv: Conversation, launchModel: string, summaryModel: string, forkMode: ApiForkMode, localSummaryOnly: boolean, includeThinkingInSummary: boolean, title?: string, launchHarness?: Harness, summaryHarness?: Harness, focus?: string, handoffAuthor?: HandoffAuthor, handoffAuthorModel?: string, handoffAuthorHarness?: Harness, projectKey?: string) => void; forkTarget: Conversation | null; forkTargetMode: ForkModeOption | undefined; forkTargetFocus: string | undefined; @@ -233,7 +234,7 @@ export function useConversationMutations( } }, [summaryForkMutation.isPending]); - const submitFork = useCallback((conv: Conversation, launchModel: string, summaryModel: string, forkMode: ApiForkMode, localSummaryOnly: boolean, includeThinkingInSummary: boolean, title?: string, launchHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code', summaryHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code', focus?: string, handoffAuthor?: HandoffAuthor, handoffAuthorModel?: string, handoffAuthorHarness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code', projectKey?: string) => { + const submitFork = useCallback((conv: Conversation, launchModel: string, summaryModel: string, forkMode: ApiForkMode, localSummaryOnly: boolean, includeThinkingInSummary: boolean, title?: string, launchHarness?: Harness, summaryHarness?: Harness, focus?: string, handoffAuthor?: HandoffAuthor, handoffAuthorModel?: string, handoffAuthorHarness?: Harness, projectKey?: string) => { summaryForkMutation.mutate({ conv, model: launchModel, diff --git a/src/dashboard/frontend/src/components/Settings/sections/CrewRow.tsx b/src/dashboard/frontend/src/components/Settings/sections/CrewRow.tsx index 419a153f75f..bbb6358cdee 100644 --- a/src/dashboard/frontend/src/components/Settings/sections/CrewRow.tsx +++ b/src/dashboard/frontend/src/components/Settings/sections/CrewRow.tsx @@ -8,7 +8,7 @@ import { type CrewEntry, } from './tiered-crews'; -const HARNESSES: Harness[] = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code']; +const HARNESSES: Harness[] = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code', 'prime-agent']; function ModelSelect({ value, onChange, label = 'Model' }: { value: string; onChange: (value: string) => void; label?: string }) { return <label className="space-y-1.5"><span className="text-xs font-medium text-foreground">{label}</span><select aria-label={label} value={value} onChange={(event) => onChange(event.target.value)} className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground"> diff --git a/src/dashboard/frontend/src/components/Settings/sections/ProviderManagementSection.tsx b/src/dashboard/frontend/src/components/Settings/sections/ProviderManagementSection.tsx index 1f6122cdc93..2c5b84fc5f8 100644 --- a/src/dashboard/frontend/src/components/Settings/sections/ProviderManagementSection.tsx +++ b/src/dashboard/frontend/src/components/Settings/sections/ProviderManagementSection.tsx @@ -53,6 +53,7 @@ const HARNESS_LABELS: Record<Harness, string> = { codex: 'Codex', acp: 'ACP', 'kimi-code': 'Kimi Code', + 'prime-agent': 'Prime Agent', }; const PROVIDERS: { id: Provider; name: string; placeholder: string }[] = [ @@ -77,7 +78,7 @@ function harnessLabel(harness: Harness): string { * provider would write a config that fails at every spawn. */ function harnessOptionsFor(provider: Provider | 'openrouter'): Harness[] { - const shared: Harness[] = ['claude-code', 'ohmypi', 'codex']; + const shared: Harness[] = ['claude-code', 'ohmypi', 'codex', 'prime-agent']; return provider === 'kimi' ? [...shared, 'acp', 'kimi-code'] : shared; } diff --git a/src/dashboard/frontend/src/components/Settings/sections/TieredExecutionSection.tsx b/src/dashboard/frontend/src/components/Settings/sections/TieredExecutionSection.tsx index 92089822969..b739af234f3 100644 --- a/src/dashboard/frontend/src/components/Settings/sections/TieredExecutionSection.tsx +++ b/src/dashboard/frontend/src/components/Settings/sections/TieredExecutionSection.tsx @@ -33,7 +33,7 @@ interface EditableCrewState { } const ITEM_KINDS = ['docs', 'api', 'backend', 'frontend', 'infra', 'test', 'refactor', 'design', 'spike'] as const; -const HARNESSES: Harness[] = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code']; +const HARNESSES: Harness[] = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code', 'prime-agent']; const CALLOUTS = ['off', 'notify', 'corroborate'] as const; // Defaults must NEVER be a frontier model (fable/opus). The first catalog // entries are premium models, and defaulting new crews/the supervisor to diff --git a/src/dashboard/frontend/src/components/Settings/types.ts b/src/dashboard/frontend/src/components/Settings/types.ts index 93262aa9bf7..d3efe00abd0 100644 --- a/src/dashboard/frontend/src/components/Settings/types.ts +++ b/src/dashboard/frontend/src/components/Settings/types.ts @@ -4,7 +4,7 @@ export type Provider = 'anthropic' | 'openai' | 'google' | 'zai' | 'kimi' | 'minimax' | 'mimo' | 'openrouter' | 'nous' | 'dashscope'; export type ModelId = string; -export type Harness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; +export type Harness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent'; export type HarnessOverride = Harness | ''; export type XBriefDifficulty = 'trivial' | 'simple' | 'medium' | 'complex' | 'expert'; export type XBriefItemKind = 'docs' | 'api' | 'backend' | 'frontend' | 'infra' | 'test' | 'refactor' | 'design' | 'spike'; diff --git a/src/dashboard/frontend/src/components/chat/ModelPicker.tsx b/src/dashboard/frontend/src/components/chat/ModelPicker.tsx index 2c758a38845..cc70178c301 100644 --- a/src/dashboard/frontend/src/components/chat/ModelPicker.tsx +++ b/src/dashboard/frontend/src/components/chat/ModelPicker.tsx @@ -179,7 +179,7 @@ function formatCost(costPer1M: number): string { } function isHarness(value: unknown): value is Harness { - return value === 'claude-code' || value === 'ohmypi' || value === 'codex' || value === 'acp' || value === 'kimi-code'; + return value === 'claude-code' || value === 'ohmypi' || value === 'codex' || value === 'acp' || value === 'kimi-code' || value === 'prime-agent'; } function providerDefaultHarness(provider: string, providerHarnesses: ProviderHarnesses): Harness { diff --git a/src/dashboard/frontend/src/components/chat/__tests__/ModelPicker.test.tsx b/src/dashboard/frontend/src/components/chat/__tests__/ModelPicker.test.tsx index cafbc8d347b..cfcc707ec79 100644 --- a/src/dashboard/frontend/src/components/chat/__tests__/ModelPicker.test.tsx +++ b/src/dashboard/frontend/src/components/chat/__tests__/ModelPicker.test.tsx @@ -120,7 +120,8 @@ describe('chat ModelPicker live harness labels', () => { expect(screen.getByLabelText('Codex logo')).toBeInTheDocument(); expect(screen.getByLabelText('ACP logo')).toBeInTheDocument(); expect(screen.getByLabelText('Kimi Code logo')).toBeInTheDocument(); - expect(screen.getAllByText(/May lose fidelity/)).toHaveLength(4); + expect(screen.getByLabelText('Prime Agent logo')).toBeInTheDocument(); + expect(screen.getAllByText(/May lose fidelity/)).toHaveLength(5); expect(screen.getByRole('button', { name: /^oh-my-pi/i })).toHaveAttribute('title', expect.stringContaining('May lose fidelity')); }); diff --git a/src/dashboard/frontend/src/components/sessionFeed/ConversationFeedCard.tsx b/src/dashboard/frontend/src/components/sessionFeed/ConversationFeedCard.tsx index 88c6ed35d55..57b5c551db6 100644 --- a/src/dashboard/frontend/src/components/sessionFeed/ConversationFeedCard.tsx +++ b/src/dashboard/frontend/src/components/sessionFeed/ConversationFeedCard.tsx @@ -24,6 +24,7 @@ const AGENT_LABELS: Record<string, string> = { ohmypi: 'oh-my-pi', codex: 'Codex', kimi_code: 'Kimi Code', + prime_agent: 'Prime Agent', acp: 'Kimi', unknown: 'Unknown', }; @@ -78,6 +79,7 @@ function harnessForFeedAgent(agent: string): Harness { if (agent === 'codex') return 'codex'; if (agent === 'acp') return 'acp'; if (agent === 'kimi_code' || agent === 'kimi-code') return 'kimi-code'; + if (agent === 'prime_agent' || agent === 'prime-agent') return 'prime-agent'; return 'claude-code'; } diff --git a/src/dashboard/frontend/src/components/sessionFeed/useConversationFeed.ts b/src/dashboard/frontend/src/components/sessionFeed/useConversationFeed.ts index d85f25542e4..d9bc27960bc 100644 --- a/src/dashboard/frontend/src/components/sessionFeed/useConversationFeed.ts +++ b/src/dashboard/frontend/src/components/sessionFeed/useConversationFeed.ts @@ -18,7 +18,7 @@ export interface ConversationFeedRow { issueId: string | null; cwd?: string | null; title?: string | null; - harness?: 'claude-code' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | null; + harness?: 'claude-code' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent' | null; archivedAt?: string | null; messageCount?: number; } diff --git a/src/dashboard/frontend/src/components/shared/ModelPicker/ModelPicker.tsx b/src/dashboard/frontend/src/components/shared/ModelPicker/ModelPicker.tsx index 0fd0a37be5c..ba91abba88c 100644 --- a/src/dashboard/frontend/src/components/shared/ModelPicker/ModelPicker.tsx +++ b/src/dashboard/frontend/src/components/shared/ModelPicker/ModelPicker.tsx @@ -12,7 +12,7 @@ import styles from './ModelPicker.module.css'; export const FALLBACK_COMPACTION_MODEL = 'claude-haiku-4-5-20251001'; -export type Harness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; +export type Harness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent'; export type AuthMode = 'api-key' | 'subscription'; export const HARNESS_OPTIONS: Array<{ id: Harness; label: string; description: string }> = [ @@ -21,6 +21,7 @@ export const HARNESS_OPTIONS: Array<{ id: Harness; label: string; description: s { id: 'codex', label: 'Codex', description: 'OpenAI Codex CLI harness' }, { id: 'acp', label: 'ACP', description: 'Agent Client Protocol harness' }, { id: 'kimi-code', label: 'Kimi Code', description: 'Moonshot Kimi Code CLI (native, Kimi models only)' }, + { id: 'prime-agent', label: 'Prime Agent', description: 'Managed persistent Prime RPC session' }, ]; export const PI_TOS_BLOCK_REASON = 'ohmypi cannot run Anthropic models when authenticated via Claude Code subscription (Terms of Service). Switch Anthropic to API-key auth, or pick a non-Anthropic model.'; diff --git a/src/dashboard/frontend/src/components/shared/ModelPicker/harnessRows.ts b/src/dashboard/frontend/src/components/shared/ModelPicker/harnessRows.ts index 8ef00927531..6a5f718fb33 100644 --- a/src/dashboard/frontend/src/components/shared/ModelPicker/harnessRows.ts +++ b/src/dashboard/frontend/src/components/shared/ModelPicker/harnessRows.ts @@ -27,6 +27,7 @@ export const KIMI_NATIVE_EFFORT_LEVELS = ['low', 'high', 'max'] as const; const HARNESS_ROW_LABELS = { 'claude-code': 'Claude Code', 'kimi-code': 'Kimi Code CLI', + 'prime-agent': 'Prime Agent', acp: 'ACP (Kimi Code)', } as const; diff --git a/src/dashboard/frontend/src/components/shared/branding/__tests__/branding.test.tsx b/src/dashboard/frontend/src/components/shared/branding/__tests__/branding.test.tsx index 8988ff8a6da..a32f73c18e2 100644 --- a/src/dashboard/frontend/src/components/shared/branding/__tests__/branding.test.tsx +++ b/src/dashboard/frontend/src/components/shared/branding/__tests__/branding.test.tsx @@ -22,7 +22,7 @@ describe('shared branding registry', () => { 'dashscope', 'openrouter', ]); - expect(Object.keys(HARNESS_BRANDS)).toEqual(['claude-code', 'codex', 'ohmypi', 'acp', 'kimi-code']); + expect(Object.keys(HARNESS_BRANDS)).toEqual(['claude-code', 'codex', 'ohmypi', 'acp', 'kimi-code', 'prime-agent']); for (const brand of [...Object.values(PROVIDER_BRANDS), ...Object.values(HARNESS_BRANDS)]) { expect(brand.id).toBeTruthy(); diff --git a/src/dashboard/frontend/src/components/shared/branding/index.tsx b/src/dashboard/frontend/src/components/shared/branding/index.tsx index 0660f4991b4..cd340b145c0 100644 --- a/src/dashboard/frontend/src/components/shared/branding/index.tsx +++ b/src/dashboard/frontend/src/components/shared/branding/index.tsx @@ -116,6 +116,7 @@ export const HARNESS_BRANDS: Record<Harness, BrandDef> = { ohmypi: { id: 'ohmypi', label: 'oh-my-pi', color: '#7c3aed', Icon: PiHarnessIcon }, acp: { id: 'acp', label: 'ACP', color: '#475569', Icon: AcpHarnessIcon }, 'kimi-code': { id: 'kimi-code', label: 'Kimi Code', color: PROVIDER_BRANDS.kimi.color, Icon: KimiIcon }, + 'prime-agent': { id: 'prime-agent', label: 'Prime Agent', color: '#64748b', Icon: PiHarnessIcon }, }; export const PROVIDER_COLORS: Record<Provider, string> = Object.fromEntries( diff --git a/src/dashboard/frontend/src/types.ts b/src/dashboard/frontend/src/types.ts index 957e98c9727..e8943b5d4c9 100644 --- a/src/dashboard/frontend/src/types.ts +++ b/src/dashboard/frontend/src/types.ts @@ -93,7 +93,7 @@ export interface Agent { id: string; issueId?: string; runtime: string; - harness?: 'claude-code' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | null; + harness?: 'claude-code' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent' | null; model: string; status: 'healthy' | 'warning' | 'stuck' | 'stalled' | 'dead' | 'stopped' | 'starting' | 'running' | 'failed' | 'error' | 'unknown'; error?: string; diff --git a/src/dashboard/server/routes/__tests__/conversations-supervisor.test.ts b/src/dashboard/server/routes/__tests__/conversations-supervisor.test.ts index 448fb8f2e68..d4d35838886 100644 --- a/src/dashboard/server/routes/__tests__/conversations-supervisor.test.ts +++ b/src/dashboard/server/routes/__tests__/conversations-supervisor.test.ts @@ -613,4 +613,31 @@ describe('spawnConversationSession PTY supervisor wiring', () => { vi.useRealTimers(); } }); + + it('does not replace a Prime conversation when its durable session is missing', async () => { + resolvedConversationHarness = 'prime-agent'; + const name = 'restart-prime-without-session'; + const session = 'conv-restart-prime-without-session'; + listedSessionNames = [session]; + const conversations = await import('../../../../lib/overdeck/conversations.js'); + conversations.createConversation({ + name, + tmuxSession: session, + cwd: tmpdir(), + claudeSessionId: 'prime-session', + model: 'claude-sonnet-4-6', + harness: 'prime-agent', + }); + const { handleConversationRestartAll } = await import('../../../../lib/overdeck/conversation-runtime.js'); + const callsBefore = createSessionCalls.length; + + const result = decodeJsonResponse(await handleConversationRestartAll({ + resolveSessionFile: vi.fn().mockResolvedValue(null), + })); + + expect(result['results']).toEqual([ + { name, model: 'claude-sonnet-4-6', status: 'failed' }, + ]); + expect(createSessionCalls).toHaveLength(callsBefore); + }); }); diff --git a/src/dashboard/server/routes/command-deck.ts b/src/dashboard/server/routes/command-deck.ts index f6d7a91b9b5..a7772bda27f 100644 --- a/src/dashboard/server/routes/command-deck.ts +++ b/src/dashboard/server/routes/command-deck.ts @@ -66,6 +66,7 @@ import { resolveJsonlPath } from './jsonl-resolver.js'; import { buildReviewerNodes, readSynthesisRounds, type ReviewerRoundMetadata } from './reviewer-tree.js'; import { PAN_CONTINUE_FILENAME, PAN_DIRNAME } from '../../../lib/pan-dir/types.js'; import { isPlanningComplete, readWorkspacePlan } from '../../../lib/xbrief/io.js'; +import { projectRuntimeSession } from '../services/runtime-session-projection.js'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); @@ -270,6 +271,7 @@ export async function fetchActivityDataWithContext( roundMetadata?: ReviewerRoundMetadata; modelOrigin?: ModelOriginData; planningComplete?: boolean; + harness?: string; lastActivity?: string; tokenUsage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number }; cost?: number; }> = []; // Shared workspace path for JSONL resolution (PAN-821) @@ -339,10 +341,7 @@ export async function fetchActivityDataWithContext( : null; const agentSnapshot = context.agentSnapshotsById?.get(checkId); - // Resolve JSONL path for conversation rendering (PAN-821) const jsonlPath = await resolveJsonlPath(checkId, workspacePath); - - // Only expose interactive terminal for work/planning/strike sessions (PAN-821 review) const exposeInteractiveTerminal = sectionType === 'work' || sectionType === 'planning' || sectionType === 'strike'; // Terminal-end signal: endedAt is populated only when the session has @@ -381,6 +380,7 @@ export async function fetchActivityDataWithContext( hasJsonl: !!jsonlPath, tmuxSession: exposeInteractiveTerminal ? checkId : undefined, planningComplete: sectionType === 'planning' ? planningFinished : undefined, + ...projectRuntimeSession(checkId, state.harness ?? 'claude-code'), }); } catch { /* skip malformed state */ } } diff --git a/src/dashboard/server/routes/context.ts b/src/dashboard/server/routes/context.ts index e07a0339fba..b86c8c29a7c 100644 --- a/src/dashboard/server/routes/context.ts +++ b/src/dashboard/server/routes/context.ts @@ -76,7 +76,7 @@ type DashboardContextSyncResponse = ContextSyncResponse & { type RuleScope = 'universal' | 'dev'; -const PREVIEW_HARNESSES: readonly Harness[] = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code']; +const PREVIEW_HARNESSES: readonly Harness[] = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code', 'prime-agent']; const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; const execFileAsync = promisify(execFile); const decodePreviewRequest = Schema.decodeUnknownSync(ContextPreviewRequest); @@ -439,6 +439,10 @@ function fullPromptPreview(previews: Record<Harness, string>): string { '', previews['kimi-code'] || '(no rendered context)', '', + '## Overdeck-controlled Prime Agent bundle', + '', + previews['prime-agent'] || '(no rendered context)', + '', '## Runtime-only sections', '', '- Memory retrieval: injected at agent spawn when enabled; unavailable in this layer editor preview.', @@ -505,6 +509,7 @@ export async function previewContextLayers( codex: previews.codex, acp: previews.acp, 'kimi-code': previews['kimi-code'], + 'prime-agent': previews['prime-agent'], fullPrompt: fullPromptPreview(previews), }, diagnostics: diagnosticsForLayers(layers, drafts), diff --git a/src/dashboard/server/routes/projects.ts b/src/dashboard/server/routes/projects.ts index 90ba3297d01..de4e3351732 100644 --- a/src/dashboard/server/routes/projects.ts +++ b/src/dashboard/server/routes/projects.ts @@ -72,7 +72,7 @@ import { findSpecByIssue } from '../../../lib/pan-dir/specs.js'; import { getOverdeckHome } from '../../../lib/paths.js'; import { parseIssueIdFromTextSync } from '../../../lib/resource-utils.js'; import { isDiscoverableAgentSession } from '../services/resource-discovery.js'; - +import { projectRuntimeSession } from '../services/runtime-session-projection.js'; // ─── Shared IssueDataService (via singleton) ──────────────────────────────── async function getIssueDataService(): Promise<IssueDataService> { @@ -295,7 +295,7 @@ async function collectSessionTreeNodes( awaitingInputReason: awaitingInput?.reason, pendingInputKinds: context.agentSnapshotsById?.get(checkId)?.pendingInputKinds, hasJsonl: !!jsonlPath, - harness: state.harness, + ...projectRuntimeSession(checkId, state.harness ?? 'claude-code'), deliveryMethod: state.deliveryMethod, planningComplete: isPlanning ? planningFinished : undefined, ...await readSessionGateFields(checkId, state), diff --git a/src/dashboard/server/routes/specialists/project-routes.ts b/src/dashboard/server/routes/specialists/project-routes.ts index 1a0eee96733..4ef7a9bc854 100644 --- a/src/dashboard/server/routes/specialists/project-routes.ts +++ b/src/dashboard/server/routes/specialists/project-routes.ts @@ -19,6 +19,7 @@ import { killSession } from '../../../../lib/tmux.js'; import { getAgentStateSync, saveAgentRuntimeState } from '../../../../lib/agents.js'; import { REVIEW_SUB_ROLES, type ReviewSubRole } from '../../../../lib/cloister/review-monitor.js'; import { resolveReviewParentRunState } from '../../../../lib/cloister/review-run-recovery.js'; +import type { RuntimeName } from '../../../../lib/runtimes/types.js'; import { jsonResponse } from '../../http-helpers.js'; import { httpHandler } from '../http-handler.js'; import { execAsync, readJsonBody, validateSpecialistAgentName } from './shared.js'; @@ -570,7 +571,7 @@ const postProjectReviewRestartRoute = HttpRouter.add( const project = params['project'] as string; const issueId = params['issueId'] as string; const body = yield* readJsonBody; - const { model, harness } = body as { model?: string; harness?: 'claude-code' | 'pi' | 'codex' }; + const { model, harness } = body as { model?: string; harness?: RuntimeName }; const { killAllReviewerSessions } = yield* Effect.promise( () => import('../../../../lib/cloister/review-agent.js'), diff --git a/src/dashboard/server/services/conversation-lifecycle.ts b/src/dashboard/server/services/conversation-lifecycle.ts index f96ad813002..88c386931d8 100644 --- a/src/dashboard/server/services/conversation-lifecycle.ts +++ b/src/dashboard/server/services/conversation-lifecycle.ts @@ -38,7 +38,7 @@ import { import { isRespawnPending } from './pending-respawn.js'; import { encodeClaudeProjectDir, sessionFilePath, getOverdeckHome } from '../../../lib/paths.js'; import { getHarnessBehavior } from '../../../lib/runtimes/behavior.js'; -import type { HarnessName } from '../../../lib/runtimes/types.js'; +import type { HarnessName, RuntimeName } from '../../../lib/runtimes/types.js'; import { cleanupUnreferencedConversationAttachments, runInBatches } from './conversation-attachments.js'; const POLL_INTERVAL_MS = 10_000; @@ -115,7 +115,7 @@ interface AgentStateFile { workspace?: string; role?: string; model?: string; - harness?: 'claude-code' | 'pi' | 'codex'; + harness?: RuntimeName; startedAt?: string; } diff --git a/src/dashboard/server/services/flywheel-actions.ts b/src/dashboard/server/services/flywheel-actions.ts index 4eaa28b6f02..8e28ecb2dec 100644 --- a/src/dashboard/server/services/flywheel-actions.ts +++ b/src/dashboard/server/services/flywheel-actions.ts @@ -10,6 +10,7 @@ import { resolveHarness } from '../../../lib/harness-resolve.js'; import { FLYWHEEL_ORCHESTRATOR_AGENT_ID, isFlywheelDevcontainerRuntime, loadResumeSessionId, saveResumeSessionId, spawnFlywheelAgent } from '../../../lib/cloister/flywheel.js'; import { FLYWHEEL_ACTIVE_RUN_ID_KEY, FLYWHEEL_GLOBAL_PAUSE_KEY } from '../../../lib/overdeck/control-settings.js'; import { isPaneDead, killSession, sessionExists } from '../../../lib/tmux.js'; +import type { RuntimeName } from '../../../lib/runtimes/types.js'; import { abortFlywheelRun, getFlywheelRunDetail, @@ -38,7 +39,7 @@ interface FlywheelGateSnapshot { } interface ResolvedFlywheelRoleConfig { - harness: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; + harness: RuntimeName; model: string; effort: RoleEffort; minAgents: number; @@ -146,7 +147,7 @@ async function createInitialFlywheelStatus( startedAt: string, cwd: string, agentModel: string | undefined, - agentHarness: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | undefined, + agentHarness: RuntimeName | undefined, roleConfig: ResolvedFlywheelRoleConfig, ): Promise<FlywheelStatus> { const ramTotalMb = mb(totalmem()); diff --git a/src/dashboard/server/services/runtime-session-projection.ts b/src/dashboard/server/services/runtime-session-projection.ts new file mode 100644 index 00000000000..668568cdf5e --- /dev/null +++ b/src/dashboard/server/services/runtime-session-projection.ts @@ -0,0 +1,28 @@ +import type { RuntimeName, TokenUsage } from '../../../lib/runtimes/types.js'; +import { getGlobalRegistry } from '../../../lib/runtimes/index.js'; + +export interface RuntimeSessionProjection { + harness: RuntimeName; + lastActivity?: string; + tokenUsage?: TokenUsage; + cost?: number; +} + +export function projectRuntimeSession(agentId: string, harness: RuntimeName): RuntimeSessionProjection { + try { + const runtime = getGlobalRegistry().get(harness); + if (!runtime) return { harness }; + const metrics = runtime.getSessionMetrics?.(agentId); + const lastActivity = metrics?.lastActivity ?? runtime.getLastActivity(agentId); + const tokenUsage = metrics?.tokenUsage; + const cost = metrics?.cost?.totalCost; + return { + harness, + ...(lastActivity ? { lastActivity: lastActivity.toISOString() } : {}), + ...(tokenUsage ? { tokenUsage } : {}), + ...(cost !== undefined ? { cost } : {}), + }; + } catch { + return { harness }; + } +} diff --git a/src/lib/__tests__/harness-binary.test.ts b/src/lib/__tests__/harness-binary.test.ts index 9383cc5e744..47ec345b309 100644 --- a/src/lib/__tests__/harness-binary.test.ts +++ b/src/lib/__tests__/harness-binary.test.ts @@ -225,6 +225,15 @@ describe('prepareHarnessLaunch', () => { 'Kimi Code CLI configured executable "/configured/missing-kimi" was not found or is not executable', ); }); + + it('returns an actionable Prime Agent error before creating a terminal session', async () => { + await expect(prepareHarnessLaunch('prime-agent', { + executablePath: '/configured/missing-prime-agent', + accessExecutable: executableAccess([]), + })).rejects.toThrow( + 'Prime Agent configured executable "/configured/missing-prime-agent" was not found or is not executable. Fix its configured path, then restart Overdeck. No terminal session was created.', + ); + }); }); describe('harnessBinaryName', () => { @@ -232,6 +241,10 @@ describe('harnessBinaryName', () => { expect(harnessBinaryName('kimi-code')).toBe('kimi'); expect(harnessBinaryName('acp')).toBe('kimi'); }); + + it("maps 'prime-agent' to the 'prime-agent' binary", () => { + expect(harnessBinaryName('prime-agent')).toBe('prime-agent'); + }); }); describe('configuredHarnessBinaryPath', () => { @@ -250,6 +263,11 @@ describe('configuredHarnessBinaryPath', () => { expect(configuredHarnessBinaryPath('acp')).toBe('/opt/acp/bin/kimi'); }); + it("reads config.primeAgent.binaryPath for 'prime-agent'", () => { + configMock.loadConfigSync.mockReturnValueOnce({ config: { primeAgent: { binaryPath: '/opt/prime/bin/prime-agent' } } }); + expect(configuredHarnessBinaryPath('prime-agent')).toBe('/opt/prime/bin/prime-agent'); + }); + it('returns undefined for harnesses with no configured binary path', () => { configMock.loadConfigSync.mockReturnValueOnce({ config: {} }); expect(configuredHarnessBinaryPath('claude-code')).toBeUndefined(); diff --git a/src/lib/__tests__/system-prerequisites.test.ts b/src/lib/__tests__/system-prerequisites.test.ts index 46cd5e79bc4..535f5e4cf17 100644 --- a/src/lib/__tests__/system-prerequisites.test.ts +++ b/src/lib/__tests__/system-prerequisites.test.ts @@ -50,6 +50,19 @@ describe('checkSystemPrerequisites', () => { }); }); + it('routes Prime Agent through its configured harness executable resolver', async () => { + const calls: Array<{ command: string; primeAgentHarness: boolean }> = []; + const report = await checkSystemPrerequisites( + async (cmd) => `${cmd} 0.1.0`, + async (command, options) => { + calls.push({ command, primeAgentHarness: options?.primeAgentHarness === true }); + return `/resolved/bin/${command}`; + }, + ); + expect(calls).toContainEqual({ command: 'prime-agent', primeAgentHarness: true }); + expect(report.checks.find((check) => check.id === 'prime-agent')).toMatchObject({ found: true }); + }); + it('flags missing required tools and keeps optional misses non-blocking', async () => { const missingTmuxAndDocker = async (command: string) => command === 'tmux' || command === 'docker' ? null : `/resolved/bin/${command}`; diff --git a/src/lib/agents/delivery.ts b/src/lib/agents/delivery.ts index 37b7e335441..eb4e222d0c6 100644 --- a/src/lib/agents/delivery.ts +++ b/src/lib/agents/delivery.ts @@ -31,7 +31,7 @@ import { export type DeliveryResult = { ok: boolean; - path: 'app-server' | 'acp' | 'supervisor' | 'channels' | 'tmux' | 'pi' | 'codex'; + path: 'app-server' | 'acp' | 'supervisor' | 'channels' | 'tmux' | 'pi' | 'codex' | 'prime-agent'; failure?: string; /** True when the delivery was suppressed by the keyed dedup record — the * side effect already happened on an earlier call with the same key. */ @@ -297,6 +297,7 @@ export async function deliverAgentMessage( resolvedMethod ??= 'auto'; } + if (state?.harness === 'prime-agent' || await import('../prime-agent/session-controller.js').then(({ hasPrimeAgentSession }) => hasPrimeAgentSession(normalizedId))) return import('../prime-agent/session-controller.js').then(({ deliverPrimeAgentMessage }) => deliverPrimeAgentMessage(normalizedId, message)).then(() => ({ ok: true, path: 'prime-agent' as const })); const isAcpTarget = state?.harness === 'acp'; if (isAcpTarget && resolvedMethod !== 'auto') { throw new Error( @@ -475,7 +476,6 @@ export async function deliverAgentMessage( await Effect.runPromise(sendKeys(normalizedId, message)); return { ok: true, path: 'tmux' }; } - /** * Keyed delivery cascade (PAN-2997). Only two tiers may carry a keyed * message, because only their crash-independent components enforce the key diff --git a/src/lib/agents/provider-auth.ts b/src/lib/agents/provider-auth.ts new file mode 100644 index 00000000000..90fe5150944 --- /dev/null +++ b/src/lib/agents/provider-auth.ts @@ -0,0 +1,30 @@ +import { Effect } from 'effect'; +import { getClaudeAuthStatus } from '../claude-auth.js'; +import { loadConfigSync as loadYamlConfig } from '../config-yaml.js'; +import { getOpenAIAuthStatus } from '../openai-auth.js'; +import { getProviderForModelSync } from '../providers.js'; +import type { AuthMode } from '../subscription-types.js'; + +export async function getProviderAuthMode(model: string): Promise<AuthMode | undefined> { + const provider = getProviderForModelSync(model); + if (provider.name === 'anthropic') { + const authStatus = await Effect.runPromise(getClaudeAuthStatus()); + if (authStatus.hasAnthropicApiKey) return 'api-key'; + return authStatus.loggedIn ? 'subscription' : undefined; + } + + if (provider.name === 'openai') { + const { config } = loadYamlConfig(); + const authStatus = await Effect.runPromise(getOpenAIAuthStatus()); + return authStatus.loggedIn + ? 'subscription' + : (config.providerAuth?.openai ?? 'api-key'); + } + + if (provider.name === 'google') { + const { config } = loadYamlConfig(); + return config.providerAuth?.google; + } + + return undefined; +} diff --git a/src/lib/agents/runtime-command.ts b/src/lib/agents/runtime-command.ts index fa9b9c4fd9d..64a757e47b7 100644 --- a/src/lib/agents/runtime-command.ts +++ b/src/lib/agents/runtime-command.ts @@ -13,7 +13,6 @@ import { getClaudePermissionFlagsStringSync } from '../claude-permissions.js'; import { loadConfigSync as loadYamlConfig } from '../config-yaml.js'; import type { RoleEffort } from '../config-yaml.js'; import { ensureSessionContextBriefingFile } from '../briefing-freshness.js'; -import { getClaudeAuthStatus } from '../claude-auth.js'; import { workspaceContextFile } from '../context-layers/layers.js'; import { materializeAcpContextFile } from '../acp/context.js'; import { getHarnessBehavior } from '../runtimes/behavior.js'; @@ -22,18 +21,24 @@ import { createOhmypiFifo, ohmypiFifoPaths, OhmypiNotReady, writeOhmypiCommandSy import { createPiFifo, piFifoPaths, PiNotReady, writePiCommandSync } from '../runtimes/pi-fifo.js'; import type { RuntimeName } from '../runtimes/types.js'; import { requireModelOverrideSync, shellQuoteModelIdSync } from '../model-validation.js'; -import { getOpenAIAuthStatus } from '../openai-auth.js'; import { getOverdeckHome, packageRoot, resolveOhmypiExtensionPath, resolvePiExtensionPath } from '../paths.js'; import { getProviderForModelSync, resolveKimiCodeModelAlias } from '../providers.js'; -import type { AuthMode } from '../subscription-types.js'; import { capturePane, sessionExists } from '../tmux.js'; import { getAgentDir, getAgentStateSync, type Role } from './agent-state.js'; import { waitForReadySignal } from './identity.js'; import { CLI_PROXY_MODEL_ALIASES } from './provider-env.js'; +import { buildPrimeAgentBaseCommand } from '../prime-agent/launch-command.js'; +import { getProviderAuthMode } from './provider-auth.js'; +export { getProviderAuthMode } from './provider-auth.js'; const execAsync = promisify(exec); const missingRoleDefinitionWarnings = new Set<string>(); +export async function getPrimeAgentBaseCommand(agentId: string, model: string, workspace: string): Promise<string> { + const authMode = await getProviderAuthMode(model) ?? 'api-key'; + return buildPrimeAgentBaseCommand({ agentId, model, workspace, authMode }); +} + export interface RoleMcpServerDef { type?: string; command?: string; @@ -714,30 +719,6 @@ export async function writeOhmypiAgentPrompt(agentId: string, prompt: string, ti } } -export async function getProviderAuthMode(model: string): Promise<AuthMode | undefined> { - const provider = getProviderForModelSync(model); - if (provider.name === 'anthropic') { - const authStatus = await Effect.runPromise(getClaudeAuthStatus()); - if (authStatus.hasAnthropicApiKey) return 'api-key'; - return authStatus.loggedIn ? 'subscription' : undefined; - } - - if (provider.name === 'openai') { - const { config } = loadYamlConfig(); - const authStatus = await Effect.runPromise(getOpenAIAuthStatus()); - return authStatus.loggedIn - ? 'subscription' - : (config.providerAuth?.openai ?? 'api-key'); - } - - if (provider.name === 'google') { - const { config } = loadYamlConfig(); - return config.providerAuth?.google; - } - - return undefined; -} - /** * Build the base command that the launcher will exec for an agent. * @@ -776,6 +757,12 @@ export async function getAgentRuntimeBaseCommand( // command; return a stub base command so the launcher generator can short-circuit. return 'kimi-code'; } + if (behavior.launchCommandKind === 'prime-agent-rpc') { + if (!agentName) throw new Error('prime-agent launcher requires an agent ID'); + const workspace = getAgentStateSync(agentName)?.workspace; + if (!workspace) throw new Error(`prime-agent launcher cannot resolve workspace for ${agentName}`); + return getPrimeAgentBaseCommand(agentName, validatedModel, workspace); + } // Integration tests can inject a harmless harness command so a leaked or // intentionally-real tmux session never runs the production `claude` binary. @@ -969,6 +956,11 @@ export async function getRoleRuntimeBaseCommand( // command; return a stub base command so the launcher generator can short-circuit. return 'kimi-code'; } + if (behavior.launchCommandKind === 'prime-agent-rpc') { + const workspace = getAgentStateSync(agentName)?.workspace; + if (!workspace) throw new Error(`prime-agent launcher cannot resolve workspace for ${agentName}`); + return getPrimeAgentBaseCommand(agentName, validatedModel, workspace); + } // Integration tests can inject a harmless harness command so a leaked or // intentionally-real tmux session never runs the production `claude` binary. diff --git a/src/lib/artifacts/lifecycle.ts b/src/lib/artifacts/lifecycle.ts index 140d74fbc35..9faac60c14d 100644 --- a/src/lib/artifacts/lifecycle.ts +++ b/src/lib/artifacts/lifecycle.ts @@ -207,7 +207,7 @@ function parseAgentRole(value: string | undefined): ArtifactMetadata['agentRole' } export function parseAgentHarness(value: string | undefined): ArtifactMetadata['agentHarness'] { - if (value === 'claude-code' || value === 'pi' || value === 'ohmypi' || value === 'codex' || value === 'acp' || value === 'kimi-code' || value === 'user') { + if (value === 'claude-code' || value === 'pi' || value === 'ohmypi' || value === 'codex' || value === 'acp' || value === 'kimi-code' || value === 'prime-agent' || value === 'user') { return value; } return null; diff --git a/src/lib/cloister/flywheel.ts b/src/lib/cloister/flywheel.ts index d9e3aa89e30..de41e02ccea 100644 --- a/src/lib/cloister/flywheel.ts +++ b/src/lib/cloister/flywheel.ts @@ -16,6 +16,7 @@ import { findProjectByPathSync, getProjectSwarmHotspots } from '../projects.js'; import { getBookAsync as getOrderBook } from '../orders/resolver.js'; import { resolveStateReadHomeSync } from '../state-read-home.js'; import type { XBriefDocument } from '../xbrief/types.js'; +import type { RuntimeName } from '../runtimes/types.js'; import { getFlywheelActiveRunId, isFlywheelAutoPickupBacklog, @@ -75,7 +76,7 @@ export interface FlywheelLifecycleOptions { briefOverlayContent?: string; prompt?: string; model?: string; - harness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; + harness?: RuntimeName; effort?: RoleEffort; minAgents?: number; maxAgents?: number; diff --git a/src/lib/cloister/review-agent.ts b/src/lib/cloister/review-agent.ts index 25b16b19774..75aae3602f5 100644 --- a/src/lib/cloister/review-agent.ts +++ b/src/lib/cloister/review-agent.ts @@ -729,7 +729,6 @@ async function spawnReviewRoleForIssuePromise( * sessions so callers do not need to know which review phase has started. */ export { isReviewSessionForIssue }; - async function killAllReviewerSessionsPromise( projectKey: string | undefined, issueId: string, diff --git a/src/lib/composer-commands/__tests__/harness-matrix.test.ts b/src/lib/composer-commands/__tests__/harness-matrix.test.ts index 77ca0a23272..008900cec2f 100644 --- a/src/lib/composer-commands/__tests__/harness-matrix.test.ts +++ b/src/lib/composer-commands/__tests__/harness-matrix.test.ts @@ -195,7 +195,7 @@ describe('composer command harness interception matrix (all KNOWN_HARNESSES)', ( expect(decodeJsonResponse(response)).toEqual({ ok: true }); expect(deps.transformMessageForHarness).toHaveBeenCalledWith(message, harness, []); - if (harness === 'ohmypi') { + if (harness === 'ohmypi' || harness === 'prime-agent') { expect(mocks.deliverControl).toHaveBeenCalledOnce(); expect(mocks.deliverAgentMessage).not.toHaveBeenCalled(); } else { diff --git a/src/lib/config-yaml/__tests__/merge.test.ts b/src/lib/config-yaml/__tests__/merge.test.ts index 46f056898af..86cff191972 100644 --- a/src/lib/config-yaml/__tests__/merge.test.ts +++ b/src/lib/config-yaml/__tests__/merge.test.ts @@ -45,3 +45,29 @@ describe('ACP config merge', () => { expect(PROVIDERS.kimi.defaultHarness).toBe('kimi-code'); }); }); + +describe('Prime Agent config merge', () => { + it('defaults the RPC startup timeout without selecting a model', () => { + expect(mergeConfigs().config.primeAgent).toEqual({ rpcStartupTimeoutMs: 30_000 }); + }); + + it('merges binary and startup timeout overrides', () => { + const { config } = mergeConfigs({ + primeAgent: { + binaryPath: '/opt/prime/bin/prime-agent', + rpcStartupTimeoutMs: 45_000, + }, + }); + + expect(config.primeAgent).toEqual({ + binaryPath: '/opt/prime/bin/prime-agent', + rpcStartupTimeoutMs: 45_000, + }); + }); + + it('rejects a non-positive RPC startup timeout', () => { + expect(() => mergeConfigs({ primeAgent: { rpcStartupTimeoutMs: 0 } })).toThrow( + 'primeAgent.rpcStartupTimeoutMs must be a positive integer', + ); + }); +}); diff --git a/src/lib/config-yaml/defaults.ts b/src/lib/config-yaml/defaults.ts index 796aca82363..34582a4532f 100644 --- a/src/lib/config-yaml/defaults.ts +++ b/src/lib/config-yaml/defaults.ts @@ -250,6 +250,9 @@ export const DEFAULT_CONFIG: NormalizedConfig = { permissionMode: 'auto', }, kimiCode: {}, + primeAgent: { + rpcStartupTimeoutMs: 30_000, + }, }; /** diff --git a/src/lib/config-yaml/merge.ts b/src/lib/config-yaml/merge.ts index 0c536ddf492..ccdbd9eee0e 100644 --- a/src/lib/config-yaml/merge.ts +++ b/src/lib/config-yaml/merge.ts @@ -51,8 +51,8 @@ function normalizeProviderConfig( } function validateProviderHarness(provider: ModelProvider, harness: RuntimeName | undefined): void { - if (harness !== undefined && harness !== 'claude-code' && harness !== 'ohmypi' && harness !== 'codex' && harness !== 'acp' && harness !== 'kimi-code') { - throw new Error(`config.yaml: models.providers.${provider}.harness must be claude-code, ohmypi, codex, acp, or kimi-code`); + if (harness !== undefined && harness !== 'claude-code' && harness !== 'ohmypi' && harness !== 'codex' && harness !== 'acp' && harness !== 'kimi-code' && harness !== 'prime-agent') { + throw new Error(`config.yaml: models.providers.${provider}.harness must be claude-code, ohmypi, codex, acp, kimi-code, or prime-agent`); } } @@ -242,6 +242,9 @@ export function mergeConfigs(...configs: (YamlConfig | null)[]): { config: Norma permissionMode: DEFAULT_CONFIG.acp.permissionMode, }, kimiCode: {}, + primeAgent: { + rpcStartupTimeoutMs: DEFAULT_CONFIG.primeAgent.rpcStartupTimeoutMs, + }, }; // Track providers explicitly disabled in models.providers so that legacy @@ -810,6 +813,15 @@ export function mergeConfigs(...configs: (YamlConfig | null)[]): { config: Norma if (config.kimiCode?.binaryPath !== undefined) { result.kimiCode.binaryPath = config.kimiCode.binaryPath; } + if (config.primeAgent?.binaryPath !== undefined) { + result.primeAgent.binaryPath = config.primeAgent.binaryPath; + } + if (config.primeAgent?.rpcStartupTimeoutMs !== undefined) { + if (!Number.isInteger(config.primeAgent.rpcStartupTimeoutMs) || config.primeAgent.rpcStartupTimeoutMs <= 0) { + throw new Error('config.yaml: primeAgent.rpcStartupTimeoutMs must be a positive integer'); + } + result.primeAgent.rpcStartupTimeoutMs = config.primeAgent.rpcStartupTimeoutMs; + } // Merge remote work-agent provisioning settings mergeRemoteConfig(result, config); diff --git a/src/lib/config-yaml/roles.ts b/src/lib/config-yaml/roles.ts index 9baeece85c5..935b01d01ac 100644 --- a/src/lib/config-yaml/roles.ts +++ b/src/lib/config-yaml/roles.ts @@ -285,8 +285,8 @@ function validateRoleFields(role: Role, roleConfig: RoleConfig): void { if (roleConfig.autonomousModel !== undefined && typeof roleConfig.autonomousModel !== 'string') { throw new Error(`config.yaml: roles.${role}.autonomousModel must be a scalar model reference`); } - if (roleConfig.harness !== undefined && roleConfig.harness !== 'claude-code' && roleConfig.harness !== 'ohmypi' && roleConfig.harness !== 'codex' && roleConfig.harness !== 'acp' && roleConfig.harness !== 'kimi-code') { - throw new Error(`config.yaml: roles.${role}.harness must be claude-code, ohmypi, codex, acp, or kimi-code`); + if (roleConfig.harness !== undefined && roleConfig.harness !== 'claude-code' && roleConfig.harness !== 'ohmypi' && roleConfig.harness !== 'codex' && roleConfig.harness !== 'acp' && roleConfig.harness !== 'kimi-code' && roleConfig.harness !== 'prime-agent') { + throw new Error(`config.yaml: roles.${role}.harness must be claude-code, ohmypi, codex, acp, kimi-code, or prime-agent`); } if (roleConfig.effort !== undefined && !ROLE_EFFORTS.includes(roleConfig.effort)) { throw new Error(`config.yaml: roles.${role}.effort must be one of ${ROLE_EFFORTS.join(', ')}`); diff --git a/src/lib/config-yaml/schema.ts b/src/lib/config-yaml/schema.ts index bb7d8f7d3dd..de1a0415a3a 100644 --- a/src/lib/config-yaml/schema.ts +++ b/src/lib/config-yaml/schema.ts @@ -379,7 +379,7 @@ export interface RoleConfig { model: RoleModelRef; /** Explicit scalar staffing model for autonomous planning dispatch. */ autonomousModel?: RoleModelRef; - harness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; + harness?: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent'; effort?: RoleEffort; mode?: ReviewMode; /** @@ -664,6 +664,12 @@ export interface YamlConfig { binaryPath?: string; }; + /** Prime Agent RPC process settings. */ + primeAgent?: { + binaryPath?: string; + rpcStartupTimeoutMs?: number; + }; + /** Remote work-agent provisioning settings (dashboard-editable subset). */ remote?: RemoteConfig; } @@ -992,6 +998,12 @@ export interface NormalizedConfig { binaryPath?: string; }; + /** Prime Agent RPC process settings. */ + primeAgent: { + binaryPath?: string; + rpcStartupTimeoutMs: number; + }; + /** Remote work-agent provisioning settings surfaced by the dashboard. */ remote?: NormalizedRemoteConfig; } diff --git a/src/lib/context-layers/harness.ts b/src/lib/context-layers/harness.ts index 2a47edf9490..b6c60308cba 100644 --- a/src/lib/context-layers/harness.ts +++ b/src/lib/context-layers/harness.ts @@ -24,7 +24,7 @@ import type { Harness } from '@overdeck/contracts'; /** Short marker name used in `{{#harness:<name>}}` blocks. */ -export type HarnessMarker = 'claude' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; +export type HarnessMarker = 'claude' | 'pi' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent'; /** Maps a Overdeck {@link Harness} to its templating marker name. */ export const HARNESS_MARKERS: Record<Harness, HarnessMarker> = { @@ -33,10 +33,11 @@ export const HARNESS_MARKERS: Record<Harness, HarnessMarker> = { codex: 'codex', acp: 'acp', 'kimi-code': 'kimi-code', + 'prime-agent': 'prime-agent', }; /** Marker names Overdeck ships an adapter for in v1. */ -export const KNOWN_HARNESS_MARKERS: ReadonlySet<string> = new Set<HarnessMarker>(['claude', 'pi', 'ohmypi', 'codex', 'acp', 'kimi-code']); +export const KNOWN_HARNESS_MARKERS: ReadonlySet<string> = new Set<HarnessMarker>(['claude', 'pi', 'ohmypi', 'codex', 'acp', 'kimi-code', 'prime-agent']); /** Matches an open `{{#harness:x}}` or close `{{/harness:x}}` marker. */ const MARKER_RE = /\{\{([#/])harness:([a-zA-Z0-9_-]+)\}\}/g; diff --git a/src/lib/context-layers/index.ts b/src/lib/context-layers/index.ts index 41052bcf0e8..355e9e10d22 100644 --- a/src/lib/context-layers/index.ts +++ b/src/lib/context-layers/index.ts @@ -34,6 +34,7 @@ export { resolveWorkspaceContextFile, piGlobalContextFile, codexGlobalContextFile, + primeAgentGlobalContextFile, GLOBAL_STARTER, PROJECT_STARTER, globalLayer, diff --git a/src/lib/context-layers/layers.ts b/src/lib/context-layers/layers.ts index 24034fc4e70..bcd3d5d9eb1 100644 --- a/src/lib/context-layers/layers.ts +++ b/src/lib/context-layers/layers.ts @@ -104,6 +104,11 @@ export function codexGlobalContextFile(): string { return join(getOverdeckHome(), 'context', 'codex-global.md'); } +/** `~/.overdeck/context/prime-agent-global.md` — supplemental Prime context. */ +export function primeAgentGlobalContextFile(): string { + return join(getOverdeckHome(), 'context', 'prime-agent-global.md'); +} + // ─── Starter templates ──────────────────────────────────────────────────── /** Seeded into `global.md` by `pan install` / first `pan sync`. */ diff --git a/src/lib/context-layers/rendered-global.ts b/src/lib/context-layers/rendered-global.ts new file mode 100644 index 00000000000..d5626fbe2e3 --- /dev/null +++ b/src/lib/context-layers/rendered-global.ts @@ -0,0 +1,10 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; + +export function writeRenderedGlobalContext(file: string, content: string): boolean { + const existing = existsSync(file) ? readFileSync(file, 'utf-8') : ''; + if (content.trim() === existing.trim()) return false; + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${content.trim()}\n`, 'utf-8'); + return true; +} diff --git a/src/lib/conversations/prime-transcript-adapter.ts b/src/lib/conversations/prime-transcript-adapter.ts new file mode 100644 index 00000000000..70160c9cee7 --- /dev/null +++ b/src/lib/conversations/prime-transcript-adapter.ts @@ -0,0 +1,72 @@ +import { existsSync } from 'node:fs'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { getOverdeckHome } from '../paths.js'; +import { summarizeSerializedText } from './smart-compaction.js'; +import type { ConversationTranscriptAdapter } from './transcript-adapter-types.js'; + +const DETAIL_LIMIT = 4_000; + +function bounded(value: unknown): string { + const text = typeof value === 'string' ? value : JSON.stringify(value); + if (!text) return ''; + return text.length > DETAIL_LIMIT ? `${text.slice(0, DETAIL_LIMIT)}…` : text; +} + +function serializePrimeRecord(record: Record<string, unknown>, includeThinking: boolean): string | undefined { + const message = record.message && typeof record.message === 'object' ? record.message as Record<string, unknown> : record; + const role = message.role; + const type = String(record.type ?? message.type ?? ''); + const content = message.content ?? record.content ?? record.text; + + if ((role === 'user' || type === 'user') && content !== undefined) return `[user]\n${bounded(content)}`; + if ((role === 'assistant' || type === 'assistant') && content !== undefined) return `[assistant]\n${bounded(content)}`; + if ((type === 'thinking' || type === 'reasoning') && includeThinking) return `[assistant]\n[thinking]\n${bounded(content ?? record.thinking)}`; + if (type === 'tool_call' || type === 'tool-call' || type === 'python') { + const name = bounded(record.name ?? record.tool ?? type); + return `[tool_use: ${name}]\n${bounded(record.arguments ?? record.input ?? record.code)}`; + } + if (type === 'tool_result' || type === 'tool-result') return `[tool]\n${bounded(record.result ?? record.output ?? content)}`; + if (type === 'compaction' || type === 'compact') return `[system]\n[compaction]\n${bounded(record.summary ?? content)}`; + if (type === 'error') return `[system]\n[error]\n${bounded(record.error ?? record.message ?? content)}`; + return undefined; +} + +export const primeAgentAdapter: ConversationTranscriptAdapter = { + name: 'prime-agent', + supportsPlainForkAsSource: false, + supportsSourceAuthoredHandoff: false, + + async resolveSessionFile(conv) { + const sessionDir = join(getOverdeckHome(), 'agents', conv.tmuxSession, 'prime-sessions'); + if (!existsSync(sessionDir)) return null; + const files = (await readdir(sessionDir)).filter(file => file.endsWith('.jsonl')).sort(); + return files.length ? join(sessionDir, files.at(-1)!) : null; + }, + + async serializeTranscript(sessionFile, options) { + const includeThinking = options?.includeThinking ?? true; + const records = (await readFile(sessionFile, 'utf8')).split('\n'); + const parts: string[] = []; + for (const line of records) { + if (!line.trim()) continue; + try { + const serialized = serializePrimeRecord(JSON.parse(line) as Record<string, unknown>, includeThinking); + if (serialized) parts.push(serialized); + } catch { /* Ignore partial trailing records. */ } + } + return parts.join('\n\n'); + }, + + async compactSummary(sessionFile, options) { + const serialized = await primeAgentAdapter.serializeTranscript(sessionFile, options); + if (!serialized.trim()) return { summary: '', summaryModel: null }; + const summary = await summarizeSerializedText(serialized, { + model: options?.model, + richMode: options?.richMode ?? false, + harness: options?.harness ?? 'claude-code', + timeoutMs: options?.timeoutMs, + }); + return { summary, summaryModel: options?.model ?? null }; + }, +}; diff --git a/src/lib/conversations/transcript-adapter-types.ts b/src/lib/conversations/transcript-adapter-types.ts new file mode 100644 index 00000000000..e53d7550ad1 --- /dev/null +++ b/src/lib/conversations/transcript-adapter-types.ts @@ -0,0 +1,22 @@ +import type { LegacyConversation as Conversation } from '../overdeck/conversations.js'; +import type { RuntimeName } from '../runtimes/types.js'; + +export interface CompactSummaryOptions { + model?: string; + richMode?: boolean; + includeThinking?: boolean; + harness?: RuntimeName; + timeoutMs?: number; +} + +export interface ConversationTranscriptAdapter { + readonly name: RuntimeName; + readonly supportsPlainForkAsSource: boolean; + readonly supportsSourceAuthoredHandoff: boolean; + resolveSessionFile(conv: Conversation): Promise<string | null>; + serializeTranscript(sessionFile: string, options?: { includeThinking?: boolean }): Promise<string>; + compactSummary( + sessionFile: string, + options?: CompactSummaryOptions, + ): Promise<{ summary: string; summaryModel: string | null }>; +} diff --git a/src/lib/conversations/transcript-adapter.ts b/src/lib/conversations/transcript-adapter.ts index 090c71a055a..9e7088dc363 100644 --- a/src/lib/conversations/transcript-adapter.ts +++ b/src/lib/conversations/transcript-adapter.ts @@ -31,7 +31,6 @@ import { Effect } from 'effect'; import { resolveCodexRolloutPath } from '../../dashboard/server/routes/jsonl-resolver.js'; import type { AcpTranscriptEntry, AcpTranscriptToolCallState } from '../acp/transcript.js'; -import type { LegacyConversation as Conversation } from '../overdeck/conversations.js'; import type { RuntimeName } from '../runtimes/types.js'; import { getOverdeckHome, sessionFilePath } from '../paths.js'; import { findKimiWirePathAsync } from '../runtimes/kimi-code.js'; @@ -41,62 +40,9 @@ import { generateSmartSummary, summarizeSerializedText, } from './smart-compaction.js'; - -export interface CompactSummaryOptions { - /** Model for the summarizer LLM. */ - model?: string; - /** Use the rich (Claude-native-style) summary prompt variant. */ - richMode?: boolean; - /** Include thinking blocks in the serialized source. Default: true. */ - includeThinking?: boolean; - /** Harness backend the summarizer LLM runs on. Default: 'claude-code'. */ - harness?: RuntimeName; - /** Per-LLM-call timeout (ms). */ - timeoutMs?: number; -} - -export interface ConversationTranscriptAdapter { - /** Display name for logging/errors. */ - readonly name: RuntimeName; - - /** Plain forks copy raw Claude JSONL and spawn Claude with `--resume` — - * only Claude Code can be the source of a plain fork. */ - readonly supportsPlainForkAsSource: boolean; - - /** Source-authored handoff requires delivering a prompt to the live source - * agent and waiting for it to write a sentinel file. Claude Code supports - * this via deliverAgentMessage; Pi has no hook-equivalent signaling channel - * for the .done sentinel (see PAN-1134), so source authoring is gated off - * for Pi until that lands. */ - readonly supportsSourceAuthoredHandoff: boolean; - - /** Resolve the path to the source conversation's transcript file. - * Returns null if the conversation has no transcript yet (e.g. session - * just started and hasn't produced a JSONL line). */ - resolveSessionFile(conv: Conversation): Promise<string | null>; - - /** Serialize the transcript into a harness-agnostic canonical text that - * the handoff prompt template can embed verbatim. Each turn is rendered - * with a `[user]` / `[assistant]` header followed by text and tool-use - * summaries; thinking blocks are optional (defaults to including them). - */ - serializeTranscript(sessionFile: string, options?: { includeThinking?: boolean }): Promise<string>; - - /** - * Produce a model-generated compact summary of the transcript, suitable for - * seeding a summary fork or feeding the handoff authoring prompt. - * - * claude-code uses the entry-aware smart-compaction flow (rich, file-op - * aware); other harnesses serialize the transcript and run a generic - * chunk-and-merge summarizer over the text. The `harness` option selects the - * summarizer LLM backend and is independent of which adapter (= source - * harness) produced the transcript. - */ - compactSummary( - sessionFile: string, - options?: CompactSummaryOptions, - ): Promise<{ summary: string; summaryModel: string | null }>; -} +import { primeAgentAdapter } from './prime-transcript-adapter.js'; +import type { ConversationTranscriptAdapter } from './transcript-adapter-types.js'; +export type { CompactSummaryOptions, ConversationTranscriptAdapter } from './transcript-adapter-types.js'; // ─── Claude Code ────────────────────────────────────────────────────────── @@ -583,6 +529,7 @@ const REGISTRY: Partial<Record<RuntimeName, ConversationTranscriptAdapter>> = { 'codex': codexAdapter, 'acp': acpAdapter, 'kimi-code': kimiCodeAdapter, + 'prime-agent': primeAgentAdapter, }; /** diff --git a/src/lib/cost-parsers/prime-agent-parser.ts b/src/lib/cost-parsers/prime-agent-parser.ts new file mode 100644 index 00000000000..11aed957b75 --- /dev/null +++ b/src/lib/cost-parsers/prime-agent-parser.ts @@ -0,0 +1,57 @@ +export interface PrimeAgentUsageInput { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + cost?: number; + contextWindow?: number; +} + +export interface PrimeAgentUsageRecord extends PrimeAgentUsageInput { + contextPercent?: number; +} + +function finite(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +export function mapPrimeAgentSessionStats(stats: Record<string, unknown>): PrimeAgentUsageRecord { + const tokens = stats.tokens && typeof stats.tokens === 'object' ? stats.tokens as Record<string, unknown> : stats; + const input = finite(tokens.input ?? tokens.inputTokens); + const output = finite(tokens.output ?? tokens.outputTokens); + const cacheRead = finite(tokens.cacheRead ?? tokens.cache_read ?? tokens.cacheReadTokens); + const cacheWrite = finite(tokens.cacheWrite ?? tokens.cache_write ?? tokens.cacheWriteTokens); + const total = finite(tokens.total ?? tokens.totalTokens); + const cost = finite(stats.cost ?? stats.totalCost); + const contextWindow = finite(stats.contextWindow ?? stats.context_window); + const contextUsed = finite(stats.contextUsed ?? stats.context_used) ?? (input !== undefined && output !== undefined ? input + output : undefined); + return { + ...(input !== undefined ? { input } : {}), + ...(output !== undefined ? { output } : {}), + ...(cacheRead !== undefined ? { cacheRead } : {}), + ...(cacheWrite !== undefined ? { cacheWrite } : {}), + ...(total !== undefined ? { total } : {}), + ...(cost !== undefined ? { cost } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(contextWindow && contextUsed !== undefined ? { contextPercent: contextUsed / contextWindow * 100 } : {}), + }; +} + +export function parsePrimeAgentSessionContent(content: string): PrimeAgentUsageRecord { + const totals: PrimeAgentUsageRecord = {}; + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const record = JSON.parse(line) as Record<string, unknown>; + if (record.type !== 'assistant' && record.type !== 'session_stats') continue; + const mapped = mapPrimeAgentSessionStats(record.usage && typeof record.usage === 'object' ? record.usage as Record<string, unknown> : record); + for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'total', 'cost'] as const) { + if (mapped[key] !== undefined) totals[key] = (totals[key] ?? 0) + mapped[key]!; + } + if (mapped.contextWindow !== undefined) totals.contextWindow = mapped.contextWindow; + if (mapped.contextPercent !== undefined) totals.contextPercent = mapped.contextPercent; + } catch { /* Ignore partial trailing records. */ } + } + return totals; +} diff --git a/src/lib/harness-binary.ts b/src/lib/harness-binary.ts index 9b284c0e9b7..81c80216de8 100644 --- a/src/lib/harness-binary.ts +++ b/src/lib/harness-binary.ts @@ -17,6 +17,7 @@ export const HARNESS_BINARY_BY_RUNTIME: Record<RuntimeName, string> = { codex: 'codex', acp: 'kimi', 'kimi-code': 'kimi', + 'prime-agent': 'prime-agent', }; export type ExecutableCommandRunner = (command: string, args: string[]) => Promise<string>; @@ -151,6 +152,7 @@ export function harnessBinaryName(harness: RuntimeName): string { export function configuredHarnessBinaryPath(harness: RuntimeName): string | undefined { if (harness === 'acp') return loadConfigSync().config.acp?.kimi?.binaryPath; if (harness === 'kimi-code') return loadConfigSync().config.kimiCode?.binaryPath; + if (harness === 'prime-agent') return loadConfigSync().config.primeAgent?.binaryPath; return undefined; } @@ -187,7 +189,9 @@ export async function requireHarnessBinary( ? 'Codex CLI' : harness === 'kimi-code' ? 'Kimi Code CLI' - : 'Kimi Code CLI'; // acp drives the native Kimi Code CLI binary too + : harness === 'prime-agent' + ? 'Prime Agent' + : 'Kimi Code CLI'; // acp drives the native Kimi Code CLI binary too if (effectiveOptions.executablePath) { throw new Error( `${harnessName} configured executable "${effectiveOptions.executablePath}" was not found or is not executable. Fix its configured path, then restart Overdeck. No terminal session was created.`, diff --git a/src/lib/launcher-generator.ts b/src/lib/launcher-generator.ts index 6af5bbe2d7e..fae5d83733a 100644 --- a/src/lib/launcher-generator.ts +++ b/src/lib/launcher-generator.ts @@ -13,7 +13,7 @@ import { shellQuote } from './shell-quote.js'; export type LauncherSpawnMode = 'conversation' | 'remote' | 'resume'; -export type LauncherHarness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; +export type LauncherHarness = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent'; export interface LauncherConfig { role: Role; @@ -474,6 +474,9 @@ function buildCommand(config: LauncherConfig): string[] { if (behavior.launchCommandKind === 'kimi-code-tui') { return buildKimiCodeCommand(config, false); } + if (behavior.launchCommandKind === 'prime-agent-rpc') { + return buildPrimeAgentCommand(config, false); + } // Conversation panel doesn't use exec — it runs the command then loops if (config.baseCommand) { @@ -555,6 +558,9 @@ function buildReviewSubRoleCommand(config: LauncherConfig): string[] { * frontmatter), permission flags are skipped — the frontmatter handles them. */ function buildNonConversationCommand(config: LauncherConfig, useExec: boolean): string[] { + if (getHarnessBehavior(config.harness ?? 'claude-code').launchCommandKind === 'prime-agent-rpc') { + return buildPrimeAgentCommand(config, useExec); + } const behavior = getHarnessBehavior(config.harness ?? 'claude-code'); if (behavior.launchCommandKind === 'ohmypi-rpc') { return buildOhmypiCommand(config, useExec); @@ -568,6 +574,9 @@ function buildNonConversationCommand(config: LauncherConfig, useExec: boolean): if (behavior.launchCommandKind === 'kimi-code-tui') { return buildKimiCodeCommand(config, useExec); } + if (behavior.launchCommandKind === 'prime-agent-rpc') { + return config.baseCommand ? [`${useExec ? 'exec ' : ''}${config.baseCommand}`] : []; + } const parts: string[] = []; if (!config.baseCommand) return parts; @@ -623,6 +632,15 @@ function buildNonConversationCommand(config: LauncherConfig, useExec: boolean): return parts; } +function buildPrimeAgentCommand(config: LauncherConfig, useExec: boolean): string[] { + if (!config.baseCommand) return []; + const args: string[] = []; + if (config.resumeSessionId) args.push(`--resume ${shellQuote(config.resumeSessionId)}`); + if (config.promptFile) args.push(`--prompt-file ${shellQuote(config.promptFile)}`); + else if (config.promptInline) args.push(`--prompt ${shellQuote(config.promptInline)}`); + return [`${useExec ? 'exec ' : ''}${config.baseCommand} ${args.join(' ')}`.trim()]; +} + /** * Build an oh-my-pi (omp) command line (PAN-1989). * diff --git a/src/lib/overdeck/conversation-delivery.ts b/src/lib/overdeck/conversation-delivery.ts index 2710099f62d..51958a60a32 100644 --- a/src/lib/overdeck/conversation-delivery.ts +++ b/src/lib/overdeck/conversation-delivery.ts @@ -29,6 +29,7 @@ import { tmuxSessionExists } from './conversation-runtime.js'; import type { PendingAskUserQuestionSnapshot, PendingInputKind } from '../agent-enrichment.js'; import { getOverdeckHome } from '../paths.js'; import { BRIDGE_TOKEN_HEADER } from '../bridge-token.js'; +import { deliverPrimeAgentMessage } from '../prime-agent/session-controller.js'; export const CONTROL_ACK_TIMEOUT_MS = 10_000; @@ -264,7 +265,6 @@ type ConversationControlCommandInput = ControlCommand extends infer C const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const satisfies readonly ThinkingLevel[]; const PI_CONVERSATION_ABORT_KEY = 'Escape'; - export function isPiControlChannelHarness(harness: RuntimeName | 'pi'): boolean { return harness === 'ohmypi' || harness === 'pi'; } @@ -279,7 +279,6 @@ export function pickDeliverAs(bodyDeliverAs: unknown): ConversationControlDelive if (bodyDeliverAs === 'follow_up') return 'follow_up'; return 'steer'; } - export function resolveConversationDeliveryMethod(conv: Pick<Conversation, 'harness' | 'deliveryMethod'>): 'auto' | 'channels' | 'tmux' { const harness = conv.harness ?? 'claude-code'; if (harness === 'acp') return 'auto'; @@ -309,13 +308,14 @@ export async function sendConversationControlCommand( } export async function deliverConversationViaControlChannel( - conv: Pick<Conversation, 'tmuxSession'>, + conv: Pick<Conversation, 'tmuxSession' | 'harness'>, message: string, options: { source: 'operator' | 'orchestrator' deliverAs: ConversationControlDeliverAs }, ): Promise<void> { + if (conv.harness === 'prime-agent') return void await deliverPrimeAgentMessage(conv.tmuxSession, message, options.deliverAs); await sendConversationControlCommand(conv, { type: options.deliverAs, message, diff --git a/src/lib/overdeck/conversation-harness.ts b/src/lib/overdeck/conversation-harness.ts new file mode 100644 index 00000000000..46f9b6a8164 --- /dev/null +++ b/src/lib/overdeck/conversation-harness.ts @@ -0,0 +1,39 @@ +import { KNOWN_HARNESSES, type Harness } from '@overdeck/contracts'; +import { getPrimeAgentBaseCommand } from '../agents/runtime-command.js'; +import { getAgentDir } from '../agents/agent-state.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { resolveHarness } from '../harness-resolve.js'; +import type { RuntimeName } from '../runtimes/types.js'; + +function isHarness(value: unknown): value is Harness { + return typeof value === 'string' && KNOWN_HARNESSES.has(value); +} + +export async function resolveAllowedHarness(requested: unknown, model?: string | null): Promise<RuntimeName> { + if (!model) return 'claude-code'; + return resolveHarness({ model, explicit: isHarness(requested) ? requested : undefined }); +} + +export async function preparePrimeAgentConversationLaunch(tmuxSession: string, cwd: string, model: string, resume = false): Promise<{ + runtimeCommand: string; + fields: { harness: 'prime-agent'; resumeSessionId?: string }; +}> { + let resumeSessionId: string | undefined; + if (resume) { + const sessionPath = join(getAgentDir(tmuxSession), 'prime-agent-session-path'); + try { + resumeSessionId = (await readFile(sessionPath, 'utf8')).trim() || undefined; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new Error(`Prime Agent conversation ${tmuxSession} cannot resume because ${sessionPath} is unreadable: ${message}`); + } + if (!resumeSessionId) { + throw new Error(`Prime Agent conversation ${tmuxSession} cannot resume because ${sessionPath} is empty`); + } + } + return { + runtimeCommand: await getPrimeAgentBaseCommand(tmuxSession, model, cwd), + fields: { harness: 'prime-agent', ...(resumeSessionId ? { resumeSessionId } : {}) }, + }; +} diff --git a/src/lib/overdeck/conversation-message.ts b/src/lib/overdeck/conversation-message.ts index c4dba01602e..f4533d10224 100644 --- a/src/lib/overdeck/conversation-message.ts +++ b/src/lib/overdeck/conversation-message.ts @@ -518,7 +518,7 @@ export async function handleConversationMessage( ); } - if (isPiControlChannelHarness(harness)) { + if (isPiControlChannelHarness(harness) || harness === 'prime-agent') { await deliverConversationViaControlChannel(conv, deliveredMessage, { source: 'operator', deliverAs: pickDeliverAs(body['deliverAs']), diff --git a/src/lib/overdeck/conversation-reads.ts b/src/lib/overdeck/conversation-reads.ts index 1236a4e55b7..9a780b34768 100644 --- a/src/lib/overdeck/conversation-reads.ts +++ b/src/lib/overdeck/conversation-reads.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { access, readdir, stat } from 'node:fs/promises'; +import { access, readFile, readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -12,7 +12,7 @@ import { loadConfigSync } from '../config-yaml.js'; import { isBackgroundFeatureEnabled } from '../background-ai/features.js'; import { capturePane, listSessionNames } from '../tmux.js'; import { paneShowsClaudeBootBlockingScreen } from '../cloister/modal-detector.js'; -import { sessionFilePath } from '../paths.js'; +import { getOverdeckHome, sessionFilePath } from '../paths.js'; import { resolveDiscoveredSessionFile } from '../conversations/discovered-session-file.js'; import { CONVERSATION_TITLE_MODEL, @@ -116,6 +116,12 @@ async function resolveUnregisteredClaudeSessionFile(name: string): Promise<strin } export async function resolveSessionFile(conv: Conversation): Promise<string | null> { + if (conv.harness === 'prime-agent') { + const sessionPath = await readFile(join(getOverdeckHome(), 'agents', conv.tmuxSession, 'prime-agent-session-path'), 'utf8') + .then(value => value.trim() || null) + .catch(() => null); + if (sessionPath) return sessionPath; + } // Pi work/review agents write per-run JSONL in the agent-dir root (PAN-1908); // conversations use sessions/. The shared resolver checks both and skips sidecars. if (getHarnessBehavior(conv.harness).transcriptKind === 'ohmypi-jsonl') { diff --git a/src/lib/overdeck/conversation-runtime.ts b/src/lib/overdeck/conversation-runtime.ts index 7dc82e89c15..aa66ee23bd3 100644 --- a/src/lib/overdeck/conversation-runtime.ts +++ b/src/lib/overdeck/conversation-runtime.ts @@ -51,7 +51,6 @@ import { writeBridgeTokenSync } from '../bridge-token.js'; import { isClaudeCodeChannelsEnabled, loadConfigSync } from '../config-yaml.js'; import { writePtyToken } from '../pty-token.js'; import { canUseHarnessSync } from '../harness-policy.js'; -import { resolveHarness } from '../harness-resolve.js'; import { prepareHarnessLaunch } from '../harness-binary.js'; import { getProviderForModelSync, piProviderForModel, UnknownModelError } from '../providers.js'; import { getOhmypiCodexAuthStatus } from '../ohmypi-codex-auth.js'; @@ -72,6 +71,8 @@ import { cleanupConversationAttachments, cleanupUnreferencedConversationAttachme import { resolveCodexRolloutPath } from '../../dashboard/server/routes/jsonl-resolver.js'; import { sendConversationControlCommand, isPiControlChannelHarness, resolveConversationDeliveryMethod } from './conversation-delivery.js'; import { deliverResumeContractUnlessGated } from './resume-contract-delivery.js'; +import { preparePrimeAgentConversationLaunch, resolveAllowedHarness } from './conversation-harness.js'; +export { preparePrimeAgentConversationLaunch, resolveAllowedHarness } from './conversation-harness.js'; const execAsync = promisify(exec); const execFileAsync = promisify(execFile); const PROCESS_CLEANUP_GRACE_MS = 750; @@ -193,14 +194,6 @@ const PI_CONVERSATION_SOURCE_CONTRACT = [ "A message marked source:'extension' was injected by the Overdeck orchestrator or another agent, not typed by the human operator.", 'Treat it as coordination guidance; do not attribute it to the human operator.', ].join(' '); -export async function resolveAllowedHarness(requested: unknown, model?: string | null): Promise<RuntimeName> { - if (!model) return 'claude-code'; - const explicit: RuntimeName | undefined = - requested === 'ohmypi' || requested === 'claude-code' || requested === 'codex' || requested === 'acp' || requested === 'kimi-code' - ? requested - : undefined; - return resolveHarness({ model, explicit }); -} export async function isInsideGitWorkTree(dir: string): Promise<boolean> { try { const { stdout } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: dir, encoding: 'utf-8' }); @@ -591,6 +584,7 @@ export async function spawnConversationSession( } | undefined; let acpFields: (ReturnType<typeof getAcpLauncherFields> & { resumeSessionId?: string }) | undefined; let kimiCodeFields: { harness: 'kimi-code'; kimiCodeModel: string; kimiCodeYolo: true; resumeSessionId?: string } | undefined; + let primeAgentFields: { harness: 'prime-agent'; resumeSessionId?: string } | undefined; let codexTransport: 'app-server' | 'tui' | undefined; if (behavior.launchCommandKind === 'acp-host') { if (!model) throw new Error('ACP conversation requires a model'); @@ -609,11 +603,16 @@ export async function spawnConversationSession( if (!SAFE_MODEL_PATTERN.test(model)) { throw new Error('Invalid model name'); } - runtimeCommand = await getAgentRuntimeBaseCommand(model, undefined, undefined, harness); - // The mode→flag mapping lives in claude-permissions.ts. The inline ternary - // that used to live here emitted the literal value `auto` under - // claude.permissionMode=auto, which Claude Code strict-validates and rejects. - runtimeCommand = ensureClaudePermissionFlagSync(runtimeCommand); + if (behavior.launchCommandKind === 'prime-agent-rpc') { + const primeLaunch = await preparePrimeAgentConversationLaunch(tmuxSession, cwd, model, resume); + runtimeCommand = primeLaunch.runtimeCommand; + primeAgentFields = primeLaunch.fields; + } else { + runtimeCommand = await getAgentRuntimeBaseCommand(model, undefined, undefined, harness); + // The mode→flag mapping lives in claude-permissions.ts. In particular, + // Claude Code rejects the literal permission-mode value `auto`. + runtimeCommand = ensureClaudePermissionFlagSync(runtimeCommand); + } providerExportsStr = (await getProviderExportsForModel(model, harness)).trim(); if (behavior.transcriptKind === 'ohmypi-jsonl') { if (getProviderForModelSync(model).name === 'openai') { @@ -763,7 +762,7 @@ export async function spawnConversationSession( workingDir: cwd, setTerminalEnv: true, unsetProviderEnv: true, - overdeckEnv: { ...(issueId ? { issueId } : {}), ...((piFields || codexFields || acpFields || useSupervisor) ? { agentId: tmuxSession } : {}) }, + overdeckEnv: { ...(issueId ? { issueId } : {}), ...((piFields || codexFields || acpFields || primeAgentFields || useSupervisor) ? { agentId: tmuxSession } : {}) }, extraEnvExports: [ harnessLaunch.pathExport, `export OVERDECK_DASHBOARD_URL="http://127.0.0.1:${process.env['API_PORT'] ?? process.env['PORT'] ?? '3011'}"`, @@ -773,15 +772,15 @@ export async function spawnConversationSession( baseCommand: runtimeCommand, appendSystemPromptFiles: piFields ? await piConversationSystemPromptFiles(cwd) - : codexFields || acpFields || kimiCodeFields + : codexFields || acpFields || kimiCodeFields || primeAgentFields ? [] : await claudeConversationSystemPromptFiles(cwd), model: launcherModel, - ...(piFields ?? codexFields ?? acpFields ?? kimiCodeFields ?? { + ...(piFields ?? codexFields ?? acpFields ?? kimiCodeFields ?? primeAgentFields ?? { resumeSessionId: resume ? claudeSessionId : undefined, sessionId: resume ? undefined : claudeSessionId, }), - extraArgs: !piFields && !acpFields && !kimiCodeFields && effort ? `--effort "${effort}"` : undefined, + extraArgs: !piFields && !acpFields && !kimiCodeFields && !primeAgentFields && effort ? `--effort "${effort}"` : undefined, keepAlive: true, fileMode: 0o700, channelsBridgeMcpConfig, @@ -1038,6 +1037,7 @@ export async function handleConversationResume( if (oldSessionId) { const resumeFile = await deps.resolveSessionFile(conv); if (!resumeFile || !existsSync(resumeFile)) { + if (harness === 'prime-agent') throw new Error(`Prime Agent conversation ${name} cannot resume because its durable session is missing or unreadable: ${resumeFile ?? '<missing sidecar>'}`); canResume = false; console.error(`[conversations] SESSION-LOST ${name} harness=${harness} claudeSessionId=${oldSessionId} resolved=${resumeFile ?? 'null'} — resuming with a fresh session`); } @@ -1047,8 +1047,7 @@ export async function handleConversationResume( await spawnConversationSession(conv.tmuxSession, conv.cwd, oldSessionId ?? randomUUID(), model, effort, conv.issueId ?? undefined, canResume, harness); await waitForTmuxSession(conv.tmuxSession); await waitForConversationRuntimeReady(conv.tmuxSession, harness, 'respawn'); - // The harness may open its own blocking gate on resume. The operator owns - // that answer, so the contract delivery seam skips rather than races it. + // The operator owns any harness gate on resume, so contract delivery skips it rather than racing it. await deliverResumeContractUnlessGated( conv.tmuxSession, `CONVERSATION RESUME: ${buildResumeContract(resumeCause)}`, @@ -1059,9 +1058,7 @@ export async function handleConversationResume( markConversationActive(name); return jsonResponse({ ...conv, status: 'active', model: model ?? conv.model, harness, reattached: false, sessionAlive: true }); } catch (error) { - // PAN-1837 review fix: kimi-code needs the same teardown-on-failure as - // acp — a failed capture must not leave a running tmux session with no - // owned native identity presented as a healthy conversation. + // PAN-1837: a failed ACP/Kimi capture must not leave a runtime without an owned native identity. if (harness === 'acp' || harness === 'kimi-code') await stopConversationRuntime(conv, name); throw error; } finally { @@ -1111,6 +1108,7 @@ export async function handleConversationRestartAll( const canResume = !!oldSessionId && !!sessionFileForResume && existsSync(sessionFileForResume); const harness = await resolveAllowedHarness(conv.harness, conv.model); attemptedHarness = harness; + if (harness === 'prime-agent' && !canResume) throw new Error(`Prime Agent conversation ${conv.name} cannot restart because its durable session is missing or unreadable: ${sessionFileForResume ?? '<missing sidecar>'}`); await spawnConversationSession(conv.tmuxSession, conv.cwd, oldSessionId ?? randomUUID(), conv.model ?? undefined, conv.effort ?? undefined, conv.issueId ?? undefined, canResume, harness); if (harness === 'acp') { await waitForConversationRuntimeReady(conv.tmuxSession, harness, 'respawn'); diff --git a/src/lib/overdeck/conversations.ts b/src/lib/overdeck/conversations.ts index a716cb86d1e..6ff6563627f 100644 --- a/src/lib/overdeck/conversations.ts +++ b/src/lib/overdeck/conversations.ts @@ -884,7 +884,7 @@ function toMillis(value: Date | string | number = new Date()): number { /** Map a raw DB harness string to a canonical RuntimeName, normalizing legacy 'pi' to 'ohmypi' on read. */ export function normalizeHarness(harness: string | null): RuntimeName | null { if (harness === 'pi' || harness === 'ohmypi') return 'ohmypi'; - if (harness === 'claude-code' || harness === 'codex' || harness === 'acp' || harness === 'kimi-code') return harness; + if (harness === 'claude-code' || harness === 'codex' || harness === 'acp' || harness === 'kimi-code' || harness === 'prime-agent') return harness; return null; } diff --git a/src/lib/overdeck/cost.ts b/src/lib/overdeck/cost.ts index 02747ec4e02..4d0f9171230 100644 --- a/src/lib/overdeck/cost.ts +++ b/src/lib/overdeck/cost.ts @@ -511,7 +511,7 @@ export class CostWriter extends Context.Service< readonly record: (event: CostEvent, opts?: { dryRun?: boolean }) => Effect.Effect<boolean, CostIngestError>; // Catch-up sweep (PAN-1935: pi/codex sweep lands here) readonly reconcile: (opts?: { - source?: 'claude' | 'ohmypi' | 'codex' | 'acp' | 'wal'; + source?: 'claude' | 'ohmypi' | 'codex' | 'acp' | 'prime_agent' | 'wal'; dryRun?: boolean; extraRoots?: string[]; extraRootSpecs?: CostReconcileExtraRoot[]; @@ -606,7 +606,7 @@ export const CostWriterLive = Layer.effect( // OVERDECK_HOME/agents/<id>/codex-home/sessions/**/*.jsonl (codex), // parses each with the existing parsers, and feeds into record() (which deduplicates). const reconcile = (opts?: { - source?: 'claude' | 'ohmypi' | 'codex' | 'acp' | 'wal'; + source?: 'claude' | 'ohmypi' | 'codex' | 'acp' | 'prime_agent' | 'wal'; dryRun?: boolean; extraRoots?: string[]; extraRootSpecs?: CostReconcileExtraRoot[]; @@ -908,7 +908,7 @@ export const CostApi = HttpApiGroup.make('costs') .add( HttpApiEndpoint.post('reconcile', '/costs/reconcile', { payload: Schema.Struct({ - source: Schema.optional(Schema.Literals(['claude', 'ohmypi', 'codex', 'acp', 'wal'])), + source: Schema.optional(Schema.Literals(['claude', 'ohmypi', 'codex', 'acp', 'prime_agent', 'wal'])), dryRun: Schema.optional(Schema.Boolean), extraRoots: Schema.optional(Schema.Array(Schema.String)), }), diff --git a/src/lib/planning/spawn-planning-session.ts b/src/lib/planning/spawn-planning-session.ts index 8170b6f6719..dfe08bf57da 100644 --- a/src/lib/planning/spawn-planning-session.ts +++ b/src/lib/planning/spawn-planning-session.ts @@ -401,7 +401,7 @@ If the probe pass changes nothing at all, record one decision: "PROBE: no findin * Write workspace `.pan/context.md` for Rally Features so story work agents can * reference feature-level context (child stories, description, URL). */ -async function claudePlanningSystemPromptFiles(workspacePath: string, harness: 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'): Promise<string[]> { +async function claudePlanningSystemPromptFiles(workspacePath: string, harness: RuntimeName): Promise<string[]> { const files: string[] = []; const contextFile = workspaceContextFile(workspacePath); try { diff --git a/src/lib/prime-agent/__tests__/host-http.test.ts b/src/lib/prime-agent/__tests__/host-http.test.ts new file mode 100644 index 00000000000..fe8cacd2cf6 --- /dev/null +++ b/src/lib/prime-agent/__tests__/host-http.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { + PRIME_AGENT_HOST_MAX_REQUEST_BYTES, + PrimeAgentHostRequestTooLarge, + readPrimeAgentHostRequest, +} from '../host-http.js'; + +async function* chunks(...values: Uint8Array[]) { yield* values; } + +describe('Prime Agent host request reader', () => { + it('parses a bounded chunked JSON request', async () => { + await expect(readPrimeAgentHostRequest(chunks(Buffer.from('{"op":'), Buffer.from('"stats"}')))) + .resolves.toEqual({ op: 'stats' }); + }); + + it('rejects oversized content-length before consuming the body', async () => { + await expect(readPrimeAgentHostRequest(chunks(), String(PRIME_AGENT_HOST_MAX_REQUEST_BYTES + 1))) + .rejects.toBeInstanceOf(PrimeAgentHostRequestTooLarge); + }); + + it('rejects a chunked body that crosses the byte cap', async () => { + await expect(readPrimeAgentHostRequest(chunks( + Buffer.alloc(PRIME_AGENT_HOST_MAX_REQUEST_BYTES), + Buffer.from('x'), + ))).rejects.toBeInstanceOf(PrimeAgentHostRequestTooLarge); + }); +}); diff --git a/src/lib/prime-agent/__tests__/jsonl-framing.test.ts b/src/lib/prime-agent/__tests__/jsonl-framing.test.ts new file mode 100644 index 00000000000..1d0b2df9bde --- /dev/null +++ b/src/lib/prime-agent/__tests__/jsonl-framing.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { encodePrimeAgentJsonl, PrimeAgentJsonlError, PrimeAgentJsonlFramer } from '../jsonl-framing.js'; + +describe('PrimeAgentJsonlFramer', () => { + it('preserves U+2028 and U+2029 inside a JSON string', () => { + const framer = new PrimeAgentJsonlFramer(); + expect(framer.push(Buffer.from('{"text":"left
middle
right"}\n'))).toEqual([ + { text: 'left
middle
right' }, + ]); + }); + + it('parses split and combined LF-delimited records', () => { + const framer = new PrimeAgentJsonlFramer(); + expect(framer.push(Buffer.from('{"id":1'))).toEqual([]); + expect(framer.push(Buffer.from('}\n{"id":2}\n'))).toEqual([{ id: 1 }, { id: 2 }]); + framer.finish(); + }); + + it('handles a large record delivered one byte at a time', () => { + const framer = new PrimeAgentJsonlFramer(); + const input = Buffer.from(`${JSON.stringify({ text: 'x'.repeat(64 * 1024) })}\n`); + const records: unknown[] = []; + for (const byte of input) records.push(...framer.push(Uint8Array.of(byte))); + expect(records).toEqual([{ text: 'x'.repeat(64 * 1024) }]); + }); + + it('strips one CR from CRLF records', () => { + const framer = new PrimeAgentJsonlFramer(); + expect(framer.push(Buffer.from('{"ok":true}\r\n'))).toEqual([{ ok: true }]); + }); + + it('rejects malformed JSON without logging record contents', () => { + const framer = new PrimeAgentJsonlFramer(); + expect(() => framer.push(Buffer.from('{"secret":"redacted",}\n'))).toThrow(PrimeAgentJsonlError); + expect(() => framer.push(Buffer.from('{"secret":"redacted",}\n'))).toThrow(/Malformed Prime Agent RPC JSON record \(22 bytes\)/); + }); + + it('rejects completed and buffered records over the byte limit', () => { + expect(() => new PrimeAgentJsonlFramer({ maxRecordBytes: 4 }).push(Buffer.from('12345\n'))).toThrow('4-byte limit'); + expect(() => new PrimeAgentJsonlFramer({ maxRecordBytes: 4 }).push(Buffer.from('12345'))).toThrow('4-byte limit'); + }); + + it('rejects an unterminated final record', () => { + const framer = new PrimeAgentJsonlFramer(); + framer.push(Buffer.from('{"id":1}')); + expect(() => framer.finish()).toThrow('unterminated'); + }); +}); + +describe('encodePrimeAgentJsonl', () => { + it('emits exactly one trailing LF', () => { + expect(encodePrimeAgentJsonl({ command: 'get_state' }).toString('utf8')).toBe('{"command":"get_state"}\n'); + }); +}); diff --git a/src/lib/prime-agent/__tests__/rpc-client.test.ts b/src/lib/prime-agent/__tests__/rpc-client.test.ts new file mode 100644 index 00000000000..371f30f5a31 --- /dev/null +++ b/src/lib/prime-agent/__tests__/rpc-client.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PrimeAgentRpcClient } from '../rpc-client.js'; + +describe('PrimeAgentRpcClient', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('correlates responses and emits asynchronous events separately', async () => { + const writes: string[] = []; + const events: Record<string, unknown>[] = []; + const client = new PrimeAgentRpcClient({ stdin: { write: (chunk: unknown) => { writes.push(String(chunk)); return true; } }, onEvent: (event) => events.push(event) }); + const result = client.request({ type: 'get_state' }); + expect(writes).toEqual(['{"type":"get_state","id":"overdeck-1"}\n']); + client.acceptStdout(Buffer.from('{"type":"agent_start"}\n{"type":"response","id":"overdeck-1","command":"get_state","success":true,"data":{"isStreaming":false}}\n')); + await expect(result).resolves.toMatchObject({ data: { isStreaming: false } }); + expect(events).toEqual([{ type: 'agent_start' }]); + }); + + it('uses fake timers for bounded request timeout', async () => { + const client = new PrimeAgentRpcClient({ stdin: { write: () => true }, requestTimeoutMs: 2_000 }); + const result = client.request({ type: 'get_state' }); + const assertion = expect(result).rejects.toThrow('timed out after 2000ms'); + await vi.advanceTimersByTimeAsync(2_000); + await assertion; + }); + + it('rejects all pending requests when the child exits', async () => { + const client = new PrimeAgentRpcClient({ stdin: { write: () => true } }); + const first = client.request({ type: 'get_state' }); + const second = client.request({ type: 'get_messages' }); + client.close(new Error('Prime Agent exited with code 7')); + await expect(first).rejects.toThrow('code 7'); + await expect(second).rejects.toThrow('code 7'); + }); + + it('bounds the pending request map', async () => { + const client = new PrimeAgentRpcClient({ stdin: { write: () => true }, maxPendingRequests: 1 }); + const first = client.request({ type: 'get_state' }); + const firstAssertion = expect(first).rejects.toThrow('process exited'); + await expect(client.request({ type: 'get_messages' })).rejects.toThrow('refusing unbounded growth'); + client.close(); + await firstAssertion; + }); +}); diff --git a/src/lib/prime-agent/host-http.ts b/src/lib/prime-agent/host-http.ts new file mode 100644 index 00000000000..a1e027c8c04 --- /dev/null +++ b/src/lib/prime-agent/host-http.ts @@ -0,0 +1,26 @@ +export const PRIME_AGENT_HOST_MAX_REQUEST_BYTES = 1024 * 1024; +export const PRIME_AGENT_HOST_MAX_CONCURRENT_REQUESTS = 8; + +export class PrimeAgentHostRequestTooLarge extends Error { + constructor() { + super(`Prime Agent host request exceeds ${PRIME_AGENT_HOST_MAX_REQUEST_BYTES} bytes`); + this.name = 'PrimeAgentHostRequestTooLarge'; + } +} + +export async function readPrimeAgentHostRequest( + input: AsyncIterable<Uint8Array>, + contentLength?: string, +): Promise<Record<string, unknown>> { + if (contentLength && Number(contentLength) > PRIME_AGENT_HOST_MAX_REQUEST_BYTES) { + throw new PrimeAgentHostRequestTooLarge(); + } + const chunks: Buffer[] = []; + let received = 0; + for await (const chunk of input) { + received += chunk.byteLength; + if (received > PRIME_AGENT_HOST_MAX_REQUEST_BYTES) throw new PrimeAgentHostRequestTooLarge(); + chunks.push(Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks, received).toString('utf8')) as Record<string, unknown>; +} diff --git a/src/lib/prime-agent/host.ts b/src/lib/prime-agent/host.ts new file mode 100644 index 00000000000..d9da9158982 --- /dev/null +++ b/src/lib/prime-agent/host.ts @@ -0,0 +1,173 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { getAgentDir } from '../agents/agent-state.js'; +import { getOverdeckHome } from '../paths.js'; +import { PrimeAgentRpcClient } from './rpc-client.js'; +import { resumePrimeAgentSession } from './session-resume.js'; +import { + PRIME_AGENT_HOST_MAX_CONCURRENT_REQUESTS, + PrimeAgentHostRequestTooLarge, + readPrimeAgentHostRequest, +} from './host-http.js'; + +const args = new Map<string, string>(); +for (let i = 2; i < process.argv.length; i += 2) args.set(process.argv[i]!, process.argv[i + 1]!); +const required = (name: string): string => { + const value = args.get(name); + if (!value) throw new Error(`Missing ${name}`); + return value; +}; + +interface RunningPrime { + child: ChildProcessWithoutNullStreams; + client: PrimeAgentRpcClient; +} + +let cleanupFailedHost: (() => Promise<void>) | undefined; + +async function main(): Promise<void> { + const agentId = required('--agent'); + const agentDir = getAgentDir(agentId); + const socketDir = join(getOverdeckHome(), 'sockets'); + const socketPath = join(socketDir, `prime-agent-${agentId}.sock`); + const token = randomBytes(32).toString('hex'); + const startupTimeoutMs = Number(required('--startup-timeout-ms')); + if (!Number.isInteger(startupTimeoutMs) || startupTimeoutMs <= 0) throw new Error('Invalid --startup-timeout-ms'); + await mkdir(agentDir, { recursive: true, mode: 0o700 }); + await mkdir(socketDir, { recursive: true, mode: 0o700 }); + await rm(socketPath, { force: true }); + await writeFile(join(agentDir, 'prime-agent-token'), token, { mode: 0o600 }); + + let lastEventAt = new Date().toISOString(); + let stats: unknown = null; + let statsWrite: Promise<void> = Promise.resolve(); + let statsTimer: NodeJS.Timeout | undefined; + let running: RunningPrime | undefined; + let server: ReturnType<typeof createServer> | undefined; + let stopped = false; + const cleanup = async (): Promise<void> => { + if (stopped) return; + stopped = true; + if (statsTimer) clearTimeout(statsTimer); + running?.client.close(new Error('Prime Agent host stopped')); + running?.child.kill('SIGTERM'); + if (server) await new Promise<void>(resolve => server!.close(() => resolve())); + await rm(socketPath, { force: true }); + }; + cleanupFailedHost = cleanup; + const stop = (): void => { void cleanup(); }; + process.once('SIGTERM', stop); + process.once('SIGINT', stop); + + const persistStats = (): Promise<void> => { + statsWrite = statsWrite.then(() => writeFile( + join(agentDir, 'prime-agent-stats.json'), + JSON.stringify({ stats, lastEventAt }), + { mode: 0o600 }, + )); + return statsWrite; + }; + const scheduleStatsWrite = (): void => { + if (statsTimer) return; + statsTimer = setTimeout(() => { statsTimer = undefined; void persistStats(); }, 250); + }; + const refreshStats = async (): Promise<void> => { + if (!running) return; + const response = await running.client.request({ type: 'get_session_stats' }); + stats = response.data ?? null; + await persistStats(); + }; + const launch = (resumePath?: string): RunningPrime => { + const childArgs = ['--mode', 'rpc', '--provider', required('--provider'), '--model', required('--model'), '--session-dir', required('--session-dir'), '--append-system-prompt', required('--append-system-prompt')]; + if (resumePath) childArgs.push('--resume', resumePath); + const child = spawn(required('--binary'), childArgs, { cwd: required('--workspace'), stdio: ['pipe', 'pipe', 'pipe'] }); + child.stderr.pipe(process.stderr); + const client = new PrimeAgentRpcClient({ stdin: child.stdin, requestTimeoutMs: startupTimeoutMs, onEvent: event => { + lastEventAt = new Date().toISOString(); + scheduleStatsWrite(); + if (event.type === 'agent_end') void refreshStats().catch(() => undefined); + } }); + child.once('error', error => client.close(new Error(`Prime Agent process failed to start: ${error.message}`))); + child.stdout.on('data', chunk => client.acceptStdout(chunk)); + child.on('exit', code => client.close(new Error(`Prime Agent exited with code ${code ?? 'unknown'}`))); + return { child, client }; + }; + + const resume = args.get('--resume'); + if (resume) { + await resumePrimeAgentSession({ + sessionId: resume, + sessionPath: resume, + start: async sessionPath => { + running = launch(sessionPath); + return { + getState: async () => { + const state = await running!.client.request<Record<string, unknown>>({ type: 'get_state' }); + return { sessionId: String(state.data?.sessionFile ?? state.data?.sessionId ?? '') }; + }, + stop: async () => { running!.child.kill('SIGTERM'); }, + }; + }, + }); + } else { + running = launch(); + } + const active = running; + if (!active) throw new Error('Prime Agent process did not start'); + + let activeRequests = 0; + server = createServer((request, response) => { + if (activeRequests >= PRIME_AGENT_HOST_MAX_CONCURRENT_REQUESTS) { + response.writeHead(503, { connection: 'close' }).end('Prime Agent host is busy'); + return; + } + activeRequests += 1; + void (async () => { + if (request.headers['x-overdeck-bridge-token'] !== token) { response.writeHead(401).end(); return; } + const body = await readPrimeAgentHostRequest(request, request.headers['content-length']) as { op: string; message?: string; preferred?: 'steer' | 'follow_up' }; + if (body.op === 'message') { + const state = await active.client.request<{ isStreaming?: boolean }>({ type: 'get_state' }); + const type = state.data?.isStreaming ? (body.preferred ?? 'steer') : 'prompt'; + await active.client.request({ type, message: body.message ?? '' }); + response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ command: type })); + } else if (body.op === 'abort') { + await active.client.request({ type: 'abort' }); response.writeHead(204).end(); + } else if (body.op === 'stats') { + await refreshStats(); response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ stats, lastEventAt })); + } else { response.writeHead(400).end('unknown operation'); } + })().catch(error => { + const status = error instanceof PrimeAgentHostRequestTooLarge ? 413 : 500; + response.writeHead(status, { connection: 'close' }).end(error instanceof Error ? error.message : String(error)); + }).finally(() => { activeRequests -= 1; }); + }); + + try { + await new Promise<void>((resolve, reject) => server!.listen(socketPath, resolve).once('error', reject)); + const state = await active.client.request<Record<string, unknown>>({ type: 'get_state' }); + const sessionId = String(state.data?.sessionFile ?? state.data?.sessionId ?? agentId); + const promptFile = args.get('--prompt-file'); + const prompt = promptFile ? await readFile(promptFile, 'utf8') : args.get('--prompt'); + if (prompt) await active.client.request({ type: 'prompt', message: prompt }); + await refreshStats(); + const sessionPath = String(state.data?.sessionFile ?? state.data?.sessionPath ?? ''); + if (!sessionPath) throw new Error('Prime Agent did not report a durable session path'); + await writeFile(join(agentDir, 'prime-agent-session-path'), sessionPath, { mode: 0o600 }); + await writeFile(join(agentDir, 'prime-agent-session-id'), sessionId, { mode: 0o600 }); + } catch (error) { + await cleanup(); + throw error; + } +} + +void main().catch(async error => { + await cleanupFailedHost?.().catch(() => undefined); + const agentId = args.get('--agent'); + if (agentId) { + await mkdir(getAgentDir(agentId), { recursive: true, mode: 0o700 }); + await writeFile(join(getAgentDir(agentId), 'prime-agent-launch-error'), error instanceof Error ? error.message : String(error)); + } + process.exitCode = 1; +}); diff --git a/src/lib/prime-agent/jsonl-framing.ts b/src/lib/prime-agent/jsonl-framing.ts new file mode 100644 index 00000000000..1e01dcd7523 --- /dev/null +++ b/src/lib/prime-agent/jsonl-framing.ts @@ -0,0 +1,82 @@ +const LF = 0x0a; +const CR = 0x0d; + +export class PrimeAgentJsonlError extends Error { + constructor(message: string) { + super(message); + this.name = 'PrimeAgentJsonlError'; + } +} + +export interface PrimeAgentJsonlFramerOptions { + maxRecordBytes?: number; +} + +/** Incremental strict-LF JSONL decoder. It intentionally does not use readline. */ +export class PrimeAgentJsonlFramer { + private chunks: Buffer[] = []; + private bufferedBytes = 0; + private readonly maxRecordBytes: number; + + constructor(options: PrimeAgentJsonlFramerOptions = {}) { + this.maxRecordBytes = options.maxRecordBytes ?? 8 * 1024 * 1024; + if (!Number.isInteger(this.maxRecordBytes) || this.maxRecordBytes < 1) { + throw new PrimeAgentJsonlError('maxRecordBytes must be a positive integer'); + } + } + + push(chunk: Uint8Array): unknown[] { + if (chunk.byteLength === 0) return []; + const input = Buffer.from(chunk); + const records: unknown[] = []; + let start = 0; + + for (let index = 0; index < input.byteLength; index += 1) { + if (input[index] !== LF) continue; + const piece = input.subarray(start, index); + const byteLength = this.bufferedBytes + piece.byteLength; + if (byteLength > this.maxRecordBytes) this.throwOversized(byteLength); + const record = this.chunks.length === 0 + ? piece + : Buffer.concat([...this.chunks, piece], byteLength); + const rawEnd = record.byteLength > 0 && record[record.byteLength - 1] === CR ? record.byteLength - 1 : record.byteLength; + if (rawEnd > 0) records.push(this.parse(record.subarray(0, rawEnd))); + this.chunks = []; + this.bufferedBytes = 0; + start = index + 1; + } + + if (start < input.byteLength) { + const tail = input.subarray(start); + this.chunks.push(tail); + this.bufferedBytes += tail.byteLength; + if (this.bufferedBytes > this.maxRecordBytes) this.throwOversized(this.bufferedBytes); + } + return records; + } + + finish(): void { + if (this.bufferedBytes > 0) { + throw new PrimeAgentJsonlError(`Prime Agent RPC stdout ended with ${this.bufferedBytes} unterminated byte(s)`); + } + } + + private parse(record: Uint8Array): unknown { + try { + return JSON.parse(Buffer.from(record).toString('utf8')) as unknown; + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new PrimeAgentJsonlError(`Malformed Prime Agent RPC JSON record (${record.byteLength} bytes): ${message}`); + } + } + + private throwOversized(byteLength: number): never { + throw new PrimeAgentJsonlError( + `Prime Agent RPC JSON record exceeded the ${this.maxRecordBytes}-byte limit (${byteLength} bytes received)`, + ); + } +} + +export function encodePrimeAgentJsonl(value: unknown): Buffer { + return Buffer.from(`${JSON.stringify(value)}\n`, 'utf8'); +} diff --git a/src/lib/prime-agent/launch-command.ts b/src/lib/prime-agent/launch-command.ts new file mode 100644 index 00000000000..53410cea8d8 --- /dev/null +++ b/src/lib/prime-agent/launch-command.ts @@ -0,0 +1,47 @@ +import { mkdir } from 'fs/promises'; +import { join } from 'path'; +import type { AuthMode } from '../subscription-types.js'; +import { getAgentDir } from '../agents/agent-state.js'; +import { PRIME_AGENT_MANAGED_POLICY } from './policy.js'; +import { resolvePrimeAgentModelRoute } from './provider-map.js'; +import { primeAgentGlobalContextFile, resolveWorkspaceContextFile } from '../context-layers/layers.js'; +import { existsSync, readFileSync } from 'fs'; +import { packageRoot } from '../paths.js'; +import { resolveHarnessBinary } from '../harness-binary.js'; +import { loadConfigSync } from '../config-yaml.js'; + +function quoteShell(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +export interface PrimeAgentLaunchCommandOptions { + agentId: string; + model: string; + workspace: string; + authMode: AuthMode; + rpcStartupTimeoutMs?: number; +} + +export async function buildPrimeAgentBaseCommand(options: PrimeAgentLaunchCommandOptions): Promise<string> { + const route = resolvePrimeAgentModelRoute(options.model, options.authMode); + const sessionDir = join(getAgentDir(options.agentId), 'prime-sessions'); + await mkdir(sessionDir, { recursive: true, mode: 0o700 }); + const context = [primeAgentGlobalContextFile(), resolveWorkspaceContextFile(options.workspace)] + .filter(existsSync) + .map(file => readFileSync(file, 'utf8')) + .join('\n\n'); + const binary = await resolveHarnessBinary('prime-agent'); + if (!binary) throw new Error('Prime Agent executable is unavailable'); + const startupTimeoutMs = options.rpcStartupTimeoutMs ?? loadConfigSync().config.primeAgent.rpcStartupTimeoutMs; + return [ + `node ${quoteShell(join(packageRoot, 'dist', 'prime-agent-host.js'))}`, + `--agent ${quoteShell(options.agentId)}`, + `--binary ${quoteShell(binary)}`, + `--workspace ${quoteShell(options.workspace)}`, + `--provider ${quoteShell(route.provider)}`, + `--model ${quoteShell(route.model)}`, + `--session-dir ${quoteShell(sessionDir)}`, + `--append-system-prompt ${quoteShell([PRIME_AGENT_MANAGED_POLICY, context].filter(Boolean).join('\n\n'))}`, + `--startup-timeout-ms ${startupTimeoutMs}`, + ].join(' '); +} diff --git a/src/lib/prime-agent/policy.ts b/src/lib/prime-agent/policy.ts new file mode 100644 index 00000000000..dcc6e561644 --- /dev/null +++ b/src/lib/prime-agent/policy.ts @@ -0,0 +1,50 @@ +export const PRIME_AGENT_MANAGED_POLICY = `You are a Prime Agent root session managed by Overdeck. + +- Treat prompts delivered by Overdeck as operator messages. Preserve their provenance in the session transcript. +- Work only inside the assigned Overdeck workspace and its explicitly mounted repositories. +- Overdeck Cloister is the only lifecycle authority. Do not start schedules, heartbeats, autonomous continuation, or direct cross-agent messages. +- Prime RLM children may run only as internal tool execution attributable to this root session. They are not Overdeck agents, must not persist after the root stops, and must not claim issue ownership. +- Only the root Overdeck agent may use pan lifecycle commands. Children must never run pan done, pan task, pan start, pan tell, or alter pipeline records. +- Canonical issue, agent, conversation, and pipeline state uses Overdeck's single state write door. Never write its SQLite cache, overdeck-state files, state.json, or tracker status directly. +- Continual refinement is session-local. Do not modify bundled, machine, project, workspace, or rendered Overdeck context artifacts, including prime-agent-global.md.`; + +const FORBIDDEN_MANAGED_COMMANDS = new Set([ + 'send_message', + 'agent_messages_status', + 'agent_messages_pause', + 'agent_messages_resume', + 'agent_messages_clear', + 'list_schedules', + 'add_schedule', + 'cancel_schedule', + 'list_heartbeats', + 'get_heartbeat', + 'set_heartbeat', + 'update_heartbeat', + 'manage_heartbeat', + 'observe', + 'unobserve', +]); + +export class PrimeAgentManagedPolicyError extends Error { + constructor(command: string) { + super(`Prime Agent RPC command "${command}" is unavailable for Overdeck-managed sessions. Cloister owns lifecycle and messaging; Prime schedules, heartbeats, autonomous continuation, observation, and cross-agent messaging are disabled.`); + this.name = 'PrimeAgentManagedPolicyError'; + } +} + +export function assertPrimeAgentManagedCommandAllowed(command: string): void { + if (FORBIDDEN_MANAGED_COMMANDS.has(command)) throw new PrimeAgentManagedPolicyError(command); +} + +/** + * Implementation checkpoint for the protocol range pinned by doctor-prime-agent.ts. + * The adapter denies every daemon coordination command documented by that range, + * and the injected policy denies tool-level lifecycle bypasses. Managed work stays + * enabled while both controls are present; protocol drift is rejected by doctor. + */ +export const PRIME_AGENT_MANAGED_POLICY_CHECKPOINT = { + outcome: 'policy-enforced', + verifiedOn: '2026-08-12', + fallback: 'If protocol compatibility fails, reject managed work and allow supervised conversations only.', +} as const; diff --git a/src/lib/prime-agent/provider-map.test.ts b/src/lib/prime-agent/provider-map.test.ts new file mode 100644 index 00000000000..fc790dd2232 --- /dev/null +++ b/src/lib/prime-agent/provider-map.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { PrimeAgentProviderMappingError, resolvePrimeAgentModelRoute } from './provider-map.js'; + +describe('resolvePrimeAgentModelRoute', () => { + it('maps verified API-key providers without changing the model', () => { + expect(resolvePrimeAgentModelRoute('gpt-5.4')).toEqual({ provider: 'openai', model: 'gpt-5.4' }); + expect(resolvePrimeAgentModelRoute('claude-sonnet-4-6')).toEqual({ provider: 'anthropic', model: 'claude-sonnet-4-6' }); + expect(resolvePrimeAgentModelRoute('gemini-3.1-pro-preview')).toEqual({ provider: 'google', model: 'gemini-3.1-pro-preview' }); + }); + + it('uses the Prime subscription provider ID for ChatGPT auth', () => { + expect(resolvePrimeAgentModelRoute('gpt-5.4', 'subscription')).toEqual({ provider: 'openai-codex', model: 'gpt-5.4' }); + }); + + it('rejects an unsupported provider instead of choosing a fallback', () => { + expect(() => resolvePrimeAgentModelRoute('ql-swift-8b')).toThrow(PrimeAgentProviderMappingError); + expect(() => resolvePrimeAgentModelRoute('ql-swift-8b')).toThrow('no fallback model was selected'); + }); + + it('rejects unsupported subscription routing', () => { + expect(() => resolvePrimeAgentModelRoute('gemini-3.1-pro-preview', 'subscription')).toThrow( + 'subscription authentication', + ); + }); +}); diff --git a/src/lib/prime-agent/provider-map.ts b/src/lib/prime-agent/provider-map.ts new file mode 100644 index 00000000000..6579c3c2570 --- /dev/null +++ b/src/lib/prime-agent/provider-map.ts @@ -0,0 +1,47 @@ +import type { ModelProvider } from '../model-fallback.js'; +import type { AuthMode } from '../subscription-types.js'; +import { getProviderForModelSync } from '../providers.js'; + +export class PrimeAgentProviderMappingError extends Error { + constructor(message: string) { + super(message); + this.name = 'PrimeAgentProviderMappingError'; + } +} + +const API_KEY_PROVIDER_IDS: Partial<Record<ModelProvider, string>> = { + anthropic: 'anthropic', + openai: 'openai', + google: 'google', + kimi: 'kimi-coding', + minimax: 'minimax', + openrouter: 'openrouter', + zai: 'zai', + mimo: 'xiaomi', + xai: 'xai', + groq: 'groq', + cerebras: 'cerebras', + mistral: 'mistral', +}; + +const SUBSCRIPTION_PROVIDER_IDS: Partial<Record<ModelProvider, string>> = { + anthropic: 'anthropic', + openai: 'openai-codex', +}; + +export interface PrimeAgentModelRoute { + provider: string; + model: string; +} + +/** Resolve an Overdeck model without choosing or substituting a fallback model. */ +export function resolvePrimeAgentModelRoute(model: string, authMode: AuthMode = 'api-key'): PrimeAgentModelRoute { + const overdeckProvider = getProviderForModelSync(model).name; + const provider = (authMode === 'subscription' ? SUBSCRIPTION_PROVIDER_IDS : API_KEY_PROVIDER_IDS)[overdeckProvider]; + if (!provider) { + throw new PrimeAgentProviderMappingError( + `Prime Agent does not support Overdeck provider "${overdeckProvider}" with ${authMode} authentication for model "${model}". Configure a verified provider/model mapping; no fallback model was selected.`, + ); + } + return { provider, model }; +} diff --git a/src/lib/prime-agent/rpc-client.ts b/src/lib/prime-agent/rpc-client.ts new file mode 100644 index 00000000000..be465e61880 --- /dev/null +++ b/src/lib/prime-agent/rpc-client.ts @@ -0,0 +1,106 @@ +import type { Writable } from 'node:stream'; +import { encodePrimeAgentJsonl, PrimeAgentJsonlFramer } from './jsonl-framing.js'; +import { assertPrimeAgentManagedCommandAllowed } from './policy.js'; + +export interface PrimeAgentRpcResponse<T = unknown> { + type: 'response'; + id: string; + command: string; + success: boolean; + data?: T; + error?: string; +} + +interface PendingRequest { + command: string; + resolve: (response: PrimeAgentRpcResponse) => void; + reject: (error: Error) => void; + timeout: ReturnType<typeof setTimeout>; +} + +export interface PrimeAgentRpcClientOptions { + stdin: Pick<Writable, 'write'>; + requestTimeoutMs?: number; + maxPendingRequests?: number; + maxRecordBytes?: number; + onEvent?: (event: Record<string, unknown>) => void; +} + +export class PrimeAgentRpcClient { + private readonly framer: PrimeAgentJsonlFramer; + private readonly pending = new Map<string, PendingRequest>(); + private readonly requestTimeoutMs: number; + private readonly maxPendingRequests: number; + private readonly stdin: Pick<Writable, 'write'>; + private readonly onEvent: (event: Record<string, unknown>) => void; + private nextId = 1; + private closedError: Error | null = null; + + constructor(options: PrimeAgentRpcClientOptions) { + this.stdin = options.stdin; + this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000; + this.maxPendingRequests = options.maxPendingRequests ?? 128; + this.onEvent = options.onEvent ?? (() => undefined); + this.framer = new PrimeAgentJsonlFramer({ maxRecordBytes: options.maxRecordBytes }); + } + + request<T = unknown>(command: Record<string, unknown> & { type: string }): Promise<PrimeAgentRpcResponse<T>> { + assertPrimeAgentManagedCommandAllowed(command.type); + if (this.closedError) return Promise.reject(this.closedError); + if (this.pending.size >= this.maxPendingRequests) { + return Promise.reject(new Error(`Prime Agent RPC has ${this.pending.size} pending requests; refusing unbounded growth`)); + } + const id = `overdeck-${this.nextId++}`; + return new Promise<PrimeAgentRpcResponse<T>>((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Prime Agent RPC ${command.type} request ${id} timed out after ${this.requestTimeoutMs}ms`)); + }, this.requestTimeoutMs); + this.pending.set(id, { command: command.type, resolve: resolve as (response: PrimeAgentRpcResponse) => void, reject, timeout }); + this.stdin.write(encodePrimeAgentJsonl({ ...command, id }), (error?: Error | null) => { + if (!error) return; + const pending = this.pending.get(id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(id); + reject(new Error(`Prime Agent RPC could not write ${command.type} request ${id}: ${error.message}`)); + }); + }); + } + + acceptStdout(chunk: Uint8Array): void { + for (const value of this.framer.push(chunk)) this.route(value); + } + + close(cause: Error = new Error('Prime Agent RPC process exited')): void { + if (this.closedError) return; + this.closedError = cause; + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(cause); + } + this.pending.clear(); + } + + private route(value: unknown): void { + if (!value || typeof value !== 'object') throw new Error('Prime Agent RPC emitted a non-object record'); + const record = value as Record<string, unknown>; + if (record.type !== 'response') { + this.onEvent(record); + return; + } + if (typeof record.id !== 'string') throw new Error('Prime Agent RPC response omitted its correlation id'); + const pending = this.pending.get(record.id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(record.id); + const response = record as unknown as PrimeAgentRpcResponse; + if (response.command !== pending.command) { + pending.reject(new Error(`Prime Agent RPC response ${record.id} named ${response.command}, expected ${pending.command}`)); + } else if (!response.success) { + pending.reject(new Error(`Prime Agent RPC ${pending.command} request failed: ${response.error ?? 'unknown error'}`)); + } else { + pending.resolve(response); + } + } +} diff --git a/src/lib/prime-agent/session-controller.ts b/src/lib/prime-agent/session-controller.ts new file mode 100644 index 00000000000..0885b5de98f --- /dev/null +++ b/src/lib/prime-agent/session-controller.ts @@ -0,0 +1,89 @@ +import type { PrimeAgentRpcClient, PrimeAgentRpcResponse } from './rpc-client.js'; +import { existsSync, readFileSync } from 'node:fs'; +import { request } from 'node:http'; +import { join } from 'node:path'; +import { AGENTS_DIR, getOverdeckHome } from '../paths.js'; + +export interface PrimeAgentManagedSession { + client: Pick<PrimeAgentRpcClient, 'request'>; + terminate: () => Promise<void>; +} + +export interface PrimeAgentDeliveryReceipt { + accepted: true; + command: 'prompt' | 'steer' | 'follow_up'; +} + +const sessions = new Map<string, PrimeAgentManagedSession>(); + +export function hasPrimeAgentSession(agentId: string): boolean { + return sessions.has(agentId); +} + +export function registerPrimeAgentSession(agentId: string, session: PrimeAgentManagedSession): () => void { + sessions.set(agentId, session); + return () => { + if (sessions.get(agentId) === session) sessions.delete(agentId); + }; +} + +export function getPrimeAgentSession(agentId: string): PrimeAgentManagedSession { + const session = sessions.get(agentId); + if (!session) throw new Error(`MessageDeliveryFailed: Prime Agent RPC session is unavailable for ${agentId}`); + return session; +} + +export async function deliverPrimeAgentMessage( + agentId: string, + message: string, + preferred: 'prompt' | 'steer' | 'follow_up' = 'steer', +): Promise<PrimeAgentDeliveryReceipt> { + const session = sessions.get(agentId); + if (!session) { + const result = await postPrimeAgentHost(agentId, { op: 'message', message, preferred }); + return { accepted: true, command: result.command as PrimeAgentDeliveryReceipt['command'] }; + } + const state = await session.client.request<{ isStreaming?: boolean }>({ type: 'get_state' }); + const command = state.data?.isStreaming + ? preferred === 'follow_up' ? 'follow_up' : 'steer' + : 'prompt'; + await session.client.request({ type: command, message }); + return { accepted: true, command }; +} + +export async function postPrimeAgentHost(agentId: string, body: unknown): Promise<Record<string, unknown>> { + const socketPath = join(getOverdeckHome(), 'sockets', `prime-agent-${agentId}.sock`); + const tokenPath = join(AGENTS_DIR, agentId, 'prime-agent-token'); + if (!existsSync(socketPath)) throw new Error(`MessageDeliveryFailed: Prime Agent host is unavailable for ${agentId}`); + const token = readFileSync(tokenPath, 'utf8').trim(); + return new Promise((resolve, reject) => { + const req = request({ socketPath, path: '/', method: 'POST', headers: { 'content-type': 'application/json', 'x-overdeck-bridge-token': token } }, response => { + const chunks: Buffer[] = []; + response.on('data', chunk => chunks.push(Buffer.from(chunk))); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if ((response.statusCode ?? 500) >= 300) reject(new Error(`Prime Agent host returned HTTP ${response.statusCode}: ${text}`)); + else resolve(text ? JSON.parse(text) as Record<string, unknown> : {}); + }); + }); + req.once('error', reject); + req.end(JSON.stringify(body)); + }); +} + +export async function killPrimeAgentSession(agentId: string, graceMs = 1_000): Promise<void> { + const session = getPrimeAgentSession(agentId); + try { + await session.client.request({ type: 'abort' }); + } finally { + await new Promise(resolve => setTimeout(resolve, graceMs)); + await session.terminate(); + sessions.delete(agentId); + } +} + +export function clearPrimeAgentSessionsForTests(): void { + sessions.clear(); +} + +export type { PrimeAgentRpcResponse }; diff --git a/src/lib/prime-agent/session-resume.ts b/src/lib/prime-agent/session-resume.ts new file mode 100644 index 00000000000..0879e3b3179 --- /dev/null +++ b/src/lib/prime-agent/session-resume.ts @@ -0,0 +1,51 @@ +import { constants } from 'node:fs'; +import { access } from 'node:fs/promises'; + +export class PrimeAgentResumeError extends Error { + constructor(message: string) { + super(message); + this.name = 'PrimeAgentResumeError'; + } +} + +export interface PrimeAgentResumeProcess { + getState(): Promise<{ sessionId?: string }>; + stop(): Promise<void>; +} + +export interface ResumePrimeAgentSessionOptions { + sessionId: string; + sessionPath: string; + start: (sessionPath: string) => Promise<PrimeAgentResumeProcess>; + accessFile?: (path: string) => Promise<void>; +} + +/** Start a resumed client only after the durable session exists, then verify identity. */ +export async function resumePrimeAgentSession(options: ResumePrimeAgentSessionOptions): Promise<PrimeAgentResumeProcess> { + const accessFile = options.accessFile ?? ((path: string) => access(path, constants.R_OK)); + try { + await accessFile(options.sessionPath); + } catch { + throw new PrimeAgentResumeError( + `Prime Agent session ${options.sessionId} cannot resume because ${options.sessionPath} is missing or unreadable. No fresh session was created.`, + ); + } + + const process = await options.start(options.sessionPath); + try { + const state = await process.getState(); + if (state.sessionId !== options.sessionId) { + throw new PrimeAgentResumeError( + `Prime Agent resume returned session ${state.sessionId ?? '<missing>'}, expected ${options.sessionId}. The replacement process was stopped; no fresh session was accepted.`, + ); + } + return process; + } catch (cause) { + await process.stop().catch(() => undefined); + if (cause instanceof PrimeAgentResumeError) throw cause; + const message = cause instanceof Error ? cause.message : String(cause); + throw new PrimeAgentResumeError( + `Prime Agent session ${options.sessionId} failed its resume verification: ${message}. The replacement process was stopped.`, + ); + } +} diff --git a/src/lib/runtimes/__tests__/prime-agent.test.ts b/src/lib/runtimes/__tests__/prime-agent.test.ts new file mode 100644 index 00000000000..6eef5ea5fcb --- /dev/null +++ b/src/lib/runtimes/__tests__/prime-agent.test.ts @@ -0,0 +1,51 @@ +import { closeSync, mkdtempSync, openSync, utimesSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { PrimeAgentRuntimeSync, type PrimeAgentRuntimeController } from '../prime-agent.js'; +import { getGlobalRegistry } from '../index.js'; + +function controller(overrides: Partial<PrimeAgentRuntimeController> = {}): PrimeAgentRuntimeController { + return { + spawn: vi.fn(async () => ({ sessionId: 'prime-session', sessionPath: '/sessions/prime.jsonl' })), + send: vi.fn(async () => undefined), abort: vi.fn(async () => undefined), terminate: vi.fn(async () => undefined), + isRunning: vi.fn(async () => true), stats: vi.fn(() => null), lastEventAt: vi.fn(() => null), sessionPath: vi.fn(() => null), + ...overrides, + }; +} + +describe('PrimeAgentRuntimeSync', () => { + it('is registered in the global runtime registry', () => { + expect(getGlobalRegistry().get('prime-agent')).toBeInstanceOf(PrimeAgentRuntimeSync); + }); + + it('prefers live RPC event activity and falls back to detached JSONL mtime', () => { + const liveAt = new Date('2026-08-12T12:00:00Z'); + const live = new PrimeAgentRuntimeSync({ controller: controller({ lastEventAt: () => liveAt }) }); + expect(live.getHeartbeat('agent-pan-1')).toMatchObject({ timestamp: liveAt, source: 'active-heartbeat', confidence: 'high' }); + + const path = join(mkdtempSync(join(tmpdir(), 'prime-runtime-')), 'session.jsonl'); + closeSync(openSync(path, 'w')); + const detachedAt = new Date('2026-08-12T11:00:00Z'); + utimesSync(path, detachedAt, detachedAt); + const detached = new PrimeAgentRuntimeSync({ controller: controller({ sessionPath: () => path }) }); + expect(detached.getHeartbeat('agent-pan-1')).toMatchObject({ timestamp: detachedAt, source: 'jsonl', confidence: 'medium' }); + }); + + it('returns the recorded session path and aborts before termination', async () => { + const order: string[] = []; + const runtime = new PrimeAgentRuntimeSync({ controller: controller({ + sessionPath: () => '/sessions/prime.jsonl', + abort: async () => { order.push('abort'); }, terminate: async () => { order.push('terminate'); }, + }) }); + expect(runtime.getSessionPath('agent-pan-1')).toBe('/sessions/prime.jsonl'); + await runtime.killAgent('agent-pan-1'); + expect(order).toEqual(['abort', 'terminate']); + }); + + it('reports session stats without inventing missing values', () => { + const runtime = new PrimeAgentRuntimeSync({ controller: controller({ stats: () => ({ tokens: { input: 10, output: 2 }, cost: 0.5 }) }) }); + expect(runtime.getTokenUsage('agent-pan-1')).toEqual({ inputTokens: 10, outputTokens: 2, cacheReadTokens: undefined, cacheWriteTokens: undefined }); + expect(runtime.getSessionCost('agent-pan-1')?.totalCost).toBe(0.5); + }); +}); diff --git a/src/lib/runtimes/index.ts b/src/lib/runtimes/index.ts index 516076591e1..46b7c9233b3 100644 --- a/src/lib/runtimes/index.ts +++ b/src/lib/runtimes/index.ts @@ -43,6 +43,7 @@ export { createKimiCodeRuntimeSync, KimiCodeSpawnTimeout, } from './kimi-code.js'; +export { PrimeAgentRuntimeSync, createPrimeAgentRuntimeSync } from './prime-agent.js'; import type { AgentRuntimeSync, @@ -56,6 +57,7 @@ import { createOhmypiRuntimeSync } from './ohmypi.js'; import { createCodexRuntimeSync } from './codex.js'; import { createAcpRuntimeSync } from './acp.js'; import { createKimiCodeRuntimeSync } from './kimi-code.js'; +import { createPrimeAgentRuntimeSync } from './prime-agent.js'; /** * Runtime registry implementation @@ -114,6 +116,9 @@ export class RuntimeRegistry implements RuntimeRegistryInterface { if (harness === 'kimi-code') { return this.get('kimi-code') ?? null; } + if (harness === 'prime-agent') { + return this.get('prime-agent') ?? null; + } return this.get('claude-code') ?? null; } } @@ -142,6 +147,7 @@ export function getGlobalRegistry(): RuntimeRegistry { globalRegistry.register(createCodexRuntimeSync()); globalRegistry.register(createAcpRuntimeSync()); globalRegistry.register(createKimiCodeRuntimeSync()); + globalRegistry.register(createPrimeAgentRuntimeSync()); } return globalRegistry; } diff --git a/src/lib/runtimes/prime-agent.ts b/src/lib/runtimes/prime-agent.ts new file mode 100644 index 00000000000..0755df8c78a --- /dev/null +++ b/src/lib/runtimes/prime-agent.ts @@ -0,0 +1,172 @@ +import { existsSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import type { AgentState } from '../agents.js'; +import { getAgentStateSync, listAgentStates } from '../agents.js'; +import { getRuntimeBehavior } from './behavior.js'; +import type { + Agent, + AgentRuntimeSync, + CostBreakdown, + HarnessBehavior, + Heartbeat, + Session, + SpawnConfig, + TokenUsage, +} from './types.js'; +import { getAgentDir } from '../agents/agent-state.js'; +import { deliverPrimeAgentMessage, postPrimeAgentHost } from '../prime-agent/session-controller.js'; +import { tmuxKillSession, tmuxSessionExists } from './tmux-cli.js'; +import { tmuxCreateSession } from './tmux-cli.js'; +import { getProviderAuthMode } from '../agents/provider-auth.js'; +import { buildPrimeAgentBaseCommand } from '../prime-agent/launch-command.js'; +import { loadConfigSync } from '../config-yaml.js'; + +export interface PrimeAgentSessionStats { + tokens?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number }; + cost?: number; +} + +export interface PrimeAgentRuntimeController { + spawn(config: SpawnConfig): Promise<{ sessionId: string; sessionPath: string }>; + send(agentId: string, message: string): Promise<void>; + abort(agentId: string): Promise<void>; + terminate(agentId: string): Promise<void>; + isRunning(agentId: string): Promise<boolean>; + stats(agentId: string): PrimeAgentSessionStats | null; + lastEventAt(agentId: string): Date | null; + sessionPath(agentId: string): string | null; +} + +const productionController: PrimeAgentRuntimeController = { + async spawn(config) { + if (!config.model) throw new Error('Prime Agent spawn requires a model'); + const startupTimeoutMs = loadConfigSync().config.primeAgent.rpcStartupTimeoutMs; + const authMode = await getProviderAuthMode(config.model); + if (!authMode) throw new Error(`Prime Agent provider credentials are unavailable for ${config.model}`); + let command = await buildPrimeAgentBaseCommand({ agentId: config.agentId, model: config.model, workspace: config.workspace, authMode, rpcStartupTimeoutMs: startupTimeoutMs }); + if (config.sessionId) command += ` --resume ${shellQuote(config.sessionId)}`; + if (config.prompt) command += ` --prompt ${shellQuote(config.prompt)}`; + rmSync(join(getAgentDir(config.agentId), 'prime-agent-session-id'), { force: true }); // PAN-3357: not a dir removal + rmSync(join(getAgentDir(config.agentId), 'prime-agent-launch-error'), { force: true }); // PAN-3357: not a dir removal + await tmuxCreateSession(config.agentId, config.workspace, command, { ...config.env, OVERDECK_AGENT_ID: config.agentId }); + try { + const deadline = Date.now() + startupTimeoutMs; + while (Date.now() < deadline) { + const sessionId = readText(config.agentId, 'prime-agent-session-id'); + if (sessionId) return { sessionId, sessionPath: readText(config.agentId, 'prime-agent-session-path') ?? '' }; + const launchError = readText(config.agentId, 'prime-agent-launch-error'); + if (launchError) throw new Error(`Prime Agent host failed to start: ${launchError}`); + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error(`Prime Agent host did not become ready for ${config.agentId}`); + } catch (error) { + if (await tmuxSessionExists(config.agentId)) await tmuxKillSession(config.agentId); + throw error; + } + }, + async send(agentId, message) { await deliverPrimeAgentMessage(agentId, message); }, + async abort(agentId) { await postPrimeAgentHost(agentId, { op: 'abort' }); }, + async terminate(agentId) { await tmuxKillSession(agentId); }, + async isRunning(agentId) { return existsSync(join(getAgentDir(agentId), 'prime-agent-token')) && await tmuxSessionExists(agentId); }, + stats(agentId) { return readStats(agentId)?.stats ?? null; }, + lastEventAt(agentId) { const value = readStats(agentId)?.lastEventAt; return value ? new Date(value) : null; }, + sessionPath(agentId) { return readText(agentId, 'prime-agent-session-path'); }, +}; + +function shellQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } + +function readText(agentId: string, file: string): string | null { + try { return readFileSync(join(getAgentDir(agentId), file), 'utf8').trim() || null; } catch { return null; } +} + +function readStats(agentId: string): { stats?: PrimeAgentSessionStats; lastEventAt?: string } | null { + try { return JSON.parse(readFileSync(join(getAgentDir(agentId), 'prime-agent-stats.json'), 'utf8')) as { stats?: PrimeAgentSessionStats; lastEventAt?: string }; } catch { return null; } +} + +export interface PrimeAgentRuntimeOptions { + controller?: PrimeAgentRuntimeController; + getAgentState?: (agentId: string) => AgentState | null; + listAgentStates?: () => AgentState[]; +} + +export class PrimeAgentRuntimeSync implements AgentRuntimeSync { + readonly name = 'prime-agent' as const; + private readonly controller: PrimeAgentRuntimeController; + private readonly resolveAgentState: (agentId: string) => AgentState | null; + private readonly resolveAgentStates: () => AgentState[]; + + constructor(options: PrimeAgentRuntimeOptions = {}) { + this.controller = options.controller ?? productionController; + this.resolveAgentState = options.getAgentState ?? getAgentStateSync; + this.resolveAgentStates = options.listAgentStates ?? listAgentStates; + } + + getHarnessBehavior(): HarnessBehavior { return getRuntimeBehavior('prime-agent'); } + getSessionPath(agentId: string): string | null { return this.controller.sessionPath(agentId); } + + getLastActivity(agentId: string): Date | null { + const live = this.controller.lastEventAt(agentId); + if (live) return live; + const path = this.getSessionPath(agentId); + if (!path) return null; + try { return statSync(path).mtime; } catch { return null; } + } + + getHeartbeat(agentId: string): Heartbeat | null { + const live = this.controller.lastEventAt(agentId); + if (live) return { timestamp: live, agentId, source: 'active-heartbeat', confidence: 'high' }; + const detached = this.getLastActivity(agentId); + return detached ? { timestamp: detached, agentId, source: 'jsonl', confidence: 'medium' } : null; + } + + getTokenUsage(agentId: string): TokenUsage | null { + const tokens = this.controller.stats(agentId)?.tokens; + if (!tokens) return null; + return { inputTokens: tokens.input ?? 0, outputTokens: tokens.output ?? 0, cacheReadTokens: tokens.cacheRead, cacheWriteTokens: tokens.cacheWrite }; + } + + getSessionCost(agentId: string): CostBreakdown | null { + const cost = this.controller.stats(agentId)?.cost; + return cost === undefined ? null : { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheWriteCost: 0, totalCost: cost, currency: 'USD' }; + } + + getSessionMetrics(agentId: string) { + const snapshot = readStats(agentId); + const tokens = snapshot?.stats?.tokens; + const cost = snapshot?.stats?.cost; + return { + lastActivity: snapshot?.lastEventAt ? new Date(snapshot.lastEventAt) : this.getLastActivity(agentId), + tokenUsage: tokens ? { inputTokens: tokens.input ?? 0, outputTokens: tokens.output ?? 0, cacheReadTokens: tokens.cacheRead, cacheWriteTokens: tokens.cacheWrite } : null, + cost: cost === undefined ? null : { inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheWriteCost: 0, totalCost: cost, currency: 'USD' as const }, + }; + } + + sendMessage(agentId: string, message: string): Promise<void> { return this.controller.send(agentId, message); } + + async killAgent(agentId: string): Promise<void> { + await this.controller.abort(agentId); + await this.controller.terminate(agentId); + } + + async spawnAgent(config: SpawnConfig): Promise<Agent> { + const session = await this.controller.spawn(config); + return { id: config.agentId, sessionId: session.sessionId, runtime: 'prime-agent', model: config.model ?? '', workspace: config.workspace, startedAt: new Date() }; + } + + listSessions(workspace?: string): Session[] { + return this.resolveAgentStates().flatMap((state) => { + if (state.harness !== 'prime-agent' || (workspace && state.workspace !== workspace) || !state.sessionId) return []; + const lastActivity = this.getLastActivity(state.id); + if (!lastActivity) return []; + return [{ id: state.sessionId, agentId: state.id, workspace: state.workspace, model: state.model, startedAt: new Date(state.startedAt), lastActivity, tokenUsage: this.getTokenUsage(state.id) ?? { inputTokens: 0, outputTokens: 0 } }]; + }); + } + + isRunning(agentId: string): Promise<boolean> { + return this.resolveAgentState(agentId)?.harness === 'prime-agent' ? this.controller.isRunning(agentId) : Promise.resolve(false); + } +} + +export function createPrimeAgentRuntimeSync(options?: PrimeAgentRuntimeOptions): PrimeAgentRuntimeSync { + return new PrimeAgentRuntimeSync(options); +} diff --git a/src/lib/runtimes/types.ts b/src/lib/runtimes/types.ts index 8eb785e5b4b..0d39847fa63 100644 --- a/src/lib/runtimes/types.ts +++ b/src/lib/runtimes/types.ts @@ -41,7 +41,7 @@ export type { * by normalizeHarness() on read. normalizeHarness still accepts the raw string * 'pi' as input so old rows round-trip safely. */ -export type RuntimeName = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code'; +export type RuntimeName = 'claude-code' | 'ohmypi' | 'codex' | 'acp' | 'kimi-code' | 'prime-agent'; /** * Legacy harness strings that can still appear in persisted state or older @@ -206,6 +206,13 @@ export interface AgentRuntimeSync { */ getSessionCost(agentId: string): CostBreakdown | null; + /** Read dashboard metrics in one pass. Request-time routes use this to avoid repeated transcript scans. */ + getSessionMetrics?(agentId: string): { + lastActivity: Date | null; + tokenUsage: TokenUsage | null; + cost: CostBreakdown | null; + }; + /** * Send a message to a running agent * diff --git a/src/lib/sync.ts b/src/lib/sync.ts index f4c262b775a..b4bf5151eef 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -28,8 +28,10 @@ import { type LegacyBeadsCleanup, piGlobalContextFile, codexGlobalContextFile, + primeAgentGlobalContextFile, } from './context-layers/index.js'; import { backupFileSync, createBackupTimestamp } from './backup.js'; +import { writeRenderedGlobalContext } from './context-layers/rendered-global.js'; export { isStartupSyncNeededSync, writeSyncManifestSync } from './sync-startup-gate.js'; export interface SyncItem { name: string; @@ -565,6 +567,7 @@ export interface ContextLayerSyncResult { piGlobalWritten: boolean; /** True when ~/.overdeck/context/codex-global.md was written this run. */ codexGlobalWritten: boolean; + primeAgentGlobalWritten: boolean; firstInjections: ContextFirstInjection[]; legacyBeadsCleanups: LegacyBeadsCleanup[]; errors: string[]; @@ -611,6 +614,7 @@ export function syncContextLayersSync(): ContextLayerSyncResult { projectsWritten: [], piGlobalWritten: false, codexGlobalWritten: false, + primeAgentGlobalWritten: false, firstInjections: [], legacyBeadsCleanups: [], errors: [], @@ -634,32 +638,28 @@ export function syncContextLayersSync(): ContextLayerSyncResult { try { const piManaged = renderGlobalLayer('ohmypi', isDevMode()); const piGlobalFile = piGlobalContextFile(); - const existingPi = existsSync(piGlobalFile) ? readFileSync(piGlobalFile, 'utf-8') : ''; - if (piManaged.trim() !== existingPi.trim()) { - mkdirSync(dirname(piGlobalFile), { recursive: true }); - writeFileSync(piGlobalFile, piManaged.trim() + '\n', 'utf-8'); - result.piGlobalWritten = true; - } + result.piGlobalWritten = writeRenderedGlobalContext(piGlobalFile, piManaged); } catch (err: any) { result.errors.push(`pi-global: ${err?.message ?? err}`); } - // PAN-1574: Global layer → ~/.overdeck/context/codex-global.md - // This static file is copied into each agent's CODEX_HOME/AGENTS.md at spawn time - // by initCodexHome(), keeping Codex context isolated from the project-root AGENTS.md. + // PAN-1574: Global layer → ~/.overdeck/context/codex-global.md. try { const codexManaged = renderGlobalLayer('codex', isDevMode()); const codexGlobalFile = codexGlobalContextFile(); - const existingCodex = existsSync(codexGlobalFile) ? readFileSync(codexGlobalFile, 'utf-8') : ''; - if (codexManaged.trim() !== existingCodex.trim()) { - mkdirSync(dirname(codexGlobalFile), { recursive: true }); - writeFileSync(codexGlobalFile, codexManaged.trim() + '\n', 'utf-8'); - result.codexGlobalWritten = true; - } + result.codexGlobalWritten = writeRenderedGlobalContext(codexGlobalFile, codexManaged); } catch (err: any) { result.errors.push(`codex-global: ${err?.message ?? err}`); } + try { + const managed = renderGlobalLayer('prime-agent', isDevMode()); + const file = primeAgentGlobalContextFile(); + result.primeAgentGlobalWritten = writeRenderedGlobalContext(file, managed); + } catch (err: any) { + result.errors.push(`prime-agent-global: ${err?.message ?? err}`); + } + // PAN-1837 (D6): kimi-code intentionally gets no dedicated global render file here — // it reads the shared AGENTS.md layer natively via ~/.agents/skills discovery, same as acp. // PAN-1837 review fix: AGENTS.md itself must therefore render as the UNION of every diff --git a/src/lib/system-prerequisites.ts b/src/lib/system-prerequisites.ts index 01de80593aa..fdb86814823 100644 --- a/src/lib/system-prerequisites.ts +++ b/src/lib/system-prerequisites.ts @@ -178,17 +178,29 @@ export const PREREQUISITES: readonly PrerequisiteDefinition[] = [ win: 'https://www.kimi.com/code/docs/en/kimi-code-cli/guides/getting-started.html', }, }, + { + id: 'prime-agent', + name: 'Prime Agent', + required: false, + purpose: 'Prime Agent managed harness using persistent RPC mode', + versionArgs: ['--version'], + install: { + linux: 'https://github.com/PrimeIntellect-ai/prime-agent#installation', + mac: 'https://github.com/PrimeIntellect-ai/prime-agent#installation', + win: 'Prime Agent supports macOS and Linux; use a supported workspace host', + }, + }, ]; export type PrerequisiteProbe = (cmd: string, args: string[]) => Promise<string>; export type PrerequisiteResolver = ( command: string, - options?: { acpHarness?: boolean; pathValue?: string }, + options?: { acpHarness?: boolean; primeAgentHarness?: boolean; pathValue?: string }, ) => Promise<string | null>; const defaultProbe: PrerequisiteProbe = async (cmd, args) => { - const { stdout } = await execFileAsync(cmd, args, { encoding: 'utf-8', timeout: 10_000 }); - return stdout; + const { stdout, stderr } = await execFileAsync(cmd, args, { encoding: 'utf-8', timeout: 10_000 }); + return stdout || stderr; }; const defaultResolver: PrerequisiteResolver = async (command, options) => { @@ -197,6 +209,8 @@ const defaultResolver: PrerequisiteResolver = async (command, options) => { : undefined; return options?.acpHarness ? resolveHarnessBinary('acp', resolutionOptions) + : options?.primeAgentHarness + ? resolveHarnessBinary('prime-agent', resolutionOptions) : resolveExecutable(command, resolutionOptions); }; @@ -207,6 +221,7 @@ function resolvePrerequisiteExecutable( ): Promise<string | null> { const options = { ...(id === 'kimi' ? { acpHarness: true } : {}), + ...(id === 'prime-agent' ? { primeAgentHarness: true } : {}), ...(pathValue !== undefined ? { pathValue } : {}), }; return Object.keys(options).length > 0 diff --git a/tests/fixtures/prime-agent/session.jsonl b/tests/fixtures/prime-agent/session.jsonl new file mode 100644 index 00000000000..9fc2effaae9 --- /dev/null +++ b/tests/fixtures/prime-agent/session.jsonl @@ -0,0 +1,7 @@ +{"type":"user","content":"Run the analysis"} +{"type":"thinking","content":"I should inspect the data first"} +{"type":"assistant","content":"I will inspect it."} +{"type":"tool_call","name":"python","arguments":{"code":"print(42)"}} +{"type":"tool_result","result":"42"} +{"type":"compaction","summary":"Earlier analysis was compacted."} +{"type":"error","error":"Python process exited"} diff --git a/tests/integration/prime-agent-smoke.test.ts b/tests/integration/prime-agent-smoke.test.ts new file mode 100644 index 00000000000..8d5a716391a --- /dev/null +++ b/tests/integration/prime-agent-smoke.test.ts @@ -0,0 +1,53 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildPrimeAgentBaseCommand } from '../../src/lib/prime-agent/launch-command.js'; +import { deliverPrimeAgentMessage, postPrimeAgentHost } from '../../src/lib/prime-agent/session-controller.js'; +import { createPrimeAgentRuntimeSync } from '../../src/lib/runtimes/prime-agent.js'; + +const runLive = process.env.OVERDECK_PRIME_AGENT_LIVE === '1'; +const model = process.env.OVERDECK_PRIME_AGENT_MODEL ?? 'gpt-5.4-mini'; +const children: ChildProcess[] = []; + +async function waitUntil(predicate: () => boolean | Promise<boolean>, timeoutMs = 120_000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('Timed out waiting for Prime Agent production host'); +} + +afterEach(() => { + for (const child of children.splice(0)) if (child.exitCode === null) child.kill('SIGTERM'); +}); + +describe.skipIf(!runLive)('Prime Agent production-path smoke', () => { + it('launches, delivers, uses a tool, reports cached stats, and resumes through the host', async () => { + const root = await mkdtemp(join(tmpdir(), 'pan-prime-agent-smoke-')); + process.env.OVERDECK_HOME = root; + const agentId = 'agent-prime-live-smoke'; + const marker = join(root, 'prime-tool-ok.txt'); + const command = await buildPrimeAgentBaseCommand({ agentId, model, workspace: root, authMode: 'api-key' }); + const first = spawn('bash', ['-lc', command], { stdio: 'inherit' }); + children.push(first); + await waitUntil(() => createPrimeAgentRuntimeSync().isRunning(agentId)); + await deliverPrimeAgentMessage(agentId, `Use the bash tool to run: printf prime-tool-ok > ${marker}. Then reply with only TOOL_OK.`); + await deliverPrimeAgentMessage(agentId, 'Keep the final reply to exactly TOOL_OK.', 'steer'); + await waitUntil(async () => await readFile(marker, 'utf8').then(value => value === 'prime-tool-ok').catch(() => false)); + await postPrimeAgentHost(agentId, { op: 'stats' }); + const runtime = createPrimeAgentRuntimeSync(); + expect(runtime.getTokenUsage(agentId)?.inputTokens).toBeGreaterThan(0); + const sessionId = await readFile(join(root, 'agents', agentId, 'prime-agent-session-id'), 'utf8'); + first.kill('SIGTERM'); + + const resumed = spawn('bash', ['-lc', `${command} --resume '${sessionId.trim()}'`], { stdio: 'inherit' }); + children.push(resumed); + await waitUntil(() => createPrimeAgentRuntimeSync().isRunning(agentId)); + await deliverPrimeAgentMessage(agentId, 'Reply with only RESUMED.'); + await postPrimeAgentHost(agentId, { op: 'stats' }); + expect(runtime.getSessionPath(agentId)).toBeTruthy(); + }, 180_000); +}); diff --git a/tests/lib/config-yaml.test.ts b/tests/lib/config-yaml.test.ts index 5b03a2c67ff..e02f80b32c1 100644 --- a/tests/lib/config-yaml.test.ts +++ b/tests/lib/config-yaml.test.ts @@ -192,7 +192,7 @@ api_keys: openai: { enabled: true, harness: 'bad' as never }, }, }, - })).toThrow('models.providers.openai.harness must be claude-code, ohmypi, codex, acp, or kimi-code'); + })).toThrow('models.providers.openai.harness must be claude-code, ohmypi, codex, acp, kimi-code, or prime-agent'); }); it('normalizes legacy DashScope API keys without re-enabling an explicitly disabled provider', () => { diff --git a/tests/lib/context-layers/harness.test.ts b/tests/lib/context-layers/harness.test.ts index bb182c19d30..aa95a69970e 100644 --- a/tests/lib/context-layers/harness.test.ts +++ b/tests/lib/context-layers/harness.test.ts @@ -6,6 +6,12 @@ import { describe, it, expect } from 'vitest'; import { renderForHarness, validateTemplate } from '../../../src/lib/context-layers/harness.js'; describe('renderForHarness', () => { + it('keeps Prime-only blocks only for Prime Agent', () => { + const source = 'shared\n{{#harness:prime-agent}}prime only{{/harness:prime-agent}}\n'; + expect(renderForHarness(source, 'prime-agent')).toContain('prime only'); + expect(renderForHarness(source, 'claude-code')).not.toContain('prime only'); + expect(validateTemplate(source).issues).toEqual([]); + }); it('keeps always-on content for every harness', () => { expect(renderForHarness('Always here.', 'claude-code')).toBe('Always here.'); expect(renderForHarness('Always here.', 'ohmypi')).toBe('Always here.'); diff --git a/tests/unit/cli/doctor-prime-agent.test.ts b/tests/unit/cli/doctor-prime-agent.test.ts new file mode 100644 index 00000000000..c68ebc313ba --- /dev/null +++ b/tests/unit/cli/doctor-prime-agent.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { checkPrimeAgent } from '../../../src/cli/commands/doctor-prime-agent.js'; + +const resolver = async () => '/opt/prime/bin/prime-agent'; + +describe('checkPrimeAgent', () => { + it('accepts a compatible release that advertises RPC mode', async () => { + const result = await checkPrimeAgent(async (_path, args) => args[0] === '--version' + ? 'prime-agent 0.1.0' + : 'Usage: prime-agent --mode <interactive|rpc>', resolver); + expect(result).toEqual([{ name: 'Prime Agent', status: 'ok', message: 'v0.1.0 (RPC mode available)' }]); + }); + + it('returns standalone errors for missing, unsupported, and non-RPC builds', async () => { + const missing = await checkPrimeAgent(async () => '', async () => null); + expect(missing[0]).toMatchObject({ status: 'warn', message: expect.stringContaining('Not installed') }); + + const unsupported = await checkPrimeAgent(async () => 'prime-agent 1.0.0', resolver); + expect(unsupported[0]).toMatchObject({ status: 'warn', message: expect.stringContaining('unsupported') }); + + const noRpc = await checkPrimeAgent(async (_path, args) => args[0] === '--version' ? '0.1.0' : 'Usage: prime-agent', resolver); + expect(noRpc[0]).toMatchObject({ status: 'warn', message: expect.stringContaining('--mode rpc') }); + }); + + it('reports missing provider credentials', async () => { + const result = await checkPrimeAgent(async (_path, args) => args[0] === '--version' + ? 'prime-agent 0.1.0' + : 'Usage: prime-agent --mode <interactive|rpc>', resolver, async () => false); + expect(result[1]).toMatchObject({ name: 'Prime Agent provider credentials', status: 'warn' }); + }); +}); diff --git a/tests/unit/dashboard/runtime-session-projection.test.ts b/tests/unit/dashboard/runtime-session-projection.test.ts new file mode 100644 index 00000000000..24c01712d94 --- /dev/null +++ b/tests/unit/dashboard/runtime-session-projection.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/lib/runtimes/index.js', () => ({ + getGlobalRegistry: () => ({ + get: (harness: string) => harness === 'prime-agent' ? { + getSessionMetrics: () => ({ + lastActivity: new Date('2026-08-12T20:00:00.000Z'), + tokenUsage: { inputTokens: 10, outputTokens: 2, cacheReadTokens: 3 }, + cost: { totalCost: 0.04 }, + }), + getLastActivity: () => { throw new Error('must not rescan'); }, + } : harness === 'claude-code' ? { + getLastActivity: () => new Date('2026-08-12T21:00:00.000Z'), + getTokenUsage: () => { throw new Error('request route must not parse transcripts'); }, + getSessionCost: () => { throw new Error('request route must not parse transcripts'); }, + } : undefined, + }), +})); + +const { projectRuntimeSession } = await import('../../../src/dashboard/server/services/runtime-session-projection.js'); + +describe('dashboard runtime session projection', () => { + it('projects Prime identity, activity, usage, and cost through the runtime adapter', () => { + expect(projectRuntimeSession('agent-pan-3668', 'prime-agent')).toEqual({ + harness: 'prime-agent', + lastActivity: '2026-08-12T20:00:00.000Z', + tokenUsage: { inputTokens: 10, outputTokens: 2, cacheReadTokens: 3 }, + cost: 0.04, + }); + }); + + it('does not synchronously parse transcripts when a runtime has no cached metrics', () => { + expect(projectRuntimeSession('agent-pan-1', 'claude-code')).toEqual({ + harness: 'claude-code', + lastActivity: '2026-08-12T21:00:00.000Z', + }); + }); +}); diff --git a/tests/unit/lib/agents/spawn-prime-agent.test.ts b/tests/unit/lib/agents/spawn-prime-agent.test.ts new file mode 100644 index 00000000000..736cf5bfb93 --- /dev/null +++ b/tests/unit/lib/agents/spawn-prime-agent.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/lib/agents/agent-state.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../../../src/lib/agents/agent-state.js')>(); + return { ...actual, getAgentDir: (id: string) => join(tmpdir(), id) }; +}); + +vi.mock('../../../../src/lib/agents.js', () => ({ + getAgentStateSync: () => null, + listAgentStates: () => [], +})); + +vi.mock('../../../../src/lib/config-yaml.js', () => ({ loadConfigSync: () => ({ config: { providerAuth: { openai: 'api-key' }, primeAgent: { rpcStartupTimeoutMs: 45_000 } } }) })); +vi.mock('../../../../src/lib/openai-auth.js', async () => { + const { Effect } = await import('effect'); + return { getOpenAIAuthStatus: () => Effect.succeed({ loggedIn: false }) }; +}); + +import { getPrimeAgentBaseCommand } from '../../../../src/lib/agents/runtime-command.js'; +import { generateLauncherScriptSync } from '../../../../src/lib/launcher-generator.js'; + +describe('Prime Agent work launch', () => { + it('assembles explicit RPC provider, model, session directory, and managed policy arguments', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'prime-workspace-')); + const baseCommand = await getPrimeAgentBaseCommand('agent-pan-3668', 'gpt-5.4', workspace); + expect(baseCommand).toContain("prime-agent-host.js' --agent 'agent-pan-3668'"); + expect(baseCommand).toContain("--provider 'openai' --model 'gpt-5.4'"); + expect(baseCommand).toContain("--session-dir '/tmp/agent-pan-3668/prime-sessions'"); + expect(baseCommand).toContain('--append-system-prompt'); + expect(baseCommand).toContain('--startup-timeout-ms 45000'); + + const launcher = generateLauncherScriptSync({ role: 'work', workingDir: workspace, harness: 'prime-agent', baseCommand }); + expect(launcher).toContain('exec node'); + expect(launcher).toContain('prime-agent-host.js'); + expect(launcher).not.toContain('exec claude'); + }); + + it('surfaces unsupported mapping without falling back to Claude Code', async () => { + await expect(getPrimeAgentBaseCommand('agent-pan-3668', 'ql-swift-8b', '/workspace')).rejects.toThrow('no fallback model was selected'); + }); +}); diff --git a/tests/unit/lib/cloister/verification-worker-supervisor.test.ts b/tests/unit/lib/cloister/verification-worker-supervisor.test.ts index 8bb6163a7b8..7e524418de7 100644 --- a/tests/unit/lib/cloister/verification-worker-supervisor.test.ts +++ b/tests/unit/lib/cloister/verification-worker-supervisor.test.ts @@ -188,17 +188,19 @@ describe('verification worker supervisor', () => { }); describe('PAN-3674 follow-up: expired workers', () => { - function writeWorkerState(home: string, issueId: string, state: Record<string, unknown>): void { + function writeWorkerState(home: string, issueId: string, state: Record<string, unknown>): string { const dir = join(home, 'verification-workers', issueId.toLowerCase()); mkdirSync(dir, { recursive: true }); + const resultPath = join(dir, 'result-seeded.json'); writeFileSync(join(dir, 'state.json'), JSON.stringify({ runId: 'run-seeded', issueId, workspacePath: '/tmp/workspace', - resultPath: join(dir, 'result-seeded.json'), + resultPath, phase: 'running', ...state, })); + return resultPath; } it('never joins a worker past its deadline — kills it and starts fresh', async () => { @@ -219,6 +221,7 @@ describe('PAN-3674 follow-up: expired workers', () => { if (outcome.outcome !== 'passed') { throw new Error(`unexpected outcome: ${JSON.stringify(outcome)}; disk=${JSON.stringify(readVerificationWorkerState('PAN-3674'))}`); } + expect(outcome.outcome).toBe('passed'); // The expired worker was killed rather than joined. expect(() => process.kill(zombiePid, 0)).toThrow(); // A fresh worker took over the registration. diff --git a/tests/unit/lib/context-layers/prime-agent.test.ts b/tests/unit/lib/context-layers/prime-agent.test.ts new file mode 100644 index 00000000000..afddc2c3af1 --- /dev/null +++ b/tests/unit/lib/context-layers/prime-agent.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/lib/paths.js', () => ({ getOverdeckHome: () => '/tmp/overdeck-prime-context' })); + +describe('Prime Agent context artifact', () => { + it('uses an Overdeck-owned path outside ~/.prime/agent', async () => { + const { primeAgentGlobalContextFile } = await import('../../../../src/lib/context-layers/layers.js'); + const path = primeAgentGlobalContextFile(); + expect(path).toBe('/tmp/overdeck-prime-context/context/prime-agent-global.md'); + expect(path).not.toContain('/.prime/agent'); + }); +}); diff --git a/tests/unit/lib/conversations/transcript-adapter-prime.test.ts b/tests/unit/lib/conversations/transcript-adapter-prime.test.ts new file mode 100644 index 00000000000..7424e3ef899 --- /dev/null +++ b/tests/unit/lib/conversations/transcript-adapter-prime.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getTranscriptAdapter } from '../../../../src/lib/conversations/transcript-adapter.js'; + +const fixture = fileURLToPath(new URL('../../../fixtures/prime-agent/session.jsonl', import.meta.url)); + +describe('Prime Agent transcript adapter', () => { + it('is registered under the Prime harness', () => { + expect(getTranscriptAdapter('prime-agent').name).toBe('prime-agent'); + }); + + it('normalizes every durable Prime record kind', async () => { + const transcript = await getTranscriptAdapter('prime-agent').serializeTranscript(fixture); + expect(transcript).toContain('[user]\nRun the analysis'); + expect(transcript).toContain('[thinking]\nI should inspect the data first'); + expect(transcript).toContain('[assistant]\nI will inspect it.'); + expect(transcript).toContain('[tool_use: python]'); + expect(transcript).toContain('[tool]\n42'); + expect(transcript).toContain('[compaction]\nEarlier analysis was compacted.'); + expect(transcript).toContain('[error]\nPython process exited'); + }); + + it('bounds large tool output', async () => { + const dir = await mkdtemp(join(tmpdir(), 'prime-transcript-')); + const path = join(dir, 'session.jsonl'); + await writeFile(path, `${JSON.stringify({ type: 'tool_result', result: 'x'.repeat(20_000) })}\n`); + try { + const transcript = await getTranscriptAdapter('prime-agent').serializeTranscript(path); + expect(transcript.length).toBeLessThan(4_100); + expect(transcript).toMatch(/…$/); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/lib/cost-parsers/prime-agent-parser.test.ts b/tests/unit/lib/cost-parsers/prime-agent-parser.test.ts new file mode 100644 index 00000000000..6156a0e6d8a --- /dev/null +++ b/tests/unit/lib/cost-parsers/prime-agent-parser.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { mapPrimeAgentSessionStats, parsePrimeAgentSessionContent } from '../../../../src/lib/cost-parsers/prime-agent-parser.js'; + +describe('Prime Agent cost parser', () => { + it('maps complete session statistics', () => { + expect(mapPrimeAgentSessionStats({ + tokens: { input: 800, output: 200, cacheRead: 100, cacheWrite: 50, total: 1_150 }, + cost: 0.12, + contextWindow: 4_000, + contextUsed: 1_000, + })).toEqual({ input: 800, output: 200, cacheRead: 100, cacheWrite: 50, total: 1_150, cost: 0.12, contextWindow: 4_000, contextPercent: 25 }); + }); + + it('does not invent omitted fields', () => { + expect(mapPrimeAgentSessionStats({ tokens: { output: 12 } })).toEqual({ output: 12 }); + }); + + it('accepts cache-token variants and durable assistant usage', () => { + const parsed = parsePrimeAgentSessionContent([ + JSON.stringify({ type: 'assistant', usage: { inputTokens: 20, outputTokens: 5, cache_read: 8 } }), + JSON.stringify({ type: 'assistant', usage: { input: 10, output: 2, cacheWriteTokens: 3, cost: 0.01 } }), + ].join('\n')); + expect(parsed).toEqual({ input: 30, output: 7, cacheRead: 8, cacheWrite: 3, cost: 0.01 }); + }); +}); diff --git a/tests/unit/lib/overdeck/conversation-runtime-prime.test.ts b/tests/unit/lib/overdeck/conversation-runtime-prime.test.ts new file mode 100644 index 00000000000..705f1dd8530 --- /dev/null +++ b/tests/unit/lib/overdeck/conversation-runtime-prime.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../src/lib/agents/agent-state.js', () => ({ + getAgentDir: (agentId: string) => `/tmp/${agentId}`, + getAgentStateSync: vi.fn(), +})); +vi.mock('../../../../src/lib/openai-auth.js', async () => { + const { Effect } = await import('effect'); + return { getOpenAIAuthStatus: () => Effect.succeed({ loggedIn: false }) }; +}); +vi.mock('../../../../src/lib/config-yaml.js', async (importOriginal) => ({ + ...await importOriginal<typeof import('../../../../src/lib/config-yaml.js')>(), + loadConfigSync: () => ({ config: { providerAuth: { openai: 'api-key' }, primeAgent: { rpcStartupTimeoutMs: 45_000 } }, path: '/tmp/config.yaml' }), +})); + +const { preparePrimeAgentConversationLaunch, resolveAllowedHarness } = await import( + '../../../../src/lib/overdeck/conversation-runtime.js' +); + +describe('Prime Agent conversation launch', () => { + beforeEach(() => vi.clearAllMocks()); + + it('accepts an explicit Prime harness selection', async () => { + await expect(resolveAllowedHarness('prime-agent', 'gpt-5.4')).resolves.toBe('prime-agent'); + }); + + it('assembles a persistent managed RPC launch', async () => { + const launch = await preparePrimeAgentConversationLaunch('conv-prime', '/workspaces/project', 'gpt-5.4'); + expect(launch.fields).toEqual({ harness: 'prime-agent' }); + expect(launch.runtimeCommand).toContain("prime-agent-host.js' --agent 'conv-prime'"); + expect(launch.runtimeCommand).toContain("--provider 'openai' --model 'gpt-5.4'"); + expect(launch.runtimeCommand).toContain("--session-dir '/tmp/conv-prime/prime-sessions'"); + expect(launch.runtimeCommand).toContain('--append-system-prompt'); + expect(launch.runtimeCommand).toContain('--startup-timeout-ms 45000'); + expect(launch.runtimeCommand).not.toContain('--permission-mode'); + }); +}); diff --git a/tests/unit/lib/prime-agent-no-loss-audit.test.ts b/tests/unit/lib/prime-agent-no-loss-audit.test.ts new file mode 100644 index 00000000000..a72936c19c9 --- /dev/null +++ b/tests/unit/lib/prime-agent-no-loss-audit.test.ts @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(import.meta.dirname, '../../..'); +const ALL = ['claude-code', 'ohmypi', 'codex', 'acp', 'kimi-code', 'prime-agent']; + +function source(path: string): string { + return readFileSync(join(ROOT, path), 'utf8'); +} + +describe('Prime Agent no-loss audit', () => { + it('has no unverified inventory row', () => { + const rows = source('docs/prime-agent-no-loss-audit.md').split('\n').filter(line => /^\| S\d+/.test(line)); + expect(rows.length).toBeGreaterThan(10); + expect(rows.filter(row => !row.endsWith('| [x] |'))).toEqual([]); + }); + + it.each([ + 'packages/contracts/src/types.ts', + 'packages/contracts/src/harness-behavior.ts', + 'src/lib/config-yaml/schema.ts', + 'src/lib/runtimes/types.ts', + 'src/lib/launcher-generator.ts', + ])('%s preserves every harness literal', (path) => { + const content = source(path); + for (const harness of ALL) expect(content, `${path} omitted ${harness}`).toMatch(new RegExp(`["']${harness}["']`)); + }); + + it.each([ + 'packages/contracts/src/artifacts.ts', + 'packages/contracts/src/telemetry.ts', + 'packages/contracts/src/flywheel.ts', + 'packages/contracts/src/context-layers.ts', + ])('%s includes Prime in its canonical enumeration', (path) => { + expect(source(path)).toContain('prime-agent'); + }); + + it('registers explicit runtime, transcript, cost, and frontend homes', () => { + expect(source('src/lib/runtimes/index.ts')).toContain("register(createPrimeAgentRuntimeSync())"); + expect(source('src/lib/conversations/transcript-adapter.ts')).toContain("'prime-agent': primeAgentAdapter"); + expect(source('src/lib/overdeck/cost.ts')).toContain("'prime_agent'"); + expect(source('src/dashboard/frontend/src/components/shared/ModelPicker/ModelPicker.tsx')).toContain("id: 'prime-agent'"); + }); +}); diff --git a/tests/unit/lib/prime-agent/delivery.test.ts b/tests/unit/lib/prime-agent/delivery.test.ts new file mode 100644 index 00000000000..0a241143d54 --- /dev/null +++ b/tests/unit/lib/prime-agent/delivery.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + clearPrimeAgentSessionsForTests, + deliverPrimeAgentMessage, + killPrimeAgentSession, + registerPrimeAgentSession, +} from '../../../../src/lib/prime-agent/session-controller.js'; + +function session(streaming: boolean) { + const commands: Array<Record<string, unknown>> = []; + const terminate = vi.fn(async () => undefined); + registerPrimeAgentSession('agent-prime', { + client: { + request: vi.fn(async (command: Record<string, unknown>) => { + commands.push(command); + return { type: 'response', id: 'test', command: command.type, success: true, data: { isStreaming: streaming } }; + }), + }, + terminate, + }); + return { commands, terminate }; +} + +describe('Prime Agent managed delivery', () => { + beforeEach(() => { + clearPrimeAgentSessionsForTests(); + vi.useFakeTimers(); + }); + afterEach(() => vi.useRealTimers()); + + it('uses prompt when idle', async () => { + const target = session(false); + await expect(deliverPrimeAgentMessage('agent-prime', 'hello')).resolves.toEqual({ accepted: true, command: 'prompt' }); + expect(target.commands.map(command => command.type)).toEqual(['get_state', 'prompt']); + }); + + it.each(['steer', 'follow_up'] as const)('uses %s while streaming', async (preferred) => { + const target = session(true); + await expect(deliverPrimeAgentMessage('agent-prime', 'guidance', preferred)).resolves.toEqual({ accepted: true, command: preferred }); + expect(target.commands.map(command => command.type)).toEqual(['get_state', preferred]); + }); + + it('reports rejected and crashed sessions', async () => { + registerPrimeAgentSession('agent-prime', { + client: { request: vi.fn(async () => { throw new Error('process exited'); }) }, + terminate: vi.fn(async () => undefined), + }); + await expect(deliverPrimeAgentMessage('agent-prime', 'hello')).rejects.toThrow('process exited'); + }); + + it('aborts, waits a bounded grace period, and terminates', async () => { + const target = session(true); + const killed = killPrimeAgentSession('agent-prime', 500); + await vi.advanceTimersByTimeAsync(500); + await killed; + expect(target.commands.map(command => command.type)).toEqual(['abort']); + expect(target.terminate).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/unit/lib/prime-agent/policy.test.ts b/tests/unit/lib/prime-agent/policy.test.ts new file mode 100644 index 00000000000..8390bd24cf4 --- /dev/null +++ b/tests/unit/lib/prime-agent/policy.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { + assertPrimeAgentManagedCommandAllowed, + PRIME_AGENT_MANAGED_POLICY, + PRIME_AGENT_MANAGED_POLICY_CHECKPOINT, + PrimeAgentManagedPolicyError, +} from '../../../../src/lib/prime-agent/policy.js'; + +describe('Prime Agent managed-session policy', () => { + it('states provenance, workspace, lifecycle, child-agent, and state-write-door rules', () => { + expect(PRIME_AGENT_MANAGED_POLICY).toContain('operator messages'); + expect(PRIME_AGENT_MANAGED_POLICY).toContain('assigned Overdeck workspace'); + expect(PRIME_AGENT_MANAGED_POLICY).toContain('only lifecycle authority'); + expect(PRIME_AGENT_MANAGED_POLICY).toContain('not Overdeck agents'); + expect(PRIME_AGENT_MANAGED_POLICY).toContain('must never run pan done'); + expect(PRIME_AGENT_MANAGED_POLICY).toContain('single state write door'); + expect(PRIME_AGENT_MANAGED_POLICY).toContain('session-local'); + }); + + it.each(['add_schedule', 'set_heartbeat', 'send_message', 'observe'])( + 'rejects managed daemon command %s with a standalone explanation', + (command) => { + expect(() => assertPrimeAgentManagedCommandAllowed(command)).toThrow(PrimeAgentManagedPolicyError); + expect(() => assertPrimeAgentManagedCommandAllowed(command)).toThrow('Cloister owns lifecycle and messaging'); + }, + ); + + it.each(['prompt', 'steer', 'follow_up', 'abort', 'get_state', 'get_messages', 'get_session_stats'])( + 'allows root-session command %s', + (command) => expect(() => assertPrimeAgentManagedCommandAllowed(command)).not.toThrow(), + ); + + it('records the pinned-protocol checkpoint and fallback', () => { + expect(PRIME_AGENT_MANAGED_POLICY_CHECKPOINT).toMatchObject({ outcome: 'policy-enforced', verifiedOn: '2026-08-12' }); + expect(PRIME_AGENT_MANAGED_POLICY_CHECKPOINT.fallback).toContain('supervised conversations only'); + }); +}); diff --git a/tests/unit/lib/prime-agent/session-resume.test.ts b/tests/unit/lib/prime-agent/session-resume.test.ts new file mode 100644 index 00000000000..abb8ce1a037 --- /dev/null +++ b/tests/unit/lib/prime-agent/session-resume.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PrimeAgentResumeError, resumePrimeAgentSession } from '../../../../src/lib/prime-agent/session-resume.js'; + +describe('resumePrimeAgentSession', () => { + it('returns a process only when get_state confirms the persisted session ID', async () => { + const process = { getState: vi.fn(async () => ({ sessionId: 'session-1' })), stop: vi.fn(async () => undefined) }; + const start = vi.fn(async () => process); + await expect(resumePrimeAgentSession({ sessionId: 'session-1', sessionPath: '/sessions/one.jsonl', start, accessFile: async () => undefined })).resolves.toBe(process); + expect(start).toHaveBeenCalledWith('/sessions/one.jsonl'); + expect(process.stop).not.toHaveBeenCalled(); + }); + + it('rejects a missing session before starting a process', async () => { + const start = vi.fn(); + await expect(resumePrimeAgentSession({ sessionId: 'session-1', sessionPath: '/missing.jsonl', start, accessFile: async () => { throw new Error('ENOENT'); } })).rejects.toThrow('No fresh session was created'); + expect(start).not.toHaveBeenCalled(); + }); + + it('stops a process whose get_state session ID does not match', async () => { + const process = { getState: vi.fn(async () => ({ sessionId: 'wrong' })), stop: vi.fn(async () => undefined) }; + await expect(resumePrimeAgentSession({ sessionId: 'session-1', sessionPath: '/sessions/one.jsonl', start: async () => process, accessFile: async () => undefined })).rejects.toBeInstanceOf(PrimeAgentResumeError); + expect(process.stop).toHaveBeenCalledOnce(); + }); + + it('stops the replacement process when it exits during state verification', async () => { + const process = { getState: vi.fn(async () => { throw new Error('process exited'); }), stop: vi.fn(async () => undefined) }; + await expect(resumePrimeAgentSession({ sessionId: 'session-1', sessionPath: '/sessions/one.jsonl', start: async () => process, accessFile: async () => undefined })).rejects.toThrow('process exited'); + expect(process.stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 0e48b8948c7..aafcde449f6 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'pty-supervisor': 'src/lib/channels/pty-supervisor.ts', 'codex-app-server-host': 'src/lib/codex/app-server-host.ts', 'acp-host': 'src/lib/acp/host.ts', + 'prime-agent-host': 'src/lib/prime-agent/host.ts', 'verification-worker': 'src/lib/cloister/verification-worker.ts', 'lib/memory/fts-worker': 'src/lib/memory/fts-worker.ts', },