From 627465189a36604cb0a341e8ea403d9aa0ffe33c Mon Sep 17 00:00:00 2001 From: Edwin Date: Wed, 5 Aug 2026 23:44:30 -0700 Subject: [PATCH] feat: add Prime Agent harness support --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 9 +- crates/adapter-pi/src/lib.rs | 307 +++++++++++++----- crates/adapter-prime-agent/Cargo.toml | 17 + crates/adapter-prime-agent/src/lib.rs | 9 + crates/cli/Cargo.toml | 1 + crates/cli/src/app/configure.rs | 7 + crates/cli/src/main.rs | 6 + crates/cli/src/project_dashboard.rs | 19 +- crates/cli/src/ui.rs | 10 + crates/client/src/lib.rs | 13 +- crates/daemon/src/availability.rs | 5 + crates/daemon/src/config.rs | 23 +- crates/daemon/src/doctor.rs | 4 + crates/daemon/src/session.rs | 20 +- crates/daemon/src/session/lifecycle.rs | 4 + docs/adding-a-harness.md | 35 +- docs/architecture.md | 1 + docs/configuration.md | 2 + docs/harnesses.md | 9 +- docs/model-routing.md | 1 + docs/unified-tool-layer.md | 2 +- ...195-prime-agent-native-session-contract.md | 57 ++++ 24 files changed, 457 insertions(+), 114 deletions(-) create mode 100644 crates/adapter-prime-agent/Cargo.toml create mode 100644 crates/adapter-prime-agent/src/lib.rs create mode 100644 specs/0195-prime-agent-native-session-contract.md diff --git a/Cargo.lock b/Cargo.lock index 22195d87..a0b6a3aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -708,6 +708,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "construct-adapter-prime-agent" +version = "0.17.3" +dependencies = [ + "anyhow", + "construct-adapter-pi", +] + [[package]] name = "construct-adapter-shell" version = "0.17.3" @@ -765,6 +773,7 @@ dependencies = [ "construct-adapter-muse", "construct-adapter-opencode", "construct-adapter-pi", + "construct-adapter-prime-agent", "construct-adapter-shell", "construct-adapter-smith", "construct-client", diff --git a/Cargo.toml b/Cargo.toml index b9b75e98..82fa60ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/adapter-kimi", "crates/adapter-hermes", "crates/adapter-pi", + "crates/adapter-prime-agent", "crates/adapter-muse", "crates/adapter-common", "crates/adapter-smith", diff --git a/README.md b/README.md index c2cfb8dc..6ba64674 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ More screenshots and demos: [gallery](gallery.md). ## Why construct? -- **tmux for agent fleets** — manage Codex, Claude Code, OpenCode, Muse, - Antigravity, Grok, and smith sessions from your terminal. Sessions live in - the daemon, not your terminal: SSH drops, laptop sleeps — agents keep +- **tmux for agent fleets** — manage Codex, Claude Code, OpenCode, Prime Agent, + Muse, Antigravity, Grok, and smith sessions from your terminal. Sessions live + in the daemon, not your terminal: SSH drops, laptop sleeps — agents keep working, and you reattach exactly where you left off, scrollback and all. Single Rust binary. - **Lineage** — sessions branch like ideas do: fork one when you want a @@ -73,6 +73,8 @@ first: - **Antigravity** — install the `agy` CLI and complete its OAuth login. - **Grok** — install the `grok` CLI and complete its OAuth login. - **Muse** — install the `muse` CLI and run `muse login`. +- **Prime Agent** — install the `prime-agent` CLI and configure a provider or + subscription. - **smith** — built in to construct. Talks to OpenAI, Anthropic, Google Gemini, or xAI Grok via API key, a local Ollama, a ChatGPT subscription via Codex OAuth, a Claude subscription via the authenticated Claude Code CLI, or a Grok @@ -113,6 +115,7 @@ favorite CLI harness command: ```sh construct new codex construct new claude --model opus +construct new prime-agent construct new muse ``` diff --git a/crates/adapter-pi/src/lib.rs b/crates/adapter-pi/src/lib.rs index a5335896..08f923ca 100644 --- a/crates/adapter-pi/src/lib.rs +++ b/crates/adapter-pi/src/lib.rs @@ -1,15 +1,17 @@ -//! pi coding agent adapter. +//! Shared Pi-family coding agent adapter core. //! -//! Wraps the `pi` CLI (npm `@earendil-works/pi-coding-agent`). pi persists -//! each conversation as a JSONL session file whose records carry everything -//! construct wants to mirror: user/assistant messages with `thinking`, +//! Wraps the `pi` CLI and Prime Agent. Prime Agent is independently shipped, +//! but intentionally retains Pi's JSON event and JSONL session schemas. Each +//! persists conversations whose records carry everything Construct wants to +//! mirror: user/assistant messages with `thinking`, //! `text`, and `toolCall` content blocks, `toolResult` messages, live //! `model_change` / `thinking_level_change` records, and per-assistant-call //! `usage` with a full token split *and* exact USD cost. //! -//! Instead of tailing pi's global store (`~/.pi/agent/sessions//`), -//! the adapter passes `--session-dir /pi-sessions` -//! so every construct session owns a private store. That removes the +//! Instead of tailing either CLI's global store, the adapter passes a private +//! `--session-dir` under `CONSTRUCT_SESSION_DATA_DIR` so every Construct +//! session owns its store. Pi and Prime Agent use distinct subdirectories and +//! native-id sidecars. That removes the //! whole sibling-disambiguation problem other adapters fight (codex //! originator tags, kimi sibling scans): the newest session file in the //! private dir is ours by construction. @@ -17,19 +19,19 @@ //! Session files are named `_.jsonl`, so lexicographic //! filename order is chronological order, and the file for a known uuid is //! findable by suffix. The uuid from the `session` header record is the -//! native id persisted to `pi_session_id.txt` (resume via `--session -//! `, native fork via `--fork ` — spec 0031/0078; reset +//! native id persisted to a flavor-specific sidecar (resume via the flavor's +//! path flag, native fork via `--fork ` — spec 0031/0078; reset //! detection via newest-file rebinds — specs 0138/0085). //! -//! Interactive mode runs pi's TUI under construct's PTY with the initial -//! prompt passed as a CLI argument (pi submits leading message arguments -//! itself). Headless mode spawns `pi -p --mode json` per turn and parses +//! Interactive mode runs the native TUI under Construct's PTY with the initial +//! prompt passed as a CLI argument. Headless mode spawns the selected CLI with +//! `-p --mode json` per turn and parses //! the event stream from stdout; `message_end` records carry the same //! message shape as the session file, so both modes share one translator. //! -//! Honors `CONSTRUCT_PI_CMD` (full command prefix) then `CONSTRUCT_PI_BIN`, -//! defaulting to `pi` on PATH (npm installs there; pi has no fixed -//! installer home to fall back to). +//! Pi honors `CONSTRUCT_PI_CMD` / `CONSTRUCT_PI_BIN`; Prime Agent honors +//! `CONSTRUCT_PRIME_AGENT_CMD` / `CONSTRUCT_PRIME_AGENT_BIN` and launches the +//! `prime-agent` binary. Both default to PATH lookup. use std::collections::VecDeque; use std::path::{Path, PathBuf}; @@ -54,12 +56,55 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::sync::mpsc; -const SESSION_ID_FILE: &str = "pi_session_id.txt"; -const SESSIONS_SUBDIR: &str = "pi-sessions"; +#[derive(Clone, Copy)] +struct HarnessFlavor { + name: &'static str, + command_env: &'static str, + binary_env: &'static str, + default_binary: &'static str, + mode_env: &'static str, + fork_env: &'static str, + session_id_file: &'static str, + sessions_subdir: &'static str, + resume_flag: &'static str, +} + +const PI: HarnessFlavor = HarnessFlavor { + name: "pi", + command_env: "CONSTRUCT_PI_CMD", + binary_env: "CONSTRUCT_PI_BIN", + default_binary: "pi", + mode_env: "CONSTRUCT_PI_MODE", + fork_env: "CONSTRUCT_PI_FORK_FROM", + session_id_file: "pi_session_id.txt", + sessions_subdir: "pi-sessions", + resume_flag: "--session", +}; + +const PRIME_AGENT: HarnessFlavor = HarnessFlavor { + name: "prime-agent", + command_env: "CONSTRUCT_PRIME_AGENT_CMD", + binary_env: "CONSTRUCT_PRIME_AGENT_BIN", + default_binary: "prime-agent", + mode_env: "CONSTRUCT_PRIME_AGENT_MODE", + fork_env: "CONSTRUCT_PRIME_AGENT_FORK_FROM", + session_id_file: "prime_agent_session_id.txt", + sessions_subdir: "prime-agent-sessions", + resume_flag: "--resume", +}; pub async fn run() -> anyhow::Result<()> { + run_flavor(PI).await +} + +/// Run Prime Agent through the shared Pi-compatible adapter core. +pub async fn run_prime_agent() -> anyhow::Result<()> { + run_flavor(PRIME_AGENT).await +} + +async fn run_flavor(flavor: HarnessFlavor) -> anyhow::Result<()> { let metadata = InitializeResult { - name: "pi".into(), + name: flavor.name.into(), version: env!("CARGO_PKG_VERSION").into(), capabilities: Capabilities { supports_input: true, @@ -69,10 +114,10 @@ pub async fn run() -> anyhow::Result<()> { ..Default::default() }, }; - adapter_run(metadata, |params, ctx| async move { - match resolve_mode(¶ms) { - Mode::Interactive => run_interactive(params, ctx).await, - Mode::Headless => run_headless(params, ctx).await, + adapter_run(metadata, move |params, ctx| async move { + match resolve_mode(¶ms, flavor) { + Mode::Interactive => run_interactive(params, ctx, flavor).await, + Mode::Headless => run_headless(params, ctx, flavor).await, } }) .await @@ -84,8 +129,8 @@ enum Mode { Headless, } -fn resolve_mode(params: &SessionStartParams) -> Mode { - if let Ok(m) = std::env::var("CONSTRUCT_PI_MODE") { +fn resolve_mode(params: &SessionStartParams, flavor: HarnessFlavor) -> Mode { + if let Ok(m) = std::env::var(flavor.mode_env) { match m.as_str() { "interactive" => return Mode::Interactive, "headless" => return Mode::Headless, @@ -107,27 +152,27 @@ fn session_data_dir() -> Option { .map(PathBuf::from) } -/// This construct session's private pi session store, created on demand. -fn pi_sessions_dir() -> Option { - let dir = session_data_dir()?.join(SESSIONS_SUBDIR); +/// This construct session's private harness session store, created on demand. +fn sessions_dir(flavor: HarnessFlavor) -> Option { + let dir = session_data_dir()?.join(flavor.sessions_subdir); let _ = std::fs::create_dir_all(&dir); Some(dir) } -fn conv_id_file() -> Option { - Some(session_data_dir()?.join(SESSION_ID_FILE)) +fn conv_id_file(flavor: HarnessFlavor) -> Option { + Some(session_data_dir()?.join(flavor.session_id_file)) } -fn read_conv_id() -> Option { - let path = conv_id_file()?; +fn read_conv_id(flavor: HarnessFlavor) -> Option { + let path = conv_id_file(flavor)?; std::fs::read_to_string(path) .ok() .map(|s| s.trim().to_string()) .filter(|s| is_pi_session_id(s)) } -fn write_conv_id(id: &str) { - if let Some(path) = conv_id_file() { +fn write_conv_id(flavor: HarnessFlavor, id: &str) { + if let Some(path) = conv_id_file(flavor) { let _ = std::fs::write(path, id); } } @@ -163,17 +208,29 @@ fn newest_session_file(dir: &Path) -> Option { best.map(|(_, path)| path) } -/// The session file for a known uuid, by filename suffix. +/// The session file for a known native uuid. Pi includes the header id in the +/// filename, while Prime Agent 0.7.0 names the file with a different UUID, so +/// the authoritative fallback scans each JSONL header. fn session_file_for_id(dir: &Path, id: &str) -> Option { let suffix = format!("_{id}.jsonl"); - std::fs::read_dir(dir) + let paths: Vec = std::fs::read_dir(dir) .ok()? .flatten() - .map(|e| e.path()) - .find(|p| { - p.file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n.ends_with(&suffix)) + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("jsonl")) + .collect(); + paths + .iter() + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + }) + .cloned() + .or_else(|| { + paths + .into_iter() + .find(|path| header_session_id(path).as_deref() == Some(id)) }) } @@ -195,15 +252,15 @@ fn header_session_id(path: &Path) -> Option { /// sessions root. The sibling walk is what makes forking from a reset /// snapshot work — the snapshot's data dir holds the retired id, but the /// file itself still lives in the original session's store. -fn resolve_session_file(id: &str) -> Option { +fn resolve_session_file(flavor: HarnessFlavor, id: &str) -> Option { let own_data_dir = session_data_dir()?; - if let Some(path) = session_file_for_id(&own_data_dir.join(SESSIONS_SUBDIR), id) { + if let Some(path) = session_file_for_id(&own_data_dir.join(flavor.sessions_subdir), id) { return Some(path); } let sessions_root = own_data_dir.parent()?; for entry in std::fs::read_dir(sessions_root).ok()?.flatten() { - let candidate = entry.path().join(SESSIONS_SUBDIR); - if candidate == own_data_dir.join(SESSIONS_SUBDIR) { + let candidate = entry.path().join(flavor.sessions_subdir); + if candidate == own_data_dir.join(flavor.sessions_subdir) { continue; } if let Some(path) = session_file_for_id(&candidate, id) { @@ -472,6 +529,16 @@ fn message_events(message: &Value, meta: &mut MetaState) -> Vec { _ => {} } } + if message.get("stopReason").and_then(Value::as_str) == Some("error") { + events.push(SessionEvent::Error { + message: message + .get("errorMessage") + .and_then(Value::as_str) + .filter(|text| !text.trim().is_empty()) + .unwrap_or("Agent turn failed") + .to_string(), + }); + } if let Some(usage) = message.get("usage") { // `last_model` was just refreshed from this same message's // `model`/`provider` fields above, so it is this call's model @@ -578,23 +645,28 @@ fn interpose_typed_input( rx } -async fn run_interactive(params: SessionStartParams, mut ctx: AdapterContext) { +async fn run_interactive( + params: SessionStartParams, + mut ctx: AdapterContext, + flavor: HarnessFlavor, +) { let command = construct_protocol::adapter::resolve_command_override( - "CONSTRUCT_PI_CMD", - "CONSTRUCT_PI_BIN", - "pi", + flavor.command_env, + flavor.binary_env, + flavor.default_binary, ); let mut args = command.args.clone(); args.extend(params.args.clone()); - let store = pi_sessions_dir(); + let store = sessions_dir(flavor); if let Some(dir) = store.as_ref() { args.extend(["--session-dir".into(), dir.to_string_lossy().into_owned()]); } else { - ctx.emit.log( - "pi: no CONSTRUCT_SESSION_DATA_DIR — falling back to pi's global session store; \ - resume and native-id tracking are disabled", - ); + ctx.emit.log(format!( + "{}: no CONSTRUCT_SESSION_DATA_DIR — falling back to its global session store; \ + resume and native-id tracking are disabled", + flavor.name + )); } let resuming = std::env::var("CONSTRUCT_RESUME").as_deref() == Ok("1"); @@ -605,37 +677,44 @@ async fn run_interactive(params: SessionStartParams, mut ctx: AdapterContext) { // than silently re-entering something we can't name. let resume_path = resuming .then(|| { - let id = read_conv_id()?; + let id = read_conv_id(flavor)?; let path = store.as_ref().and_then(|d| session_file_for_id(d, &id)); if path.is_none() { ctx.emit.log(format!( - "pi respawn: captured session {id} has no file in the private store; \ - starting a fresh conversation" + "{} respawn: captured session {id} has no file in the private store; \ + starting a fresh conversation", + flavor.name )); } path }) .flatten(); - if resuming && resume_path.is_none() && read_conv_id().is_none() { - ctx.emit - .log("pi respawn: no captured native session id; starting a fresh conversation"); + if resuming && resume_path.is_none() && read_conv_id(flavor).is_none() { + ctx.emit.log(format!( + "{} respawn: no captured native session id; starting a fresh conversation", + flavor.name + )); } if let Some(path) = resume_path.as_ref() { - args.extend(["--session".into(), path.to_string_lossy().into_owned()]); + args.extend([ + flavor.resume_flag.into(), + path.to_string_lossy().into_owned(), + ]); } // Same-harness fork (spec 0031/0078): the daemon passes the parent's // captured uuid; pi forks a session file into OUR private store. let fork_path = (!resuming) .then(|| { - let parent = std::env::var("CONSTRUCT_PI_FORK_FROM") + let parent = std::env::var(flavor.fork_env) .ok() .filter(|s| is_pi_session_id(s))?; - let path = resolve_session_file(&parent); + let path = resolve_session_file(flavor, &parent); if path.is_none() { ctx.emit.log(format!( - "pi fork: parent session {parent} not found in any construct pi store; \ - starting fresh without parent context" + "{} fork: parent session {parent} not found in any construct store; \ + starting fresh without parent context", + flavor.name )); } path @@ -679,6 +758,7 @@ async fn run_interactive(params: SessionStartParams, mut ctx: AdapterContext) { dir, resume_path, initial_model: params.model.clone(), + flavor, }, ctx.emit.clone(), ); @@ -712,6 +792,7 @@ struct WatcherSetup { /// Model the daemon asked for at launch; seeds change detection so a /// spawn on the requested model stays quiet. initial_model: Option, + flavor: HarnessFlavor, } /// Watch the private session store: bind to the newest session file, mirror @@ -724,6 +805,7 @@ fn spawn_session_watcher(setup: WatcherSetup, emit: EventEmitter) { dir, resume_path, initial_model, + flavor, } = setup; let mut meta = MetaState { last_model: initial_model, @@ -752,14 +834,15 @@ fn spawn_session_watcher(setup: WatcherSetup, emit: EventEmitter) { if let Some(id) = header_session_id(&newest) { if let Some((_, prior_id)) = current.as_ref() { emit.log(format!( - "pi: native session id changed {prior_id} -> {id}; rebinding" + "{}: native session id changed {prior_id} -> {id}; rebinding", + flavor.name )); emit.emit(SessionEvent::NativeIdChanged { prior_native_id: prior_id.clone(), new_native_id: id.clone(), }); } - write_conv_id(&id); + write_conv_id(flavor, &id); cursor = 0; current = Some((newest, id)); } @@ -778,7 +861,7 @@ fn spawn_session_watcher(setup: WatcherSetup, emit: EventEmitter) { prior_native_id: prior_id, new_native_id: new_id.clone(), }); - write_conv_id(&new_id); + write_conv_id(flavor, &new_id); current = Some((path, new_id)); } } @@ -811,28 +894,28 @@ fn spawn_session_watcher(setup: WatcherSetup, emit: EventEmitter) { }); } -async fn run_headless(params: SessionStartParams, mut ctx: AdapterContext) { +async fn run_headless(params: SessionStartParams, mut ctx: AdapterContext, flavor: HarnessFlavor) { let command = construct_protocol::adapter::resolve_command_override( - "CONSTRUCT_PI_CMD", - "CONSTRUCT_PI_BIN", - "pi", + flavor.command_env, + flavor.binary_env, + flavor.default_binary, ); let emit = ctx.emit.clone(); - let store = pi_sessions_dir(); + let store = sessions_dir(flavor); let resuming = std::env::var("CONSTRUCT_RESUME").as_deref() == Ok("1"); let mut session_path: Option = resuming .then(|| { - let id = read_conv_id()?; + let id = read_conv_id(flavor)?; store.as_ref().and_then(|d| session_file_for_id(d, &id)) }) .flatten(); let mut fork_path = (!resuming) .then(|| { - let parent = std::env::var("CONSTRUCT_PI_FORK_FROM") + let parent = std::env::var(flavor.fork_env) .ok() .filter(|s| is_pi_session_id(s))?; - resolve_session_file(&parent) + resolve_session_file(flavor, &parent) }) .flatten(); @@ -884,7 +967,10 @@ async fn run_headless(params: SessionStartParams, mut ctx: AdapterContext) { child_args.extend(["--session-dir".into(), dir.to_string_lossy().into_owned()]); } if let Some(path) = session_path.as_ref() { - child_args.extend(["--session".into(), path.to_string_lossy().into_owned()]); + child_args.extend([ + flavor.resume_flag.into(), + path.to_string_lossy().into_owned(), + ]); } else if let Some(path) = fork_path.take() { child_args.extend(["--fork".into(), path.to_string_lossy().into_owned()]); } @@ -931,6 +1017,7 @@ async fn run_headless(params: SessionStartParams, mut ctx: AdapterContext) { emit.clone(), meta.clone(), captured_sid.clone(), + flavor, ); let stderr_task = spawn_stderr_log(child_stderr, emit.clone()); @@ -944,7 +1031,7 @@ async fn run_headless(params: SessionStartParams, mut ctx: AdapterContext) { // a continue that re-minted the uuid) so the next turn and a daemon // respawn both target it. if let Some(sid) = captured_sid.lock().unwrap().clone() { - write_conv_id(&sid); + write_conv_id(flavor, &sid); if let Some(dir) = store.as_ref() { // The file lands on child exit, which has already happened. if let Some(path) = session_file_for_id(dir, &sid) { @@ -984,6 +1071,7 @@ fn spawn_stdout( emit: EventEmitter, meta: Arc>, captured_sid: Arc>>, + flavor: HarnessFlavor, ) -> tokio::task::JoinHandle<()> where R: tokio::io::AsyncRead + Unpin + Send + 'static, @@ -995,7 +1083,7 @@ where continue; } let Ok(v) = serde_json::from_str::(&line) else { - emit.log(format!("pi stdout: {line}")); + emit.log(format!("{} stdout: {line}", flavor.name)); continue; }; match v.get("type").and_then(Value::as_str) { @@ -1032,6 +1120,46 @@ mod tests { MetaState::default() } + #[test] + fn prime_flavor_is_isolated_and_uses_public_cli_contract() { + assert_eq!(PRIME_AGENT.name, "prime-agent"); + assert_eq!(PRIME_AGENT.default_binary, "prime-agent"); + assert_eq!(PRIME_AGENT.session_id_file, "prime_agent_session_id.txt"); + assert_eq!(PRIME_AGENT.sessions_subdir, "prime-agent-sessions"); + assert_eq!(PRIME_AGENT.resume_flag, "--resume"); + assert_eq!(PRIME_AGENT.command_env, "CONSTRUCT_PRIME_AGENT_CMD"); + assert_eq!(PRIME_AGENT.binary_env, "CONSTRUCT_PRIME_AGENT_BIN"); + assert_eq!(PRIME_AGENT.mode_env, "CONSTRUCT_PRIME_AGENT_MODE"); + assert_eq!(PRIME_AGENT.fork_env, "CONSTRUCT_PRIME_AGENT_FORK_FROM"); + + assert_ne!(PRIME_AGENT.session_id_file, PI.session_id_file); + assert_ne!(PRIME_AGENT.sessions_subdir, PI.sessions_subdir); + } + + #[test] + fn prime_error_message_is_not_silently_dropped() { + // Captured from Prime Agent 0.7.0 `--mode json` on 2026-08-05. + let message = serde_json::json!({ + "role":"assistant", + "content":[], + "api":"openai-responses", + "provider":"openai", + "model":"gpt-5.4", + "usage":{ + "input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0, + "cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0} + }, + "stopReason":"error", + "errorMessage":"Connection error." + }); + + let events = message_events(&message, &mut meta()); + assert!(events.iter().any(|event| matches!( + event, + SessionEvent::Error { message } if message == "Connection error." + ))); + } + #[test] fn header_id_parses_and_validates() { let tmp = tempfile::tempdir().unwrap(); @@ -1080,6 +1208,29 @@ mod tests { assert_eq!(session_file_for_id(tmp.path(), "missing-id"), None); } + #[test] + fn prime_session_file_resolves_by_header_when_filename_id_differs() { + // Prime Agent 0.7.0 writes a file UUID that differs from the native + // session UUID in its header; `--resume` addresses the header id. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp + .path() + .join("019fd5b7-7938-71de-88db-e00d504d3ad4.jsonl"); + std::fs::write( + &path, + concat!( + r#"{"type":"session","version":3,"id":"019fd5b7-7a60-755b-9f9e-4d5d3349196d","timestamp":"2026-08-06T06:16:39.264Z","cwd":"/w"}"#, + "\n", + ), + ) + .unwrap(); + + assert_eq!( + session_file_for_id(tmp.path(), "019fd5b7-7a60-755b-9f9e-4d5d3349196d"), + Some(path) + ); + } + #[test] fn model_and_effort_changes_dedupe() { let mut meta = meta(); diff --git a/crates/adapter-prime-agent/Cargo.toml b/crates/adapter-prime-agent/Cargo.toml new file mode 100644 index 00000000..9c062446 --- /dev/null +++ b/crates/adapter-prime-agent/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "construct-adapter-prime-agent" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Prime Agent adapter for construct (wraps the `prime-agent` CLI)" + +[lib] +name = "construct_adapter_prime_agent" +path = "src/lib.rs" + +[dependencies] +construct-adapter-pi = { path = "../adapter-pi" } +anyhow.workspace = true diff --git a/crates/adapter-prime-agent/src/lib.rs b/crates/adapter-prime-agent/src/lib.rs new file mode 100644 index 00000000..ea6d5adf --- /dev/null +++ b/crates/adapter-prime-agent/src/lib.rs @@ -0,0 +1,9 @@ +//! Prime Agent adapter entry point. +//! +//! Prime Agent intentionally retains Pi's JSON event and JSONL session +//! formats, so its runtime lives in the shared Pi-family adapter core while +//! this crate supplies a first-class Construct adapter boundary. + +pub async fn run() -> anyhow::Result<()> { + construct_adapter_pi::run_prime_agent().await +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index e1904495..bd57216b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -50,6 +50,7 @@ construct-adapter-grok = { path = "../adapter-grok" } construct-adapter-kimi = { path = "../adapter-kimi" } construct-adapter-hermes = { path = "../adapter-hermes" } construct-adapter-pi = { path = "../adapter-pi" } +construct-adapter-prime-agent = { path = "../adapter-prime-agent" } construct-adapter-muse = { path = "../adapter-muse" } construct-adapter-smith = { path = "../adapter-smith" } async-trait = { workspace = true } diff --git a/crates/cli/src/app/configure.rs b/crates/cli/src/app/configure.rs index b1967c77..e29b0efc 100644 --- a/crates/cli/src/app/configure.rs +++ b/crates/cli/src/app/configure.rs @@ -82,6 +82,13 @@ pub fn harness_guidance(name: &str) -> String { provider API key; it must be on the PATH of the shell that starts the construct \ daemon (or set CONSTRUCT_PI_BIN)" .to_string(), + "prime-agent" => { + "install Prime Agent from https://github.com/PrimeIntellect-ai/prime-agent \ + and configure a provider or subscription; `prime-agent` must be on the \ + PATH of the shell that starts the construct daemon (or set \ + CONSTRUCT_PRIME_AGENT_BIN)" + .to_string() + } "muse" => "install the `muse` CLI and run `muse login`; it must be on the PATH of the \ shell that starts the construct daemon (or installed at ~/.local/bin/muse, \ or set CONSTRUCT_MUSE_BIN)" diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index ad1e852f..64482a35 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -385,6 +385,8 @@ enum AdapterCommand { #[command(hide = true)] Pi, #[command(hide = true)] + PrimeAgent, + #[command(hide = true)] Muse, #[command(hide = true)] Smith { @@ -838,6 +840,10 @@ async fn main() -> Result<()> { construct_adapter_pi::run().await?; Ok(()) } + AdapterCommand::PrimeAgent => { + construct_adapter_prime_agent::run().await?; + Ok(()) + } AdapterCommand::Muse => { construct_adapter_muse::run().await?; Ok(()) diff --git a/crates/cli/src/project_dashboard.rs b/crates/cli/src/project_dashboard.rs index e650bf7a..07fac517 100644 --- a/crates/cli/src/project_dashboard.rs +++ b/crates/cli/src/project_dashboard.rs @@ -259,11 +259,16 @@ pub fn primary_label(s: &SessionSummary) -> String { } fn harness_label(s: &SessionSummary) -> String { + let harness = if s.harness == "prime-agent" { + "prime" + } else { + &s.harness + }; let mode = s.mode.as_deref().unwrap_or(""); if mode.eq_ignore_ascii_case("headless") { - format!("h:{}", s.harness) + format!("h:{harness}") } else { - s.harness.clone() + harness.to_string() } } @@ -1050,6 +1055,16 @@ mod tests { } } + #[test] + fn prime_agent_uses_compact_dashboard_label() { + let mut interactive = session("s1", None, SessionState::Running, false); + interactive.harness = "prime-agent".into(); + assert_eq!(harness_label(&interactive), "prime"); + + interactive.mode = Some("headless".into()); + assert_eq!(harness_label(&interactive), "h:prime"); + } + #[test] fn sort_puts_attention_and_errors_first() { let a = session("idle", Some("p"), SessionState::AwaitingInput, false); diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 9dddae1f..6fd0b16c 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -15376,6 +15376,8 @@ pub fn is_headless(s: &construct_protocol::SessionSummary) -> bool { fn harness_label(s: &construct_protocol::SessionSummary) -> String { let harness = if s.harness == "antigravity" { "agy" + } else if s.harness == "prime-agent" { + "prime" } else { &s.harness }; @@ -28293,6 +28295,14 @@ mod tests { "smith" ); assert_eq!(harness_label(&summary_with_mode("shell", None)), "shell"); + assert_eq!( + harness_label(&summary_with_mode("prime-agent", Some("interactive"))), + "prime" + ); + assert_eq!( + harness_label(&summary_with_mode("prime-agent", Some("headless"))), + "(headless) prime" + ); } #[test] diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 1fc5fb58..a9e6a7be 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -1601,14 +1601,17 @@ impl Default for ForkOptions { /// id and the harness forks it byte-for-byte (claude: `--resume /// --fork-session`; codex: `codex fork `; opencode: `--session /// --fork`; grok: `-r -/// --fork-session`; pi: `--fork ` — wired through the +/// --fork-session`; pi / Prime Agent: `--fork ` — wired through the /// daemon's session lifecycle). For /// these, `fork_session` skips the rendered seed — the harness already /// holds the full context with better fidelity. Antigravity has no native /// fork primitive (only in-place `--conversation` resume, backed by an /// indexed store a state-copy would desync), so it keeps the seed. fn harness_forks_natively(harness: &str) -> bool { - matches!(harness, "claude" | "codex" | "opencode" | "grok" | "pi") + matches!( + harness, + "claude" | "codex" | "opencode" | "grok" | "pi" | "prime-agent" + ) } /// Build the `ForkedFrom` stamp for a fork anchored at `anchor` (an @@ -2223,8 +2226,8 @@ mod fork_lineage_tests { let _ = std::fs::remove_file(&sock); } - /// codex (`codex fork `), opencode (`--session --fork`), and - /// grok (`-r --fork-session`) fork + /// codex (`codex fork `), opencode (`--session --fork`), grok + /// (`-r --fork-session`), pi, and Prime Agent (`--fork `) fork /// natively like claude — same-harness terminal forks skip the seed. /// Antigravity has no native fork primitive, so it keeps the seed. #[tokio::test] @@ -2233,6 +2236,8 @@ mod fork_lineage_tests { ("codex", false), ("opencode", false), ("grok", false), + ("pi", false), + ("prime-agent", false), ("antigravity", true), ("agy", true), ] { diff --git a/crates/daemon/src/availability.rs b/crates/daemon/src/availability.rs index 0ccec5d2..161f864a 100644 --- a/crates/daemon/src/availability.rs +++ b/crates/daemon/src/availability.rs @@ -155,6 +155,11 @@ pub async fn probe_harness( ), ), "pi" => probe_wrapper_cli("CONSTRUCT_PI_CMD", "CONSTRUCT_PI_BIN", "pi"), + "prime-agent" => probe_wrapper_cli( + "CONSTRUCT_PRIME_AGENT_CMD", + "CONSTRUCT_PRIME_AGENT_BIN", + "prime-agent", + ), "muse" => probe_wrapper_cli( "CONSTRUCT_MUSE_CMD", "CONSTRUCT_MUSE_BIN", diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index a0fb3902..2dcc336c 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -25,7 +25,7 @@ pub const CONFIG_TOML_TEMPLATE: &str = r#"# construct configuration template # Active config: ~/.config/construct/config.toml (or $CONSTRUCT_CONFIG_DIR/config.toml) # This template: ~/.config/construct/config.toml.template # -# All built-in adapters (shell, claude, codex, opencode, antigravity, grok, kimi, hermes, pi, muse, smith) are +# All built-in adapters (shell, claude, codex, opencode, antigravity, grok, kimi, hermes, pi, prime-agent, muse, smith) are # registered automatically — you do not need to declare them unless you want # to change a field. @@ -45,6 +45,7 @@ pub const CONFIG_TOML_TEMPLATE: &str = r#"# construct configuration template # kimi — Kimi Code # hermes — Hermes Agent # pi — pi coding agent +# prime-agent — Prime Agent # muse — Meta Muse Code # smith — native multi-provider agent (OpenAI / Anthropic / Gemini / Meta / Ollama / Grok) @@ -117,7 +118,7 @@ pub const CONFIG_TOML_TEMPLATE: &str = r#"# construct configuration template # the adapter's generated arguments. Wins over the binary-only CONSTRUCT_*_BIN. # # [adapters.codex.env] -# CONSTRUCT_CODEX_CMD = "exec codex" # also: CONSTRUCT_CLAUDE_CMD, CONSTRUCT_OPENCODE_CMD, CONSTRUCT_SHELL_CMD, CONSTRUCT_ANTIGRAVITY_CMD, CONSTRUCT_GROK_CMD, CONSTRUCT_KIMI_CMD, CONSTRUCT_HERMES_CMD, CONSTRUCT_PI_CMD, CONSTRUCT_MUSE_CMD +# CONSTRUCT_CODEX_CMD = "exec codex" # also: CONSTRUCT_CLAUDE_CMD, CONSTRUCT_OPENCODE_CMD, CONSTRUCT_SHELL_CMD, CONSTRUCT_ANTIGRAVITY_CMD, CONSTRUCT_GROK_CMD, CONSTRUCT_KIMI_CMD, CONSTRUCT_HERMES_CMD, CONSTRUCT_PI_CMD, CONSTRUCT_PRIME_AGENT_CMD, CONSTRUCT_MUSE_CMD # Usage-probe command ──────────────────────────────────────────────────────── # @@ -377,6 +378,7 @@ enabled = true # CONSTRUCT_KIMI_CMD — command prefix for the kimi adapter # CONSTRUCT_HERMES_CMD — command prefix for the hermes adapter # CONSTRUCT_PI_CMD — command prefix for the pi adapter +# CONSTRUCT_PRIME_AGENT_CMD — command prefix for the Prime Agent adapter # CONSTRUCT_MUSE_CMD — command prefix for the muse adapter # CONSTRUCT_SHELL_CMD — command prefix for the shell adapter # CONSTRUCT_CLAUDE_BIN — binary path fallback for the claude adapter @@ -387,6 +389,7 @@ enabled = true # CONSTRUCT_KIMI_BIN — binary path fallback for the kimi adapter # CONSTRUCT_HERMES_BIN — binary path fallback for the hermes adapter # CONSTRUCT_PI_BIN — binary path fallback for the pi adapter +# CONSTRUCT_PRIME_AGENT_BIN — binary path fallback for the Prime Agent adapter # CONSTRUCT_MUSE_BIN — binary path fallback for the muse adapter # # --------------------------------------------------------------------------- @@ -1094,6 +1097,12 @@ pub const BUILTIN_ADAPTERS: &[BuiltinAdapter] = &[ args: &["__adapter", "pi"], description: "pi coding agent", }, + BuiltinAdapter { + name: "prime-agent", + binary: "construct", + args: &["__adapter", "prime-agent"], + description: "Prime Agent", + }, BuiltinAdapter { name: "muse", binary: "construct", @@ -1448,6 +1457,16 @@ mod tests { assert_eq!(adapter.args, &["__adapter", "pi"]); } + #[test] + fn prime_agent_is_registered_as_a_builtin_wrapper() { + let adapter = BUILTIN_ADAPTERS + .iter() + .find(|adapter| adapter.name == "prime-agent") + .expect("prime-agent builtin"); + assert_eq!(adapter.binary, "construct"); + assert_eq!(adapter.args, &["__adapter", "prime-agent"]); + } + #[test] fn muse_is_registered_as_a_builtin_wrapper() { let adapter = BUILTIN_ADAPTERS diff --git a/crates/daemon/src/doctor.rs b/crates/daemon/src/doctor.rs index 937681be..dd959a95 100644 --- a/crates/daemon/src/doctor.rs +++ b/crates/daemon/src/doctor.rs @@ -811,6 +811,10 @@ const HARNESS_FIX: &[(&str, &str)] = &[ ("kimi", "curl -fsSL https://kimi.com/code/install.sh | bash"), ("hermes", "install the Hermes agent, then ensure `hermes` is on PATH"), ("pi", "install the pi coding agent, then ensure `pi` is on PATH"), + ( + "prime-agent", + "install Prime Agent, then ensure `prime-agent` is on PATH", + ), ("muse", "install Muse Code and run `muse login`, then ensure `muse` is on PATH"), ( "smith", diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index cff9f937..affc723d 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -5216,7 +5216,7 @@ impl SessionManager { let s = entry.summary.read().await; let native_fork = matches!( s.harness.as_str(), - "claude" | "codex" | "opencode" | "grok" | "pi" + "claude" | "codex" | "opencode" | "grok" | "pi" | "prime-agent" ) && s.has_pty && s.mode.as_deref() != Some("headless"); let forked_from = native_fork.then(|| construct_protocol::ForkedFrom { @@ -6381,6 +6381,7 @@ impl SessionManager { "kimi" => Some("kimi_session_id.txt"), "hermes" => Some("hermes_session_id.txt"), "pi" => Some("pi_session_id.txt"), + "prime-agent" => Some("prime_agent_session_id.txt"), "muse" => Some("muse_session_id.txt"), _ => None, } @@ -7175,8 +7176,17 @@ fn harness_uses_quiescence(s: &SessionSummary) -> bool { s.has_pty && matches!( s.harness.as_str(), - "claude" | "codex" | "antigravity" | "agy" | "grok" | "hermes" | "kimi" | "opencode" - | "pi" | "muse" + "claude" + | "codex" + | "antigravity" + | "agy" + | "grok" + | "hermes" + | "kimi" + | "opencode" + | "pi" + | "prime-agent" + | "muse" ) } @@ -7228,7 +7238,7 @@ fn effective_mode(params: &CreateSessionParams) -> String { fn builtin_harness_capabilities(name: &str) -> construct_protocol::Capabilities { match name { "shell" | "claude" | "codex" | "opencode" | "antigravity" | "agy" | "grok" | "kimi" - | "hermes" | "pi" | "muse" | "smith" => construct_protocol::Capabilities { + | "hermes" | "pi" | "prime-agent" | "muse" | "smith" => construct_protocol::Capabilities { supports_pty: true, ..Default::default() }, @@ -10533,6 +10543,7 @@ mod tests { "kimi", "opencode", "pi", + "prime-agent", "muse", ] { s.harness = h.into(); @@ -11458,6 +11469,7 @@ mod tests { "kimi", "hermes", "pi", + "prime-agent", "muse", "smith", ] { diff --git a/crates/daemon/src/session/lifecycle.rs b/crates/daemon/src/session/lifecycle.rs index 433b4b19..39fbb17a 100644 --- a/crates/daemon/src/session/lifecycle.rs +++ b/crates/daemon/src/session/lifecycle.rs @@ -861,6 +861,10 @@ pub(super) fn native_fork_spec(harness: &str) -> Option<(&'static str, &'static "opencode" => Some(("opencode_session_id.txt", "CONSTRUCT_OPENCODE_FORK_FROM")), "grok" => Some(("grok_session_id.txt", "CONSTRUCT_GROK_FORK_FROM")), "pi" => Some(("pi_session_id.txt", "CONSTRUCT_PI_FORK_FROM")), + "prime-agent" => Some(( + "prime_agent_session_id.txt", + "CONSTRUCT_PRIME_AGENT_FORK_FROM", + )), _ => None, } } diff --git a/docs/adding-a-harness.md b/docs/adding-a-harness.md index aa924485..49fdfbd8 100644 --- a/docs/adding-a-harness.md +++ b/docs/adding-a-harness.md @@ -256,23 +256,23 @@ listed data surface, not by trusting this table — upstream CLIs grow surfaces between releases (codex's token splits and grok's context figures both existed for months before we consumed them). -| Capability | smith | claude | codex | opencode | kimi | hermes | pi | muse | grok | antigravity | shell | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Interactive / headless | both | both | both | interactive | interactive | both | both | both | both | both | PTY | -| Structured chat events | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | n/a | -| ModelChanged | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | when stated | ✓¹ | ✓ | n/a | -| EffortChanged | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | when stated | ✓ | ✓ | n/a | -| Token split (0103) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | gap² | ✓³ | n/a | -| Context gauge (0104) | ✓ | ✓ | ✓ | ✓ | ✓ | gap | ✓⁶ | used-only | ✓ | ✓³ | n/a | -| Context breakdown (0156) | ✓ | ✓ | ✓ | gap⁸ | ✓ | gap⁹ | ✓ | gap | ✓ | ✓ | n/a | -| USD cost | ✓ | headless only | — | gap⁴ | — | ✓ | ✓ | — | — | — | n/a | -| Native resume | ✓ (own state) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | fresh shell | -| Reset detection (0085) | n/a | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | n/a | -| Native fork | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | — | ✓ | — | n/a | -| Native subagent mirrors | n/a⁵ | ✓ | ✓ | — | — | gap | — | gap | ✓ | ✓ | n/a | -| MCP injection | native tools | ✓ | ✓ | ✓ | gap | gap | gap⁷ | gap | gap | gap | — | -| Approval translation | native | ✓ | — | — | — | — | — | — | ✓ | — | n/a | -| Usage probe (0086) | disabled | `/usage` | `/status` | — | — | `/usage` | — | — | `/usage show` | `/usage` | disabled | +| Capability | smith | claude | codex | opencode | kimi | hermes | pi | prime-agent | muse | grok | antigravity | shell | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Interactive / headless | both | both | both | interactive | interactive | both | both | both | both | both | both | PTY | +| Structured chat events | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | n/a | +| ModelChanged | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | when stated | ✓¹ | ✓ | n/a | +| EffortChanged | ✓ | — | ✓ | — | ✓ | ✓ | ✓ | ✓ | when stated | ✓ | ✓ | n/a | +| Token split (0103) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | gap² | ✓³ | n/a | +| Context gauge (0104) | ✓ | ✓ | ✓ | ✓ | ✓ | gap | ✓⁶ | ✓⁶ | used-only | ✓ | ✓³ | n/a | +| Context breakdown (0156) | ✓ | ✓ | ✓ | gap⁸ | ✓ | gap⁹ | ✓ | ✓ | gap | ✓ | ✓ | n/a | +| USD cost | ✓ | headless only | — | gap⁴ | — | ✓ | ✓ | ✓ | — | — | — | n/a | +| Native resume | ✓ (own state) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — | fresh shell | +| Reset detection (0085) | n/a | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | n/a | +| Native fork | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ | ✓ | — | ✓ | — | n/a | +| Native subagent mirrors | n/a⁵ | ✓ | ✓ | — | — | gap | — | — | gap | ✓ | ✓ | n/a | +| MCP injection | native tools | ✓ | ✓ | ✓ | gap | gap | gap⁷ | gap⁷ | gap | gap | gap | — | +| Approval translation | native | ✓ | — | — | — | — | — | — | — | ✓ | — | n/a | +| Usage probe (0086) | disabled | `/usage` | `/status` | — | — | `/usage` | — | — | — | `/usage show` | `/usage` | disabled | ¹ grok reports the model, but its upstream `model_id` was observed frozen per session — a mid-session switch may go unreported (investigation note in @@ -319,6 +319,7 @@ short real session: | hermes | `$HERMES_HOME/state.db` (`sessions`, `messages`) | source-tagged native id, model/reasoning config, full structured messages/tool calls, token split, estimated/actual USD cost | | antigravity | `~/.gemini/antigravity-cli/conversations/.db` (`gen_metadata` blobs), `brain//.system_generated/logs/transcript.jsonl` | per-generation usage varints at blob path `1.4` (`2` fresh input, `3` output incl. thoughts, `5` cached prefix, `9` thoughts), model display label (ASCII scrape); transcript carries conversation content (`USER_INPUT`/`PLANNER_RESPONSE`/tool records, `CHECKPOINT` = truncation summary) but no usage | | pi | `/pi-sessions/_.jsonl` (private store the adapter selects via `--session-dir`; pi's global default is `~/.pi/agent/sessions//`) | per-assistant-message `usage` (input/output/cacheRead/cacheWrite/reasoning + exact USD `cost.total`; `input` EXCLUDES cache reads), `model_change` / `thinking_level_change` records, `thinking`/`text`/`toolCall` content blocks, `toolResult` messages | +| prime-agent | `/prime-agent-sessions/.jsonl` (private store selected via `--session-dir`; the filename UUID differs from the native header UUID in 0.7.0) | Pi-compatible v3 message/event records, model and thinking changes, full token/cost usage, tool calls/results, and error diagnostics | | muse | `${XDG_DATA_HOME:-~/.local/share}/muse/sessions/YYYY/MM/DD//session.jsonl` | process-tagged root identity, committed assistant messages, per-call input/output/cache/reasoning usage, model/effort when stated, and nested subagent logs (excluded from parent binding) | ## 5. Verification checklist diff --git a/docs/architecture.md b/docs/architecture.md index 797e58ec..87c06e50 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ Everything ships as **one binary**: `construct` runs the TUI by default, the dae | `crates/adapter-antigravity` | — (lib, via `construct __adapter antigravity`) | Wraps the `agy` CLI | | `crates/adapter-kimi` | — (lib, via `construct __adapter kimi`) | Wraps the `kimi` CLI (Kimi Code) | | `crates/adapter-pi` | — (lib, via `construct __adapter pi`) | Wraps the `pi` CLI (pi coding agent) | +| `crates/adapter-prime-agent` | — (lib, via `construct __adapter prime-agent`) | Wraps Prime Agent through the shared Pi-compatible session/event core | | `crates/adapter-smith` | — (lib, via `construct __adapter smith`) | Built-in multi-provider agent (OpenAI / Anthropic / Gemini / Meta / Grok / Ollama) | | `crates/mcp` | — (lib, via `construct __mcp`) | MCP stdio server for agents running inside sessions | diff --git a/docs/configuration.md b/docs/configuration.md index b9c0bf6e..19cd1bde 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -108,6 +108,7 @@ env = { CONSTRUCT_CODEX_CMD = "exec codex" } | `kimi` | `CONSTRUCT_KIMI_CMD` | `CONSTRUCT_KIMI_BIN` | | `hermes` | `CONSTRUCT_HERMES_CMD` | `CONSTRUCT_HERMES_BIN` | | `pi` | `CONSTRUCT_PI_CMD` | `CONSTRUCT_PI_BIN` | +| `prime-agent` | `CONSTRUCT_PRIME_AGENT_CMD` | `CONSTRUCT_PRIME_AGENT_BIN` | | `muse` | `CONSTRUCT_MUSE_CMD` | `CONSTRUCT_MUSE_BIN` | | `shell` | `CONSTRUCT_SHELL_CMD` | `CONSTRUCT_SHELL_BIN` | @@ -118,6 +119,7 @@ binary overrides above take precedence over both. Hermes discovery checks `hermes` on `PATH`, then `~/.local/bin/hermes`; set `CONSTRUCT_HERMES_HOME` when its config and `state.db` live outside the default `~/.hermes`. Muse discovery checks `muse` on `PATH`, then `~/.local/bin/muse`. +Prime Agent discovery checks `prime-agent` on the daemon's `PATH`. ## TUI Theme diff --git a/docs/harnesses.md b/docs/harnesses.md index 33a5a13e..5d68543d 100644 --- a/docs/harnesses.md +++ b/docs/harnesses.md @@ -1,7 +1,7 @@ # Harnesses A **harness** is an agent or shell runner inside construct. Harnesses let you run -smith, Claude, Codex, OpenCode, Muse, Hermes, Antigravity, and local shells +smith, Claude, Codex, OpenCode, Prime Agent, Muse, Hermes, Antigravity, and local shells side by side while construct gives them one UI, history, widgets, control plane, and shared approval surface where supported. @@ -27,6 +27,7 @@ developer-facing integration checklist lives in | `kimi` | The Kimi Code CLI | You already use Kimi Code and want its native TUI inside the same construct session fleet. | | `hermes` | The Hermes Agent CLI | You use Hermes for coding and want its native UI, persisted sessions, and usage data in the same construct fleet. | | `pi` | The pi coding agent CLI | You already use pi and want it inside the same construct UI and session fleet. | +| `prime-agent` | Prime Agent | You use Prime Agent and want its native TUI, structured headless mode, and sessions in the same construct fleet. | | `muse` | Meta's Muse Code CLI | You use Muse and want its native TUI, headless mode, and resumable sessions in the same construct fleet. | Create a session with: @@ -39,6 +40,7 @@ construct new --prompt "implement the failing test" opencode construct new --prompt "implement the failing test" kimi construct new --prompt "implement the failing test" hermes construct new --prompt "implement the failing test" pi +construct new --prompt "implement the failing test" prime-agent construct new --prompt "implement the failing test" muse ``` @@ -107,7 +109,7 @@ See [smith built-in agent](smith.md) for details. ### CLI-backed harnesses `claude`, `codex`, `opencode`, `antigravity`, `grok`, `kimi`, `hermes`, `pi`, -and `muse` wrap existing CLIs. Use them when you want those tools exactly as +`prime-agent`, and `muse` wrap existing CLIs. Use them when you want those tools exactly as installed on your machine, but inside the same construct fleet. Because these depend on binaries and logins construct does not own, `construct @@ -161,7 +163,7 @@ construct new --no-tui claude construct new --mode headless --prompt "summarize the last run" smith ``` -`smith`, `claude`, `codex`, `antigravity`, `grok`, `hermes`, `pi`, and `muse` +`smith`, `claude`, `codex`, `antigravity`, `grok`, `hermes`, `pi`, `prime-agent`, and `muse` support both modes. `opencode` and `kimi` are interactive-only and always run their native TUIs. `shell` always owns a PTY (there is no structured "headless" shell), so it presents a terminal @@ -191,6 +193,7 @@ and ask you. | `kimi` | interactive only | Kimi Code's native TUI | No | Not translated | Not injected | | `hermes` | interactive, headless | Hermes' own defaults | No | Not translated | Not injected | | `pi` | interactive, headless | pi's own defaults | No | Not translated | Not injected | +| `prime-agent` | interactive, headless | Prime Agent's own defaults | No | Not translated | Not injected | | `muse` | interactive, headless | Muse's approval and sandbox policy | No | Not translated | Not injected | ### Reading the table diff --git a/docs/model-routing.md b/docs/model-routing.md index ee0713a4..3562cdd4 100644 --- a/docs/model-routing.md +++ b/docs/model-routing.md @@ -174,6 +174,7 @@ The table below summarizes router capability across all supported harness adapte | **Codex** | `codex` | ✅ Yes | ✅ Yes | `chatgpt.com`, `api.openai.com`
(`SSL_CERT_FILE`, replacing bundle) | Publishes session-local model catalog; entries appear in `/model` and native subagent scheduler as ` · `. | | **Grok** | `grok` | ✅ Yes | ❌ No | `cli-chat-proxy.grok.com`
(`SSL_CERT_FILE`, additive) | Probe-verified interception of native Grok CLI traffic when redirected. | | **Pi** | `pi` | ✅ Yes | ❌ No | `chatgpt.com`
(`NODE_EXTRA_CA_CERTS`, additive) | Probe-verified interception of native Pi CLI traffic when redirected. | +| **Prime Agent** | `prime-agent` | ❌ No | ❌ No | Pass-through only | Provider and endpoint vary with Prime Agent configuration; no fixed interception contract is declared. | | **Hermes** | `hermes` | ✅ Yes | ❌ No | `inference-api.nousresearch.com`
(`SSL_CERT_FILE`, replacing bundle) | Probe-verified interception of native Hermes CLI traffic when redirected. | | **OpenCode** | `opencode` | ❌ No | ❌ No | Pass-through only | Endpoint host varies per user configuration (no fixed intercept host). | | **Kimi** | `kimi` | ❌ No | ❌ No | None | No proxy routing probe or native picker catalog injection. | diff --git a/docs/unified-tool-layer.md b/docs/unified-tool-layer.md index 711caedb..33221b34 100644 --- a/docs/unified-tool-layer.md +++ b/docs/unified-tool-layer.md @@ -43,7 +43,7 @@ sessions; it should be able to use `agentd_list_sessions` without running the | OpenCode | Enabled by default. | Adapter merges a `construct` entry into the config content it hands the session, pointing at `construct __mcp`. | | Antigravity | Not injected yet. | Receives `CONSTRUCT_SESSION_ID`; browser/tools can be injected once `agy` exposes an MCP config flag. | | Hermes | Not injected yet. | Hermes supports MCP in persistent config, but the adapter does not mutate user configuration; injection waits for a per-invocation config surface. | -| Grok, Kimi, pi | Not injected yet. | These adapters receive `CONSTRUCT_SESSION_ID` but do not inject an MCP entry today. | +| Grok, Kimi, pi, Prime Agent | Not injected yet. | These adapters receive `CONSTRUCT_SESSION_ID` but do not inject an MCP entry today. | ## Fleet-control tools diff --git a/specs/0195-prime-agent-native-session-contract.md b/specs/0195-prime-agent-native-session-contract.md new file mode 100644 index 00000000..94e2d7f3 --- /dev/null +++ b/specs/0195-prime-agent-native-session-contract.md @@ -0,0 +1,57 @@ +# 0195-prime-agent-native-session-contract + +Status: accepted +Date: 2026-08-05 +Area: harness +Scope: How Construct runs, identifies, resumes, resets, and forks Prime Agent sessions. + +## Decision + +Prime Agent is exposed under the canonical Construct harness id +`prime-agent`, matching its installed executable and public product name. +Space-constrained session labels may render the compact name `prime` without +changing the persisted harness id or command syntax. + +The adapter uses Prime Agent's public interactive TUI and structured JSON mode. +It selects a private session directory inside each Construct session, captures +the native UUID from the JSONL session header, and persists that UUID in a +Prime-specific sidecar. Resume and native fork resolve the authoritative header +UUID back to its JSONL path; they must not assume the filename UUID is the same. + +Prime Agent and Pi may share translation/runtime code only where their public +schemas remain compatible. Their command overrides, mode overrides, session +directories, native-id sidecars, fork variables, binaries, and resume flags +remain separate flavor inputs. Neither harness may read or adopt the other's +private sessions. + +## Reason + +Prime Agent 0.7.0 intentionally retains Pi's version-3 JSONL messages and JSON +event stream, including model/thinking changes, tool calls/results, token usage, +and exact cost. Sharing that parser avoids two copies drifting apart. + +Prime Agent differs operationally: it launches as `prime-agent`, resumes with +`--resume`, uses a different global configuration home, and writes session +files whose filename UUID differs from the native UUID in the header. Treating +it as merely another Pi binary would break restart/fork and risk state crossing +between the two installed harnesses. + +## Consequences + +- `construct new prime-agent` is the stable creation command. +- Interactive, headless, restart, reset lineage, and same-harness native fork + use Prime Agent's own persisted conversation. +- Construct-owned Prime Agent state is deleted or archived with its Construct + session and does not appear in the CLI's ordinary global session picker. +- A future Prime Agent schema divergence must be handled behind its flavor + boundary or split into its own parser; compatibility must not be assumed from + ancestry alone. +- Provider traffic remains pass-through until a stable endpoint/dialect and + trust-channel contract is probe-verified for Prime Agent's configurable + providers. + +## Non-Goals + +- Injecting Construct MCP tools into Prime Agent's extension system. +- Translating Prime Agent's native tool policy into Construct approval modes. +- Treating `prime` as a second persisted harness id or duplicate picker entry.