diff --git a/.claude/skills/update-acp/SKILL.md b/.claude/skills/update-acp/SKILL.md new file mode 100644 index 0000000000..cdb10c0525 --- /dev/null +++ b/.claude/skills/update-acp/SKILL.md @@ -0,0 +1,15 @@ +--- +name: update-acp +description: Audit or update AgentOS against the latest stable ACP v1 schema, including its manual sidecar client, public types, and SDK-backed adapters. +--- + +# Update ACP + +1. Read `ACP_IMPLEMENTED_SCHEMA_VERSION` in `crates/agentos-sidecar/src/acp_extension.rs`. +2. Find the latest `schema-v1.*` in [ACP releases](https://github.com/agentclientprotocol/agent-client-protocol/releases) and inspect its `schema.json` plus [`schema/v1/CHANGELOG.md`](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/schema/v1/CHANGELOG.md). Separately check `npm view @agentclientprotocol/sdk version`, its published types, and its [changelog](https://github.com/agentclientprotocol/typescript-sdk/blob/main/CHANGELOG.md). Ignore unstable v2 and keep wire `protocolVersion` separate. +3. Update together: + - Adapters/dependencies/tests: `registry/agent/` and `pnpm-lock.yaml`. + - Public types/client/tests: `packages/core/src/agent-session-types.ts`, `packages/core/src/agent-os.ts`, and `packages/core/tests/`. + - Manual JSON-RPC/compatibility/tests: `crates/agentos-sidecar/src/acp_extension.rs`, `crates/agentos-sidecar/tests/`, and `crates/agentos-sidecar-core/src/engine.rs`. + - Internal generated protocol only if its AgentOS extension contract changes: `crates/agentos-protocol/` and `packages/core/src/sidecar/`. +4. Preserve unknown additive JSON fields, run scoped adapter/core/sidecar tests plus type checks, and update `ACP_IMPLEMENTED_SCHEMA_VERSION` only after the stable schema is fully audited and implemented. diff --git a/CLAUDE.md b/CLAUDE.md index b0298183e0..128ac150d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,29 @@ the guest — over inventing a softer fallback that hides the failure. reason. Fast tests where the configured safeguard fires should stay in the default suite. +## Gigacode Performance Investigations + +- For cold-start latency, run `gigacode` directly and use the plain + `[gigacode]` phase lines and durations mirrored from `daemon.log` while the + client waits for provider bootstrap. These startup lines are intentionally + human-readable and separate from Pino session logs. +- Investigate Gigacode latency from its per-session Pino JSONL logs, not by + inferring timing from the OpenCode screen or the aggregate `daemon.log`. +- Logs live at + `~/.local/state/gigacode/session-logs/.jsonl` by default, + or under `$GIGACODE_STATE_DIR/session-logs/` when that override is set. +- Reproduce one turn in a fresh session, identify the newest log with + `ls -lt ~/.local/state/gigacode/session-logs`, then inspect its ordered + `event` and `durationMs` fields with `jq`. +- Compare `rivet.actor.resolved`, `agentos.session.created`, + `agentos.prompt.completed`, `prompt.completed`, `session.idle`, and + `agentos.connection.disposed` before optimizing. The actor event measures + resolution of the shared per-cwd workspace actor; the ACP event measures the + distinct harness session created inside it. +- Preserve the raw JSONL file when reporting a regression. Use + `GIGACODE_LOG_LEVEL` to change the Pino level; performance phase records are + emitted at `info`. + ## Version Control - Commit and PR titles are plain conventional commits with no coding-agent diff --git a/biome.json b/biome.json index 82904fdfd4..fb6dbc14df 100644 --- a/biome.json +++ b/biome.json @@ -4,6 +4,7 @@ "includes": [ "packages/**/*.ts", "examples/**/*.ts", + "experiments/**/*.ts", "!packages/core/src/sidecar/generated-protocol.ts", "!_secure-exec-sibling", "!/**/node_modules" diff --git a/crates/agentos-actor-plugin/src/actions/contract_surface.rs b/crates/agentos-actor-plugin/src/actions/contract_surface.rs index 1298f929e1..9536c04ef9 100644 --- a/crates/agentos-actor-plugin/src/actions/contract_surface.rs +++ b/crates/agentos-actor-plugin/src/actions/contract_surface.rs @@ -202,11 +202,34 @@ pub const ACTION_CONTRACTS: &[ActionContract] = &[ ts_signature: "createSession: (c: Ctx, agentType: string, options?: CreateSessionOptions) => Promise;", }, + ActionContract { + name: "probeAgentConfig", + reply_shape: ReplyShape::Array, + ts_signature: + "probeAgentConfig: (c: Ctx, agentType: string, options?: CreateSessionOptions) => Promise;", + }, ActionContract { name: "sendPrompt", reply_shape: ReplyShape::Object(&["response", "text"]), ts_signature: "sendPrompt: (c: Ctx, sessionId: string, text: string) => Promise;", }, + ActionContract { + name: "sendPromptAsync", + reply_shape: ReplyShape::Unit, + ts_signature: "sendPromptAsync: ( c: Ctx, sessionId: string, text: string, requestId: string, ) => Promise;", + }, + ActionContract { + name: "setSessionModel", + reply_shape: ReplyShape::Object(&["id", "jsonrpc", "result"]), + ts_signature: + "setSessionModel: (c: Ctx, sessionId: string, model: string) => Promise;", + }, + ActionContract { + name: "cancelSession", + reply_shape: ReplyShape::Object(&["id", "jsonrpc", "result"]), + ts_signature: + "cancelSession: (c: Ctx, sessionId: string) => Promise;", + }, ActionContract { name: "closeSession", reply_shape: ReplyShape::Unit, @@ -259,6 +282,11 @@ pub const EVENT_CONTRACTS: &[EventContract] = &[ payload_shape: ReplyShape::Object(&["event", "sessionId"]), ts_signature: "sessionEvent: SessionEventPayload;", }, + EventContract { + name: "promptResult", + payload_shape: ReplyShape::Object(&["error", "requestId", "result"]), + ts_signature: "promptResult: PromptResultEventPayload;", + }, EventContract { name: "permissionRequest", payload_shape: ReplyShape::Object(&["request", "sessionId"]), @@ -366,9 +394,11 @@ const TYPE_IMPORTS: &[TsImport] = &[ module: "@rivet-dev/agentos-core", names: &[ "ExecResult", + "JsonRpcResponse", "PermissionReply", "ProcessInfo", "ProcessTreeNode", + "SessionConfigOption", "SpawnedProcessInfo", "VirtualStat", ], diff --git a/crates/agentos-actor-plugin/src/actions/mod.rs b/crates/agentos-actor-plugin/src/actions/mod.rs index 8b4f9e533b..44898142bb 100644 --- a/crates/agentos-actor-plugin/src/actions/mod.rs +++ b/crates/agentos-actor-plugin/src/actions/mod.rs @@ -48,6 +48,10 @@ pub struct Vars { /// list exists so VM teardown aborts any still-live pumps. Bounded by the /// client's shell registries, not here. pub shell_tasks: Vec>, + /// Long-running prompt replies keyed by the client-facing session id. Prompt + /// I/O must not occupy the serial action worker, otherwise `cancelSession` + /// is queued behind the prompt it needs to interrupt. + pub prompt_tasks: HashMap>, /// One cron event pump per VM lifetime. It fans `AgentOs::cron_events()` to /// actor clients as `cronEvent` broadcasts. pub cron_task: Option>, @@ -75,6 +79,9 @@ impl Vars { for task in self.shell_tasks.drain(..) { task.abort(); } + for (_, task) in self.prompt_tasks.drain() { + task.abort(); + } if let Some(task) = self.cron_task.take() { task.abort(); } @@ -203,7 +210,8 @@ pub mod contract { use agentos_client::{ AgentExitEvent, CronEvent, CronOverlap, DirEntry, DirEntryType, JsonRpcResponse, - ProcessInfo, ProcessStatus, ProcessTreeNode, SpawnHandle, SpawnedProcessInfo, VirtualStat, + ProcessInfo, ProcessStatus, ProcessTreeNode, SessionConfigOption, SpawnHandle, + SpawnedProcessInfo, VirtualStat, }; use anyhow::{anyhow, Result}; use ciborium::Value as CborValue; @@ -270,6 +278,7 @@ pub mod contract { "closeShell" | "waitShell" | "cancelCronJob" + | "cancelSession" | "closeSession" | "getSessionEvents" | "expireSignedPreviewUrl" => super::decode_as::<(String,)>(args).map(|_| ()), @@ -277,12 +286,15 @@ pub mod contract { .map(|_| ()) .or_else(|_| super::decode_as::<(u16, String)>(args).map(|_| ())), "scheduleCron" => super::decode_as::<(cron::CronJobOptionsDto,)>(args).map(|_| ()), - "createSession" => { + "createSession" | "probeAgentConfig" => { super::decode_as::<(String, Option)>(args) .map(|_| ()) .or_else(|_| super::decode_as::<(String,)>(args).map(|_| ())) } - "sendPrompt" => super::decode_as::<(String, String)>(args).map(|_| ()), + "sendPrompt" | "setSessionModel" => { + super::decode_as::<(String, String)>(args).map(|_| ()) + } + "sendPromptAsync" => super::decode_as::<(String, String, String)>(args).map(|_| ()), "respondPermission" => super::decode_as::<(String, String, String)>(args).map(|_| ()), "createSignedPreviewUrl" => super::decode_as::<(u16, u64)>(args).map(|_| ()), other => Err(anyhow!("unknown action {other}")), @@ -301,6 +313,7 @@ pub mod contract { | "closeShell" | "waitShell" | "cancelCronJob" + | "cancelSession" | "closeSession" | "getSessionEvents" | "expireSignedPreviewUrl" => { @@ -348,11 +361,13 @@ pub mod contract { "scheduleCron" => vec![ json!([{ "id": "job-1", "schedule": "* * * * *", "action": { "type": "exec", "command": "echo", "args": ["hi"] }, "overlap": "skip" }]), ], - "createSession" => vec![ + "createSession" | "probeAgentConfig" => vec![ json!(["default"]), json!(["default", { "cwd": "/workspace", "env": { "A": "B" }, "skipOsInstructions": true, "additionalInstructions": "test" }]), ], "sendPrompt" => vec![json!(["session-1", "hello"])], + "sendPromptAsync" => vec![json!(["session-1", "hello", "request-1"])], + "setSessionModel" => vec![json!(["session-1", "provider/model-1"])], "respondPermission" => vec![json!(["session-1", "permission-1", "once"])], "createSignedPreviewUrl" => vec![json!([3000, 60])], other => return Err(anyhow!("unknown action {other}")), @@ -375,6 +390,7 @@ pub mod contract { | "closeShell" | "waitShell" | "cancelCronJob" + | "cancelSession" | "closeSession" | "getSessionEvents" | "expireSignedPreviewUrl" => { @@ -430,11 +446,21 @@ pub mod contract { "cron job requires a schedule/action shape", json!([{ "id": "job-1" }]), )], - "createSession" => vec![("agent type must be a string", json!([42]))], + "createSession" | "probeAgentConfig" => { + vec![("agent type must be a string", json!([42]))] + } "sendPrompt" => vec![( "prompt must be a string", json!(["session-1", { "text": "hello" }]), )], + "sendPromptAsync" => vec![( + "request id must be a string", + json!(["session-1", "hello", 42]), + )], + "setSessionModel" => vec![( + "model must be a string", + json!(["session-1", { "model": "provider/model-1" }]), + )], "respondPermission" => vec![( "permission response must be a string", json!(["session-1", "permission-1", 42]), @@ -465,6 +491,7 @@ pub mod contract { | "resizeShell" | "closeShell" | "cancelCronJob" + | "sendPromptAsync" | "closeSession" | "respondPermission" | "expireSignedPreviewUrl" => encode(&()), @@ -541,6 +568,19 @@ pub mod contract { next_run: Some(2.0), }]), "createSession" => encode_create_session_reply("session-1"), + "probeAgentConfig" => encode(&vec![SessionConfigOption { + id: "model".to_owned(), + name: Some("Model".to_owned()), + kind: Some("select".to_owned()), + category: Some("model".to_owned()), + label: Some("Model".to_owned()), + description: None, + current_value: Some(json!("default")), + options: Some(Vec::new()), + allowed_values: None, + read_only: None, + extra: BTreeMap::new(), + }]), "sendPrompt" => encode(&session::PromptResultDto { response: JsonRpcResponse { jsonrpc: "2.0".to_owned(), @@ -550,6 +590,18 @@ pub mod contract { }, text: "hello".to_owned(), }), + "setSessionModel" => encode(&JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: None, + result: Some(json!({ "configOptions": [] })), + error: None, + }), + "cancelSession" => encode(&JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: None, + result: Some(json!({ "cancelled": true })), + error: None, + }), "listPersistedSessions" => encode(&vec![session::PersistedSessionDto { session_id: "session-1".to_owned(), agent_type: "default".to_owned(), @@ -590,6 +642,18 @@ pub mod contract { "session-1", &json!({ "jsonrpc": "2.0", "method": "session/update", "params": {} }), ), + "promptResult" => { + let result = session::PromptResultDto { + response: JsonRpcResponse { + jsonrpc: "2.0".to_owned(), + id: None, + result: Some(json!({ "stopReason": "end_turn" })), + error: None, + }, + text: "hello".to_owned(), + }; + session::encode_prompt_result_event("request-1", Some(&result), None) + } "permissionRequest" => session::encode_permission_request_event( "session-1", "permission-1", @@ -941,15 +1005,144 @@ pub(crate) async fn dispatch( Err(error) => reply_err(host, token, error), } } + "probeAgentConfig" => { + let decoded = decode_as::<(String, Option)>(args) + .map(|(agent_type, options)| (agent_type, options.unwrap_or_default())) + .or_else(|_| { + decode_as::<(String,)>(args).map(|(agent_type,)| { + (agent_type, session::CreateSessionOptionsDto::default()) + }) + }); + match decoded { + Ok((agent_type, options)) => { + match session::probe_agent_config(vm, &agent_type, options).await { + Ok(config_options) => reply_ok(host, token, &config_options), + Err(error) => { + tracing::error!(?error, agent_type, "probe_agent_config failed"); + reply_err(host, token, error) + } + } + } + Err(error) => reply_err(host, token, error), + } + } "sendPrompt" => match decode_as::<(String, String)>(args) { Ok((session_id, text)) => { - match session::send_prompt(host, vm, vars, &session_id, &text).await { - Ok(result) => reply_ok(host, token, &result), + vars.prompt_tasks.retain(|_, task| !task.is_finished()); + if vars.prompt_tasks.contains_key(&session_id) { + reply_err( + host, + token, + anyhow::anyhow!( + "session {session_id} already has an in-flight prompt; wait for it to finish or cancel it" + ), + ); + } else if vars.prompt_tasks.len() >= 128 { + reply_err( + host, + token, + anyhow::anyhow!( + "actor reached the 128 in-flight prompt limit; wait for work to finish or create another actor" + ), + ); + } else { + match session::prepare_prompt(host, vm, vars, &session_id, &text).await { + Ok(live_session_id) => { + let host = host.clone(); + let vm = vm.clone(); + let task = tokio::spawn(async move { + match session::send_prepared_prompt(&vm, &live_session_id, &text) + .await + { + Ok(result) => reply_ok(&host, token, &result), + Err(error) => reply_err(&host, token, error), + } + }); + vars.prompt_tasks.insert(session_id, task); + } + Err(error) => reply_err(host, token, error), + } + } + } + Err(error) => reply_err(host, token, error), + }, + "sendPromptAsync" => match decode_as::<(String, String, String)>(args) { + Ok((session_id, text, request_id)) => { + vars.prompt_tasks.retain(|_, task| !task.is_finished()); + if vars.prompt_tasks.contains_key(&session_id) { + reply_err( + host, + token, + anyhow::anyhow!( + "session {session_id} already has an in-flight prompt; wait for it to finish or cancel it" + ), + ); + } else if vars.prompt_tasks.len() >= 128 { + reply_err( + host, + token, + anyhow::anyhow!( + "actor reached the 128 in-flight prompt limit; wait for work to finish or create another actor" + ), + ); + } else { + match session::prepare_prompt(host, vm, vars, &session_id, &text).await { + Ok(live_session_id) => { + let task_host = host.clone(); + let vm = vm.clone(); + let task = tokio::spawn(async move { + let outcome = + session::send_prepared_prompt(&vm, &live_session_id, &text) + .await; + let encoded = match &outcome { + Ok(result) => session::encode_prompt_result_event( + &request_id, + Some(result), + None, + ), + Err(error) => session::encode_prompt_result_event( + &request_id, + None, + Some(&format!("{error:#}")), + ), + }; + match encoded { + Ok(bytes) => { + let _ = + task_host.broadcast(b"promptResult".to_vec(), bytes); + } + Err(error) => tracing::warn!( + ?error, + request_id, + "failed to encode prompt result broadcast" + ), + } + }); + vars.prompt_tasks.insert(session_id, task); + reply_ok(host, token, &()); + } + Err(error) => reply_err(host, token, error), + } + } + } + Err(error) => reply_err(host, token, error), + }, + "setSessionModel" => match decode_as::<(String, String)>(args) { + Ok((session_id, model)) => { + match session::set_session_model(vm, vars, &session_id, &model).await { + Ok(response) => reply_ok(host, token, &response), Err(error) => reply_err(host, token, error), } } Err(error) => reply_err(host, token, error), }, + "cancelSession" => match decode_as::<(String,)>(args) { + Ok((session_id,)) => match session::cancel_session(vm, vars, &session_id).await { + Ok(response) => reply_ok(host, token, &response), + Err(error) => reply_err(host, token, error), + }, + Err(error) => reply_err(host, token, error), + }, "closeSession" => match decode_as::<(String,)>(args) { Ok((session_id,)) => match session::close_session(host, vm, vars, &session_id).await { Ok(()) => reply_ok(host, token, &()), diff --git a/crates/agentos-actor-plugin/src/actions/session.rs b/crates/agentos-actor-plugin/src/actions/session.rs index 7e4b8c0b29..866b6b12d1 100644 --- a/crates/agentos-actor-plugin/src/actions/session.rs +++ b/crates/agentos-actor-plugin/src/actions/session.rs @@ -13,6 +13,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::host_ctx::HostCtx; use agentos_client::{ AgentExitEvent, AgentOs, CreateSessionOptions, JsonRpcResponse, PermissionReply, + ResumeSessionOptions, }; use anyhow::{anyhow, Result}; use futures::StreamExt; @@ -25,7 +26,7 @@ use crate::persistence::{ }; /// Options object for `createSession(agentType, options?)`. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionOptionsDto { #[serde(default)] @@ -46,6 +47,26 @@ pub struct PromptResultDto { pub text: String, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct PromptResultEventDto<'a> { + request_id: &'a str, + result: Option<&'a PromptResultDto>, + error: Option<&'a str>, +} + +pub(crate) fn encode_prompt_result_event( + request_id: &str, + result: Option<&PromptResultDto>, + error: Option<&str>, +) -> Result> { + super::encode_event_arg(&PromptResultEventDto { + request_id, + result, + error, + }) +} + /// One row of `listPersistedSessions`. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] @@ -71,6 +92,28 @@ pub struct PersistedSessionEventDto { pub created_at: f64, } +/// Start an ACP session only long enough to inspect its configuration options. +/// Unlike `create_session`, this does not persist metadata or expose the probe +/// through `listPersistedSessions`. +pub async fn probe_agent_config( + vm: &AgentOs, + agent_type: &str, + dto: CreateSessionOptionsDto, +) -> Result> { + let options = CreateSessionOptions { + cwd: dto.cwd, + env: dto.env, + skip_os_instructions: dto.skip_os_instructions, + additional_instructions: dto.additional_instructions, + ..CreateSessionOptions::default() + }; + let session_id = vm.create_session(agent_type, options).await?.session_id; + let config_options = vm.get_session_config_options(&session_id); + vm.close_session(&session_id) + .map_err(|error| anyhow!(error))?; + Ok(config_options) +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -370,6 +413,11 @@ pub async fn create_session( additional_instructions: dto.additional_instructions, ..CreateSessionOptions::default() }; + let resume_cwd = options + .cwd + .clone() + .unwrap_or_else(|| "/workspace".to_owned()); + let resume_env = serde_json::to_string(&options.env)?; let session_id = vm.create_session(agent_type, options).await?.session_id; // Persist session metadata so the set of sessions survives sleep/wake. Capture the REAL // agent capabilities + info (not a `"{}"` placeholder) so the resume path can capability-gate @@ -396,6 +444,13 @@ pub async fn create_session( ], ) .await?; + run_stmt( + ctx, + "INSERT OR REPLACE INTO agent_os_session_launch (session_id, cwd, env) \ + VALUES (?, ?, ?)", + &[json!(session_id), json!(resume_cwd), json!(resume_env)], + ) + .await?; // At create time `external == live`; capture every `session/update` for this // session under the external id (spec §3/§5), and start fanning the guest's // permission requests out to connected clients. The permission pump must be @@ -437,13 +492,13 @@ pub(crate) fn encode_agent_crashed_event( encode_agent_crashed_event_value(external_session_id, event) } -pub async fn send_prompt( +pub async fn prepare_prompt( ctx: &HostCtx, vm: &AgentOs, vars: &mut Vars, session_id: &str, text: &str, -) -> Result { +) -> Result { // Lazy-resume trigger (spec §8): a prompt for a session that is persisted in // `agent_os_sessions` but absent from `Vars.live_sessions` means the VM was // recreated since the session was last live — resume it before forwarding. @@ -471,8 +526,14 @@ pub async fn send_prompt( tracing::warn!(?error, session_id, "failed to persist user_prompt event"); } - // Forward to the live id (== external for native/not-yet-resumed sessions). - let live_session_id = vars.live_id(session_id).to_owned(); + Ok(vars.live_id(session_id).to_owned()) +} + +pub async fn send_prepared_prompt( + vm: &AgentOs, + live_session_id: &str, + text: &str, +) -> Result { let result = vm.prompt(&live_session_id, text).await?; Ok(PromptResultDto { response: result.response, @@ -480,6 +541,39 @@ pub async fn send_prompt( }) } +pub async fn set_session_model( + vm: &AgentOs, + vars: &Vars, + session_id: &str, + model: &str, +) -> Result { + let live_session_id = vars.live_id(session_id); + vm.set_session_model(live_session_id, model) + .await + .map_err(|error| anyhow!(error)) +} + +pub async fn cancel_session( + vm: &AgentOs, + vars: &mut Vars, + session_id: &str, +) -> Result { + let live_session_id = vars.live_id(session_id).to_owned(); + // Drop the actor-side prompt future before sending the real cancel. If the + // prompt task remains alive, AgentOs::cancel_session resolves that local + // prompt and sends session/cancel in a detached fallback task. The actor + // action then observes only the synthetic result while the real cancel can + // remain queued behind an unowned prompt response. Aborting first leaves no + // local prompt resolver, so this call owns the single real ACP cancellation + // request through completion. + if let Some(prompt_task) = vars.prompt_tasks.remove(session_id) { + prompt_task.abort(); + } + vm.cancel_session(&live_session_id) + .await + .map_err(|error| anyhow!(error)) +} + pub async fn close_session( ctx: &HostCtx, vm: &AgentOs, @@ -510,6 +604,12 @@ pub async fn close_session( &[json!(session_id)], ) .await?; + run_stmt( + ctx, + "DELETE FROM agent_os_session_launch WHERE session_id = ?", + &[json!(session_id)], + ) + .await?; run_stmt( ctx, "DELETE FROM agent_os_sessions WHERE session_id = ?", @@ -614,15 +714,18 @@ async fn session_is_persisted(ctx: &HostCtx, external_session_id: &str) -> Resul Ok(!rows.is_empty()) } -/// Read the persisted `(agent_type, capabilities)` for a session from the -/// registry, returning the parsed capabilities JSON (`{}` if absent/unparsable). +/// Read the persisted agent and launch configuration for a session. Sessions +/// created before launch persistence was added fall back to the client defaults. async fn read_session_registry( ctx: &HostCtx, external_session_id: &str, -) -> Result<(String, JsonValue)> { +) -> Result<(String, JsonValue, Option, BTreeMap)> { let rows = query_rows( ctx, - "SELECT agent_type, capabilities FROM agent_os_sessions WHERE session_id = ? LIMIT 1", + "SELECT s.agent_type, s.capabilities, l.cwd, l.env \ + FROM agent_os_sessions s \ + LEFT JOIN agent_os_session_launch l ON l.session_id = s.session_id \ + WHERE s.session_id = ? LIMIT 1", &[json!(external_session_id)], ) .await?; @@ -640,7 +743,16 @@ async fn read_session_registry( .and_then(|v| v.as_str()) .and_then(|raw| serde_json::from_str::(raw).ok()) .unwrap_or_else(|| json!({})); - Ok((agent_type, capabilities)) + let cwd = row + .get("cwd") + .and_then(|value| value.as_str()) + .map(str::to_owned); + let env = row + .get("env") + .and_then(|value| value.as_str()) + .and_then(|raw| serde_json::from_str::>(raw).ok()) + .unwrap_or_default(); + Ok((agent_type, capabilities, cwd, env)) } /// Resume a persisted-but-not-live session in the freshly recreated VM @@ -661,39 +773,43 @@ pub async fn resume_session( vars: &mut Vars, external_session_id: &str, ) -> Result<()> { - let (agent_type, _capabilities) = read_session_registry(ctx, external_session_id).await?; + let (agent_type, _capabilities, cwd, env) = + read_session_registry(ctx, external_session_id).await?; // Disposable on-demand render of the canonical event log; handed to the // sidecar so a fallback agent can read prior context with its file tools. let transcript_path = reconstruct_transcript_to_file(ctx, external_session_id).await?; // Call the sidecar resume orchestration through the client. The contract is - // `AcpResumeSessionRequest { sessionId, agentType, transcriptPath?, cwd, env }` + // `AcpResumeSessionRequest { sessionId, agentType, transcriptPath?, cwd, + // additionalDirectories, mcpServers, env }` // (spec §6); it returns the live session id (== external for the native tier, // a new id for the `session/new` fallback). The actor records the remap. // - // TODO(session-resume): the `agentos_client::AgentOs::resume_session` method - // is being implemented in parallel against the same spec §6 contract and is - // not present in the pinned client yet. Once it lands, replace the error - // below with the real call + remap: - // - // let live_session_id = vm - // .resume_session(external_session_id, &agent_type, Some(&transcript_path)) - // .await? - // .session_id; - // // The remap lives SOLELY in the actor (spec §3): record external -> live - // // and capture the live session's events under the stable external id. - // vars.live_sessions - // .insert(external_session_id.to_owned(), live_session_id.clone()); - // spawn_event_capture(ctx, vm, vars, external_session_id, &live_session_id); - // return Ok(()); - let _ = (&agent_type, vm, vars); - Err(anyhow!( - "resume_session: client `resume_session` not yet available \ - (transcript reconstructed at {transcript_path}); blocked on the \ - parallel sidecar/client implementation of the spec §6 \ - AcpResumeSessionRequest contract" - )) + let resumed = vm + .resume_session( + external_session_id, + &agent_type, + ResumeSessionOptions { + transcript_path: Some(transcript_path), + cwd, + additional_directories: Vec::new(), + mcp_servers: Vec::new(), + env, + }, + ) + .await?; + let live_session_id = resumed.session_id; + + // The remap lives solely in the actor (spec §3). All live event sources are + // reattached to the new live id while retaining the stable external id seen + // by RivetKit clients and durable storage. + vars.live_sessions + .insert(external_session_id.to_owned(), live_session_id.clone()); + spawn_event_capture(ctx, vm, vars, external_session_id, &live_session_id); + spawn_permission_pump(ctx, vm, vars, external_session_id, &live_session_id); + spawn_exit_capture(ctx, vm, vars, external_session_id, &live_session_id); + Ok(()) } #[cfg(test)] diff --git a/crates/agentos-actor-plugin/src/config.rs b/crates/agentos-actor-plugin/src/config.rs index be2d19398f..4ec64ace0e 100644 --- a/crates/agentos-actor-plugin/src/config.rs +++ b/crates/agentos-actor-plugin/src/config.rs @@ -58,6 +58,36 @@ pub(crate) struct AgentOsConfigJson { sidecar: Option, } +/// Sparse options supplied as the actor creation input. These are deliberately +/// limited to VM options that are safe to vary per actor. Software/package and +/// sidecar-pool selection remain factory-wide so one native factory cannot be +/// tricked into loading arbitrary host binaries. +#[derive(serde::Deserialize, Default)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentOsCreateOptionsJson { + #[serde(default)] + additional_instructions: Option, + #[serde(default)] + loopback_exempt_ports: Option>, + #[serde(default)] + allowed_node_builtins: Option>, + #[serde(default)] + permissions: Option, + #[serde(default)] + mounts: Option>, + #[serde(default)] + root_filesystem: Option, + #[serde(default)] + limits: Option, +} + +#[derive(serde::Deserialize, Default)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct AgentOsActorCreateInputJson { + #[serde(default)] + options: AgentOsCreateOptionsJson, +} + #[derive(serde::Deserialize, Clone)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct NativeMountJson { @@ -123,6 +153,45 @@ impl AgentOsConfigJson { serde_json::from_str(config_json).context("agent-os config JSON parse error") } + /// Apply the actor's durable `createWithInput` options to the factory-wide + /// defaults. Mounts and loopback exemptions extend the shared defaults; + /// scalar/structured fields override them for this actor only. + pub(crate) fn with_actor_input(&self, input: Option<&[u8]>) -> Result { + let Some(input) = input else { + return Ok(self.clone()); + }; + let create: AgentOsActorCreateInputJson = + ciborium::from_reader(input).context("decode agent-os actor creation input")?; + let options = create.options; + let mut merged = self.clone(); + if let Some(value) = options.additional_instructions { + merged.additional_instructions = Some(value); + } + if let Some(values) = options.loopback_exempt_ports { + for value in values { + if !merged.loopback_exempt_ports.contains(&value) { + merged.loopback_exempt_ports.push(value); + } + } + } + if let Some(value) = options.allowed_node_builtins { + merged.allowed_node_builtins = Some(value); + } + if let Some(value) = options.permissions { + merged.permissions = Some(value); + } + if let Some(values) = options.mounts { + merged.mounts.extend(values); + } + if let Some(value) = options.root_filesystem { + merged.root_filesystem = Some(value); + } + if let Some(value) = options.limits { + merged.limits = Some(value); + } + Ok(merged) + } + /// Build a fresh [`AgentOsConfig`] (non-`Clone`, so rebuilt per bring-up). /// /// `fallback_pool` is the per-plugin-runtime sidecar pool used when the @@ -271,3 +340,60 @@ fn read_package_dir_software_info(dir: &str) -> Option { commands: Vec::new(), }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn cbor(value: serde_json::Value) -> Vec { + let mut bytes = Vec::new(); + ciborium::into_writer(&value, &mut bytes).expect("encode actor input"); + bytes + } + + #[test] + fn actor_input_extends_factory_mounts_and_overrides_instructions() { + let base = AgentOsConfigJson::parse( + r#"{ + "additionalInstructions":"shared", + "loopbackExemptPorts":[3000], + "mounts":[{ + "path":"/host", + "plugin":{"id":"host_dir","config":{"hostPath":"/"}}, + "readOnly":false + }] + }"#, + ) + .expect("parse base config"); + let input = cbor(serde_json::json!({ + "options": { + "additionalInstructions": "workspace", + "loopbackExemptPorts": [3000, 4000], + "mounts": [{ + "path": "/work", + "plugin": {"id": "host_dir", "config": {"hostPath": "/tmp/work"}}, + "readOnly": false + }] + } + })); + let merged = base + .with_actor_input(Some(&input)) + .expect("merge actor input"); + assert_eq!(merged.additional_instructions.as_deref(), Some("workspace")); + assert_eq!(merged.loopback_exempt_ports, vec![3000, 4000]); + assert_eq!(merged.mounts.len(), 2); + assert_eq!(merged.mounts[1].path, "/work"); + } + + #[test] + fn actor_input_rejects_unknown_options() { + let input = cbor(serde_json::json!({"options": {"software": []}})); + let error = match AgentOsConfigJson::default().with_actor_input(Some(&input)) { + Ok(_) => panic!("unknown dynamic option must fail"), + Err(error) => error, + }; + assert!(error + .to_string() + .contains("decode agent-os actor creation input")); + } +} diff --git a/crates/agentos-actor-plugin/src/host_ctx.rs b/crates/agentos-actor-plugin/src/host_ctx.rs index 314b12f0e6..a5667cea0d 100644 --- a/crates/agentos-actor-plugin/src/host_ctx.rs +++ b/crates/agentos-actor-plugin/src/host_ctx.rs @@ -150,6 +150,19 @@ impl HostCtx { (self.vtable.sql_is_enabled)(self.ctx()) != 0 } + /// Return the durable actor creation input encoded by the Rivet client. + /// The host identity envelope and its nested `input` are both CBOR. Missing + /// input is valid for actors created without `createWithInput`. + pub(crate) fn actor_input(&self) -> Result>, String> { + let bytes = unsafe { (self.vtable.actor_identity)(self.ctx()).into_vec() }; + if bytes.is_empty() { + return Ok(None); + } + let identity: abi::ActorIdentity = ciborium::from_reader(bytes.as_slice()) + .map_err(|error| format!("decode actor identity: {error}"))?; + Ok(identity.input) + } + /// Signal actor startup to the host (required: the native-plugin factory is /// built with manual startup-ready, so the host's `start()` caller awaits /// this before the actor is considered live). `ok = false` reports a fatal diff --git a/crates/agentos-actor-plugin/src/lib.rs b/crates/agentos-actor-plugin/src/lib.rs index 5cadc455b5..bd33c7fd31 100644 --- a/crates/agentos-actor-plugin/src/lib.rs +++ b/crates/agentos-actor-plugin/src/lib.rs @@ -222,6 +222,21 @@ async fn actor_loop( pool: String, cancel: CancellationToken, ) { + let actor_input = match host.actor_input() { + Ok(input) => input, + Err(error) => { + host.startup_ready(false, &error); + return; + } + }; + let config = match config.with_actor_input(actor_input.as_deref()) { + Ok(config) => Arc::new(config), + Err(error) => { + let message = format!("invalid agent-os actor creation input: {error}"); + host.startup_ready(false, &message); + return; + } + }; // Ensure the agent-os schema exists before handling events (best-effort; // mirrors rivetkit-agent-os run.rs). if host.sql_is_enabled() { diff --git a/crates/agentos-actor-plugin/src/persistence.rs b/crates/agentos-actor-plugin/src/persistence.rs index 56cddb2d0b..9777ddce84 100644 --- a/crates/agentos-actor-plugin/src/persistence.rs +++ b/crates/agentos-actor-plugin/src/persistence.rs @@ -57,6 +57,12 @@ CREATE TABLE IF NOT EXISTS agent_os_sessions ( agent_info TEXT, created_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS agent_os_session_launch ( + session_id TEXT PRIMARY KEY, + cwd TEXT NOT NULL, + env TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES agent_os_sessions(session_id) ON DELETE CASCADE +); CREATE TABLE IF NOT EXISTS agent_os_session_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, diff --git a/crates/agentos-protocol/protocol/agent_os_acp_v1.bare b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare index edbb678870..e6f48dc7ce 100644 --- a/crates/agentos-protocol/protocol/agent_os_acp_v1.bare +++ b/crates/agentos-protocol/protocol/agent_os_acp_v1.bare @@ -13,6 +13,7 @@ type AcpCreateSessionRequest struct { agentType: str runtime: AcpRuntimeKind cwd: str + additionalDirectories: list args: list env: map protocolVersion: i32 @@ -63,6 +64,8 @@ type AcpResumeSessionRequest struct { agentType: str transcriptPath: optional cwd: str + additionalDirectories: list + mcpServers: JsonUtf8 env: map } diff --git a/crates/agentos-protocol/tests/roundtrip.rs b/crates/agentos-protocol/tests/roundtrip.rs index 5d99418b41..8485b8ec54 100644 --- a/crates/agentos-protocol/tests/roundtrip.rs +++ b/crates/agentos-protocol/tests/roundtrip.rs @@ -8,6 +8,7 @@ fn acp_protocol_round_trips_create_session() { agent_type: String::from("codex"), runtime: AcpRuntimeKind::JavaScript, cwd: String::from("/home/agentos"), + additional_directories: vec![String::from("/tmp/reference")], args: vec![String::from("--model"), String::from("gpt-5")], env: [(String::from("AGENTOS_KEEP_STDIN_OPEN"), String::from("1"))] .into_iter() diff --git a/crates/agentos-sidecar-core/src/engine.rs b/crates/agentos-sidecar-core/src/engine.rs index 8c7ab25586..6804ff7311 100644 --- a/crates/agentos-sidecar-core/src/engine.rs +++ b/crates/agentos-sidecar-core/src/engine.rs @@ -30,11 +30,14 @@ const SESSION_CLOSE_TIMEOUT_MS: u64 = 5_000; /// Matches the native `INITIALIZE_TIMEOUT` (10s) and `SESSION_NEW_TIMEOUT` (30s). const INITIALIZE_TIMEOUT_MS: u64 = 10_000; const SESSION_NEW_TIMEOUT_MS: u64 = 30_000; +const MAX_ACP_ADDITIONAL_DIRECTORIES: usize = 128; +const MAX_ACP_GUEST_PATH_BYTES: usize = 4_096; /// Matches the native `ACP_RESUME_PROTOCOL_VERSION`. const ACP_RESUME_PROTOCOL_VERSION: i32 = 1; /// Matches the native `DEFAULT_RESUME_CLIENT_CAPABILITIES`. -const DEFAULT_RESUME_CLIENT_CAPABILITIES: &str = "{}"; +const DEFAULT_RESUME_CLIENT_CAPABILITIES: &str = + "{\"fs\":{\"readTextFile\":true,\"writeTextFile\":true},\"terminal\":true,\"session\":{\"configOptions\":{\"boolean\":{}}}}"; /// Transcript-continuation preamble armed by the resume fallback tier; matches the /// native `CONTINUATION_PREAMBLE`. const CONTINUATION_PREAMBLE: &str = "You are continuing an earlier session. The full prior transcript is at `{path}`. Read it with your file tools if you need context before answering."; @@ -131,6 +134,7 @@ struct PendingCreate { protocol_version: i32, cwd: String, mcp_servers: Value, + additional_directories: Vec, step: CreateStep, stdout_buffer: String, init_result: Option>, @@ -226,6 +230,37 @@ impl AcpCore { ))); }; + let mut acp_close_error = None; + match serialized_session_capability(session.agent_capabilities.as_deref(), "close") { + Ok(true) if !session.closed => { + let request_id = session.next_request_id; + let request = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "session/close", + "params": { "sessionId": session.session_id }, + }); + let mut stdout = session.stdout_buffer.clone(); + match send_json_rpc( + host, + &session.process_id, + &request, + request_id, + SESSION_CLOSE_TIMEOUT_MS, + &mut stdout, + ) { + Ok(response) => { + if let Err(error) = response_result(response, "ACP session/close") { + acp_close_error = Some(error); + } + } + Err(error) => acp_close_error = Some(error), + } + } + Ok(_) => {} + Err(error) => acp_close_error = Some(error), + } + let _ = host.close_stdin(&session.process_id); // Mirror of the native `close_session` short-circuit: an adapter that // already exited (crash / OOM / idle eviction, recorded on the session @@ -248,6 +283,9 @@ impl AcpCore { } } + if let Some(error) = acp_close_error { + return Err(error); + } Ok(AcpResponse::AcpSessionClosedResponse( AcpSessionClosedResponse { session_id: request.session_id.clone(), @@ -379,6 +417,7 @@ impl AcpCore { protocol_version: request.protocol_version, cwd: request.cwd.clone(), mcp_servers, + additional_directories: request.additional_directories.clone(), step: CreateStep::AwaitingInitialize, stdout_buffer: String::new(), init_result: None, @@ -441,12 +480,18 @@ impl AcpCore { } let init = response_result(message, "ACP initialize")?; validate_initialize_result(&init, pending.protocol_version)?; + let session_new_params = session_lifecycle_params( + &pending.cwd, + pending.mcp_servers.clone(), + &pending.additional_directories, + init.get("agentCapabilities"), + )?; pending.init_result = Some(init); let session_new = json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { "cwd": pending.cwd.clone(), "mcpServers": pending.mcp_servers.clone() }, + "params": session_new_params, }); write_json_line(host, process_id, &session_new)?; pending.step = CreateStep::AwaitingSessionNew; @@ -637,11 +682,17 @@ impl AcpCore { let init_result = response_result(init_response, "ACP initialize")?; validate_initialize_result(&init_result, request.protocol_version)?; + let session_new_params = session_lifecycle_params( + &request.cwd, + mcp_servers, + &request.additional_directories, + init_result.get("agentCapabilities"), + )?; let session_new = json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { "cwd": request.cwd, "mcpServers": mcp_servers }, + "params": session_new_params, }); let session_response = send_json_rpc( host, @@ -856,14 +907,26 @@ impl AcpCore { let init_result = response_result(init_response, "ACP initialize")?; validate_initialize_result(&init_result, ACP_RESUME_PROTOCOL_VERSION)?; let agent_capabilities = init_result.get("agentCapabilities").cloned(); + let mcp_servers = parse_json_text(&request.mcp_servers, "mcpServers")?; + let lifecycle_params = session_lifecycle_params( + &request.cwd, + mcp_servers, + &request.additional_directories, + agent_capabilities.as_ref(), + )?; // Tier 1 — native (capability-gated). Re-probed caps decide eligibility. if let Some(native_method) = native_resume_method(agent_capabilities.as_ref()) { + let mut load_params = lifecycle_params.clone(); + load_params.insert( + String::from("sessionId"), + Value::String(request.session_id.clone()), + ); let load = json!({ "jsonrpc": "2.0", "id": 2, "method": native_method, - "params": { "sessionId": request.session_id, "cwd": request.cwd, "mcpServers": [] }, + "params": load_params, }); let mut load_response = send_json_rpc( host, @@ -904,7 +967,7 @@ impl AcpCore { "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { "cwd": request.cwd, "mcpServers": [] }, + "params": lifecycle_params, }); let session_response = send_json_rpc( host, @@ -1080,9 +1143,81 @@ fn prepend_prompt_preamble(params: &mut Map, preamble: &str) { } } +fn session_capability(agent_capabilities: Option<&Value>, name: &str) -> bool { + agent_capabilities + .and_then(Value::as_object) + .and_then(|caps| caps.get("sessionCapabilities")) + .and_then(Value::as_object) + .and_then(|caps| caps.get(name)) + .is_some_and(Value::is_object) +} + +fn serialized_session_capability( + agent_capabilities: Option<&str>, + name: &str, +) -> Result { + agent_capabilities + .map(|capabilities| { + parse_json_text(capabilities, "agentCapabilities") + .map(|capabilities| session_capability(Some(&capabilities), name)) + }) + .transpose() + .map(Option::unwrap_or_default) +} + +fn session_lifecycle_params( + cwd: &str, + mcp_servers: Value, + additional_directories: &[String], + agent_capabilities: Option<&Value>, +) -> Result, AcpCoreError> { + if additional_directories.len() > MAX_ACP_ADDITIONAL_DIRECTORIES { + return Err(AcpCoreError::InvalidState(format!( + "ACP additionalDirectories exceeds {MAX_ACP_ADDITIONAL_DIRECTORIES} entries" + ))); + } + for (index, directory) in additional_directories.iter().enumerate() { + if !directory.starts_with('/') { + return Err(AcpCoreError::InvalidState(format!( + "ACP additionalDirectories[{index}] must be an absolute guest path" + ))); + } + if directory.len() > MAX_ACP_GUEST_PATH_BYTES { + return Err(AcpCoreError::InvalidState(format!( + "ACP additionalDirectories[{index}] exceeds {MAX_ACP_GUEST_PATH_BYTES} bytes" + ))); + } + } + if !additional_directories.is_empty() + && !session_capability(agent_capabilities, "additionalDirectories") + { + return Err(AcpCoreError::InvalidState(String::from( + "ACP agent does not advertise sessionCapabilities.additionalDirectories", + ))); + } + + let mut params = Map::from_iter([ + (String::from("cwd"), Value::String(cwd.to_string())), + (String::from("mcpServers"), mcp_servers), + ]); + if !additional_directories.is_empty() { + params.insert( + String::from("additionalDirectories"), + Value::Array( + additional_directories + .iter() + .cloned() + .map(Value::String) + .collect(), + ), + ); + } + Ok(params) +} + /// The adapter's native-resume RPC method from re-probed `agentCapabilities`: -/// prefer ACP `session/load`, then the non-standard `session/resume`. Mirrors the -/// native `native_resume_method`. +/// prefer ACP `session/load`, then stable `sessionCapabilities.resume`. Mirrors +/// the native `native_resume_method` and retains the pre-standard boolean draft. fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static str> { let caps = agent_capabilities.and_then(Value::as_object)?; if caps @@ -1092,7 +1227,9 @@ fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static s { return Some("session/load"); } - if caps.get("resume").and_then(Value::as_bool).unwrap_or(false) { + if session_capability(agent_capabilities, "resume") + || caps.get("resume").and_then(Value::as_bool).unwrap_or(false) + { return Some("session/resume"); } None @@ -1256,6 +1393,28 @@ mod tests { use super::*; use crate::host::{AgentOutput, SpawnAgentRequest, SpawnedAgent}; + #[test] + fn stable_resume_and_additional_directory_capabilities_are_detected() { + let capabilities = json!({ + "sessionCapabilities": { + "resume": {}, + "additionalDirectories": {}, + } + }); + assert_eq!( + native_resume_method(Some(&capabilities)), + Some("session/resume") + ); + let params = session_lifecycle_params( + "/workspace", + json!([]), + &[String::from("/reference")], + Some(&capabilities), + ) + .expect("advertised additional directories"); + assert_eq!(params["additionalDirectories"], json!(["/reference"])); + } + #[derive(Default)] struct MockHost { killed: Vec<(String, String)>, @@ -1625,6 +1784,8 @@ mod tests { agent_type: "echo".into(), transcript_path: Some("/transcripts/old.jsonl".into()), cwd: "/workspace".into(), + additional_directories: Vec::new(), + mcp_servers: "[]".into(), env: HashMap::new(), }; @@ -1730,6 +1891,7 @@ mod tests { runtime: AcpRuntimeKind::JavaScript, protocol_version: 1, cwd: "/workspace".into(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), client_capabilities: "{}".into(), @@ -1824,6 +1986,7 @@ mod tests { runtime: AcpRuntimeKind::JavaScript, protocol_version: 1, cwd: "/workspace".into(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), client_capabilities: "{}".into(), diff --git a/crates/agentos-sidecar-core/src/json_rpc.rs b/crates/agentos-sidecar-core/src/json_rpc.rs index fc1deca579..b5deb48d80 100644 --- a/crates/agentos-sidecar-core/src/json_rpc.rs +++ b/crates/agentos-sidecar-core/src/json_rpc.rs @@ -46,8 +46,25 @@ pub fn send_json_rpc( let deadline = host.now_ms().saturating_add(timeout_ms); loop { if host.now_ms() >= deadline { + let cancel = serde_json::json!({ + "jsonrpc": "2.0", + "method": "$/cancel_request", + "params": { "requestId": response_id }, + }); + let cancel_status = match serde_json::to_vec(&cancel) { + Ok(mut line) => { + line.push(b'\n'); + match host.write_stdin(process_id, &line) { + Ok(()) => String::from("sent $/cancel_request notification"), + Err(error) => { + format!("failed to send $/cancel_request notification: {error}") + } + } + } + Err(error) => format!("failed to serialize $/cancel_request: {error}"), + }; return Err(AcpCoreError::Execution(format!( - "timed out waiting for ACP response id={response_id}" + "timed out waiting for ACP response id={response_id}; {cancel_status}" ))); } match host.poll_output(process_id)? { @@ -99,7 +116,9 @@ mod tests { fn write_stdin(&mut self, _: &str, chunk: &[u8]) -> Result<(), AcpCoreError> { let request: Value = serde_json::from_slice(chunk.strip_suffix(b"\n").unwrap_or(chunk)) .expect("valid json line"); - let id = request.get("id").and_then(Value::as_i64).unwrap(); + let Some(id) = request.get("id").and_then(Value::as_i64) else { + return Ok(()); + }; let reply = json!({"jsonrpc": "2.0", "id": id, "result": {"ok": true}}); let mut bytes = serde_json::to_vec(&reply).unwrap(); bytes.push(b'\n'); diff --git a/crates/agentos-sidecar-core/tests/real_agent_round_trip.rs b/crates/agentos-sidecar-core/tests/real_agent_round_trip.rs index 69668bb680..047c613987 100644 --- a/crates/agentos-sidecar-core/tests/real_agent_round_trip.rs +++ b/crates/agentos-sidecar-core/tests/real_agent_round_trip.rs @@ -44,6 +44,11 @@ impl AcpHost for NodeChildAcpHost { let entrypoint = request .entrypoint .ok_or_else(|| AcpCoreError::InvalidState("missing agent entrypoint".into()))?; + let entrypoint = if entrypoint == "/opt/agentos/bin/echo-agent" { + echo_agent_path() + } else { + PathBuf::from(entrypoint) + }; let mut command = Command::new("node"); command .arg(&entrypoint) @@ -149,7 +154,7 @@ impl AcpHost for NodeChildAcpHost { let manifest = serde_json::json!({ "name": "echo", "agent": { - "acpEntrypoint": echo_agent_path().to_string_lossy(), + "acpEntrypoint": "echo-agent", }, }); serde_json::to_vec(&manifest) @@ -192,6 +197,7 @@ fn acp_core_runs_a_real_session_round_trip_against_the_echo_agent() { runtime: AcpRuntimeKind::JavaScript, protocol_version: 1, cwd: ".".into(), + additional_directories: Vec::new(), args: Vec::new(), env: BTreeMap::new().into_iter().collect(), client_capabilities: "{}".into(), diff --git a/crates/agentos-sidecar/src/acp_extension.rs b/crates/agentos-sidecar/src/acp_extension.rs index 4f2ef41491..2d386e2f41 100644 --- a/crates/agentos-sidecar/src/acp_extension.rs +++ b/crates/agentos-sidecar/src/acp_extension.rs @@ -36,6 +36,13 @@ const SESSION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); // process output keep moving mid-turn. const ACP_JSON_RPC_POLL_INTERVAL: Duration = Duration::from_micros(250); const ACP_CANCEL_METHOD: &str = "session/cancel"; +const ACP_CANCEL_REQUEST_METHOD: &str = "$/cancel_request"; +const MAX_ACP_ADDITIONAL_DIRECTORIES: usize = 128; +const MAX_ACP_GUEST_PATH_BYTES: usize = 4_096; +/// Official stable ACP schema release implemented manually by this sidecar. +/// See `.claude/skills/update-acp/SKILL.md`. +#[allow(dead_code)] +pub const ACP_IMPLEMENTED_SCHEMA_VERSION: &str = "1.19.0"; /// Transcript-continuation preamble prepended (once) to the first prompt after a /// fallback resume. Lossy-but-universal floor: the agent is handed a *pointer* to /// the rendered transcript and reads it on demand with its own file tools. `{path}` @@ -48,7 +55,7 @@ const ACP_RESUME_PROTOCOL_VERSION: i32 = 1; /// client's `defaultAcpClientCapabilities()` so resumed sessions behave like /// freshly created ones. const DEFAULT_RESUME_CLIENT_CAPABILITIES: &str = - "{\"fs\":{\"readTextFile\":true,\"writeTextFile\":true},\"terminal\":true}"; + "{\"fs\":{\"readTextFile\":true,\"writeTextFile\":true},\"terminal\":true,\"session\":{\"configOptions\":{\"boolean\":{}}}}"; const OPENCODE_SYSTEM_PROMPT_PATH: &str = "/tmp/agentos-system-prompt.md"; const OPENCODE_DEFAULT_CONTEXT_PATHS: [&str; 11] = [ ".github/copilot-instructions.md", @@ -149,6 +156,8 @@ struct AdapterRestartState { args: Vec, env: BTreeMap, cwd: String, + additional_directories: Vec, + mcp_servers: String, protocol_version: i32, client_capabilities: String, count: u32, @@ -300,6 +309,8 @@ impl AcpExtension { args: args.clone(), env: env.clone(), cwd: request.cwd.clone(), + additional_directories: request.additional_directories.clone(), + mcp_servers: request.mcp_servers.clone(), protocol_version: request.protocol_version, client_capabilities: request.client_capabilities.clone(), count: 0, @@ -460,14 +471,17 @@ impl AcpExtension { validate_initialize_result(&init_result, request.protocol_version)?; tracing::info!(target: "agentos_sidecar::perf", phase = "acp_initialize", elapsed_ms = __ti.elapsed().as_millis() as u64, "create_session_inner phase"); + let session_new_params = session_lifecycle_params( + &request.cwd, + mcp_servers, + &request.additional_directories, + init_result.get("agentCapabilities"), + )?; let session_new = json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { - "cwd": request.cwd, - "mcpServers": mcp_servers, - }, + "params": session_new_params, }); let session_response = send_json_rpc_request( ctx, @@ -621,6 +635,50 @@ impl AcpExtension { request.session_id ))); }; + + let mut acp_close_error = None; + match serialized_session_capability(session.agent_capabilities.as_deref(), "close") { + Ok(true) if !session.closed => { + let request_id = session.next_request_id; + let request = json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": "session/close", + "params": { "sessionId": session.session_id }, + }); + let mut stdout = session.stdout_buffer.clone(); + match send_json_rpc_request( + &mut ctx, + &session.process_id, + &session.agent_type, + request, + request_id, + SESSION_CLOSE_TIMEOUT, + &mut stdout, + None, + ) + .await + { + Ok(exchange) => { + if !exchange.notifications.is_empty() { + tracing::debug!( + target: "agentos_sidecar::acp_extension", + session_id = session.session_id, + notifications = exchange.notifications.len(), + "ACP adapter emitted notifications while closing session", + ); + } + if let Err(error) = response_result(exchange.response, "ACP session/close") + { + acp_close_error = Some(error); + } + } + Err(error) => acp_close_error = Some(error), + } + } + Ok(_) => {} + Err(error) => acp_close_error = Some(error), + } let _ = ctx .close_stdin_wire(CloseStdinRequest { process_id: session.process_id.clone(), @@ -669,6 +727,9 @@ impl AcpExtension { process_id = session.process_id, "ACP session closed; adapter process terminated", ); + if let Some(error) = acp_close_error { + return Err(error); + } Ok(AcpResponse::AcpSessionClosedResponse( AcpSessionClosedResponse { session_id: request.session_id, @@ -940,11 +1001,12 @@ impl AcpExtension { agent_type: request.agent_type.clone(), runtime: AcpRuntimeKind::JavaScript, cwd: request.cwd.clone(), + additional_directories: request.additional_directories.clone(), args: Vec::new(), env: request.env.clone(), protocol_version: ACP_RESUME_PROTOCOL_VERSION, client_capabilities: DEFAULT_RESUME_CLIENT_CAPABILITIES.to_string(), - mcp_servers: "[]".to_string(), + mcp_servers: request.mcp_servers.clone(), skip_os_instructions: true, additional_instructions: None, }; @@ -974,6 +1036,8 @@ impl AcpExtension { args: args.clone(), env: env.clone(), cwd: create_like.cwd.clone(), + additional_directories: create_like.additional_directories.clone(), + mcp_servers: create_like.mcp_servers.clone(), protocol_version: create_like.protocol_version, client_capabilities: create_like.client_capabilities.clone(), count: 0, @@ -1110,18 +1174,26 @@ impl AcpExtension { validate_initialize_result(&init_result, create_like.protocol_version)?; let agent_capabilities = init_result.get("agentCapabilities").cloned(); + let mcp_servers = parse_json_text(&request.mcp_servers, "mcpServers")?; + let lifecycle_params = session_lifecycle_params( + &request.cwd, + mcp_servers, + &request.additional_directories, + agent_capabilities.as_ref(), + )?; // Tier 1 — native (capability-gated). Re-probed caps decide eligibility. if let Some(native_resume_method) = native_resume_method(agent_capabilities.as_ref()) { + let mut load_params = lifecycle_params.clone(); + load_params.insert( + String::from("sessionId"), + Value::String(request.session_id.clone()), + ); let load = json!({ "jsonrpc": "2.0", "id": 2, "method": native_resume_method, - "params": { - "sessionId": request.session_id, - "cwd": request.cwd, - "mcpServers": [], - }, + "params": load_params, }); let mut load_response = send_json_rpc_request( ctx, @@ -1178,10 +1250,7 @@ impl AcpExtension { "jsonrpc": "2.0", "id": 2, "method": "session/new", - "params": { - "cwd": request.cwd, - "mcpServers": [], - }, + "params": lifecycle_params, }); let session_response = send_json_rpc_request( ctx, @@ -1531,9 +1600,12 @@ impl Extension for AcpExtension { { Some(ExtensionInterruptResponse { interrupted_response_payload, - interrupting_response_payload: Some( - encode_interrupted_cancel_response(&request.session_id)?, - ), + // Re-queue the cancel frame after resolving the blocked + // prompt. A synthetic response here used to consume the + // frame without ever delivering session/cancel to the + // adapter, leaving the harness query running and the next + // prompt permanently busy. + interrupting_response_payload: None, }) } AcpRequest::AcpCreateSessionRequest(_) @@ -1785,7 +1857,12 @@ async fn send_json_rpc_request( loop { let now = Instant::now(); if now >= deadline { - let cancel_status = if let Some(session_id) = event_session_id { + let request_cancel_status = + match write_request_cancel_notification(ctx, process_id, response_id).await { + Ok(()) => String::from("sent $/cancel_request notification"), + Err(error) => format!("failed to send $/cancel_request notification: {error}"), + }; + let session_cancel_status = if let Some(session_id) = event_session_id { match write_session_cancel_notification(ctx, process_id, session_id).await { Ok(()) => String::from("sent session/cancel notification"), Err(error) => format!("failed to send session/cancel notification: {error}"), @@ -1793,6 +1870,7 @@ async fn send_json_rpc_request( } else { String::from("no session/cancel notification for bootstrap request") }; + let cancel_status = format!("{request_cancel_status}; {session_cancel_status}"); if event_session_id.is_some() { return Ok(JsonRpcExchange { response: timeout_error_response( @@ -1973,6 +2051,34 @@ async fn write_session_cancel_notification( Ok(()) } +async fn write_request_cancel_notification( + ctx: &mut ExtensionContext<'_>, + process_id: &str, + request_id: i64, +) -> Result<(), SidecarError> { + let mut line = + serde_json::to_vec(&request_cancel_notification(request_id)).map_err(|error| { + SidecarError::InvalidState(format!( + "failed to serialize ACP request-cancel notification: {error}" + )) + })?; + line.push(b'\n'); + ctx.write_stdin_wire(WriteStdinRequest { + process_id: process_id.to_string(), + chunk: line, + }) + .await?; + Ok(()) +} + +fn request_cancel_notification(request_id: i64) -> Value { + json!({ + "jsonrpc": "2.0", + "method": ACP_CANCEL_REQUEST_METHOD, + "params": { "requestId": request_id }, + }) +} + fn session_cancel_notification(session_id: &str) -> Value { json!({ "jsonrpc": "2.0", @@ -2008,21 +2114,6 @@ fn encode_interrupted_session_response(session_id: &str) -> Option> { ) } -fn encode_interrupted_cancel_response(session_id: &str) -> Option> { - encode_session_rpc_response( - session_id, - json!({ - "jsonrpc": "2.0", - "id": null, - "result": { - "cancelled": true, - "requested": true, - "via": "prompt-interrupt", - }, - }), - ) -} - fn encode_session_rpc_response(session_id: &str, response: Value) -> Option> { let response = AcpResponse::AcpSessionRpcResponse( agentos_protocol::generated::v1::AcpSessionRpcResponse { @@ -2649,15 +2740,24 @@ async fn restart_handshake( let Some(native_resume_method) = native_resume_method(agent_capabilities.as_ref()) else { return Err(AdapterRestartError::Unsupported); }; + let mcp_servers = + parse_json_text(&restart.mcp_servers, "mcpServers").map_err(AdapterRestartError::Failed)?; + let mut load_params = session_lifecycle_params( + &restart.cwd, + mcp_servers, + &restart.additional_directories, + agent_capabilities.as_ref(), + ) + .map_err(AdapterRestartError::Failed)?; + load_params.insert( + String::from("sessionId"), + Value::String(session_id.to_string()), + ); let load = json!({ "jsonrpc": "2.0", "id": 2, "method": native_resume_method, - "params": { - "sessionId": session_id, - "cwd": restart.cwd, - "mcpServers": [], - }, + "params": load_params, }); let load_response = send_json_rpc_request( ctx, @@ -2746,9 +2846,82 @@ async fn kill_process_best_effort(ctx: &mut ExtensionContext<'_>, process_id: &s .await; } +fn session_capability(agent_capabilities: Option<&Value>, name: &str) -> bool { + agent_capabilities + .and_then(Value::as_object) + .and_then(|caps| caps.get("sessionCapabilities")) + .and_then(Value::as_object) + .and_then(|caps| caps.get(name)) + .is_some_and(Value::is_object) +} + +fn serialized_session_capability( + agent_capabilities: Option<&str>, + name: &str, +) -> Result { + agent_capabilities + .map(|capabilities| { + parse_json_text(capabilities, "agentCapabilities") + .map(|capabilities| session_capability(Some(&capabilities), name)) + }) + .transpose() + .map(Option::unwrap_or_default) +} + +fn session_lifecycle_params( + cwd: &str, + mcp_servers: Value, + additional_directories: &[String], + agent_capabilities: Option<&Value>, +) -> Result, SidecarError> { + if additional_directories.len() > MAX_ACP_ADDITIONAL_DIRECTORIES { + return Err(SidecarError::InvalidState(format!( + "ACP additionalDirectories exceeds {MAX_ACP_ADDITIONAL_DIRECTORIES} entries" + ))); + } + for (index, directory) in additional_directories.iter().enumerate() { + if !directory.starts_with('/') { + return Err(SidecarError::InvalidState(format!( + "ACP additionalDirectories[{index}] must be an absolute guest path" + ))); + } + if directory.len() > MAX_ACP_GUEST_PATH_BYTES { + return Err(SidecarError::InvalidState(format!( + "ACP additionalDirectories[{index}] exceeds {MAX_ACP_GUEST_PATH_BYTES} bytes" + ))); + } + } + if !additional_directories.is_empty() + && !session_capability(agent_capabilities, "additionalDirectories") + { + return Err(SidecarError::InvalidState(String::from( + "ACP agent does not advertise sessionCapabilities.additionalDirectories", + ))); + } + + let mut params = Map::from_iter([ + (String::from("cwd"), Value::String(cwd.to_string())), + (String::from("mcpServers"), mcp_servers), + ]); + if !additional_directories.is_empty() { + params.insert( + String::from("additionalDirectories"), + Value::Array( + additional_directories + .iter() + .cloned() + .map(Value::String) + .collect(), + ), + ); + } + Ok(params) +} + /// Return the adapter native-resume RPC method from re-probed -/// `agentCapabilities`. Prefer ACP `loadSession`/`session/load`; fall back to the -/// non-standard `resume`/`session/resume` capability some adapters expose. +/// `agentCapabilities`. Prefer ACP `loadSession`/`session/load`; then use stable +/// `sessionCapabilities.resume`/`session/resume`. The final top-level boolean is +/// retained for adapters that implemented AgentOS's pre-standard resume draft. fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static str> { let caps = agent_capabilities.and_then(Value::as_object)?; if caps @@ -2758,7 +2931,9 @@ fn native_resume_method(agent_capabilities: Option<&Value>) -> Option<&'static s { return Some("session/load"); } - if caps.get("resume").and_then(Value::as_bool).unwrap_or(false) { + if session_capability(agent_capabilities, "resume") + || caps.get("resume").and_then(Value::as_bool).unwrap_or(false) + { return Some("session/resume"); } None @@ -2950,7 +3125,7 @@ fn is_model_config_option(value: &Value) -> bool { || map .get("category") .and_then(Value::as_str) - .is_some_and(|category| category == "model") + .is_some_and(|category| category == "model" || category == "model_config") }) } @@ -2962,7 +3137,7 @@ fn derive_config_options(agent_type: &str, session_result: &Map) .get("currentModelId") .and_then(Value::as_str) .map(String::from); - let allowed_values = models + let options = models .get("availableModels") .and_then(Value::as_array) .map(|models| { @@ -2972,18 +3147,20 @@ fn derive_config_options(agent_type: &str, session_result: &Map) .filter_map(|model| { let model_id = model.get("modelId")?.as_str()?; let mut item = Map::from_iter([( - String::from("id"), + String::from("value"), Value::String(String::from(model_id)), )]); if let Some(name) = model.get("name").and_then(Value::as_str) { - item.insert(String::from("label"), Value::String(String::from(name))); + item.insert(String::from("name"), Value::String(String::from(name))); + } else { + item.insert(String::from("name"), Value::String(String::from(model_id))); } Some(Value::Object(item)) }) .collect::>() }) .unwrap_or_default(); - if current_model_id.is_none() && allowed_values.is_empty() { + if current_model_id.is_none() && options.is_empty() { return Vec::new(); } @@ -2993,8 +3170,9 @@ fn derive_config_options(agent_type: &str, session_result: &Map) String::from("category"), Value::String(String::from("model")), ), - (String::from("label"), Value::String(String::from("Model"))), - (String::from("allowedValues"), Value::Array(allowed_values)), + (String::from("name"), Value::String(String::from("Model"))), + (String::from("type"), Value::String(String::from("select"))), + (String::from("options"), Value::Array(options)), ( String::from("readOnly"), Value::Bool(agent_type == "opencode"), @@ -3071,6 +3249,45 @@ mod tests { #[test] fn acp_extension_uses_agent_os_namespace() { assert_eq!(AcpExtension::new().namespace(), ACP_EXTENSION_NAMESPACE); + assert_eq!(ACP_IMPLEMENTED_SCHEMA_VERSION, "1.19.0"); + } + + #[test] + fn cancel_interrupt_resolves_prompt_and_requeues_cancel_for_adapter_delivery() { + let extension = AcpExtension::new(); + let prompt = serde_bare::to_vec(&AcpRequest::AcpSessionRequest(AcpSessionRequest { + session_id: String::from("session-1"), + method: String::from("session/prompt"), + params: None, + })) + .expect("encode prompt"); + let cancel = serde_bare::to_vec(&AcpRequest::AcpSessionRequest(AcpSessionRequest { + session_id: String::from("session-1"), + method: String::from("session/cancel"), + params: None, + })) + .expect("encode cancel"); + + let interrupted = extension + .interrupt_blocking_request( + &prompt, + ExtensionInterruptRequest::ExtensionPayload(&cancel), + ) + .expect("matching cancel interrupts prompt"); + + assert!( + interrupted.interrupting_response_payload.is_none(), + "the stdio transport must requeue the cancel frame instead of consuming it synthetically" + ); + let response: AcpResponse = + serde_bare::from_slice(&interrupted.interrupted_response_payload) + .expect("decode interrupted prompt response"); + let AcpResponse::AcpSessionRpcResponse(response) = response else { + panic!("unexpected interrupted prompt response: {response:?}"); + }; + let response: Value = + serde_json::from_str(&response.response).expect("prompt response JSON"); + assert_eq!(response["result"]["stopReason"], "cancelled"); } #[test] @@ -3213,6 +3430,51 @@ mod tests { ); } + #[test] + fn request_cancel_notification_has_stable_acp_shape() { + assert_eq!( + request_cancel_notification(17), + json!({ + "jsonrpc": "2.0", + "method": "$/cancel_request", + "params": { "requestId": 17 }, + }) + ); + } + + #[test] + fn stable_resume_and_additional_directory_capabilities_are_detected() { + let capabilities = json!({ + "sessionCapabilities": { + "resume": {}, + "additionalDirectories": {}, + } + }); + assert_eq!( + native_resume_method(Some(&capabilities)), + Some("session/resume") + ); + let params = session_lifecycle_params( + "/workspace", + json!([]), + &[String::from("/reference")], + Some(&capabilities), + ) + .expect("advertised additional directories"); + assert_eq!(params["additionalDirectories"], json!(["/reference"])); + + let error = session_lifecycle_params( + "/workspace", + json!([]), + &[String::from("/reference")], + Some(&json!({})), + ) + .expect_err("unadvertised additional directories must fail"); + assert!(error + .to_string() + .contains("sessionCapabilities.additionalDirectories")); + } + #[test] fn cancel_method_not_found_detection_accepts_error_data_or_message() { assert!(is_cancel_method_not_found(&json!({ @@ -3279,12 +3541,33 @@ mod tests { "id": "provider-model", "category": "model", }))); + assert!(is_model_config_option(&json!({ + "id": "provider-model", + "category": "model_config", + }))); assert!(!is_model_config_option(&json!({ "id": "thought-level", "category": "thought_level", }))); } + #[test] + fn derived_model_config_uses_stable_select_shape() { + let result = Map::from_iter([( + String::from("models"), + json!({ + "currentModelId": "gpt-5", + "availableModels": [{ "modelId": "gpt-5", "name": "GPT-5" }], + }), + )]); + let options = derive_config_options("opencode", &result); + assert_eq!(options.len(), 1); + assert_eq!(options[0]["type"], "select"); + assert_eq!(options[0]["name"], "Model"); + assert_eq!(options[0]["options"][0]["value"], "gpt-5"); + assert!(options[0].get("allowedValues").is_none()); + } + #[test] fn session_new_session_id_falls_back_to_wrapper_id() { assert_eq!( @@ -3385,6 +3668,8 @@ mod tests { args: Vec::new(), env: BTreeMap::new(), cwd: String::from("/"), + additional_directories: Vec::new(), + mcp_servers: String::from("[]"), protocol_version: 1, client_capabilities: String::from("{}"), count: 0, diff --git a/crates/agentos-sidecar/tests/acp_adapter_restart.rs b/crates/agentos-sidecar/tests/acp_adapter_restart.rs index 96d9950ca9..81687975aa 100644 --- a/crates/agentos-sidecar/tests/acp_adapter_restart.rs +++ b/crates/agentos-sidecar/tests/acp_adapter_restart.rs @@ -466,6 +466,7 @@ fn create_session_request(adapter: &Path, cwd: &Path) -> AcpRequest { agent_type: agent_type_for_adapter(adapter), runtime: AcpRuntimeKind::JavaScript, cwd: cwd.to_string_lossy().into_owned(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), protocol_version: i32::from(ACP_PROTOCOL_VERSION), diff --git a/crates/agentos-sidecar/tests/acp_adapter_stderr.rs b/crates/agentos-sidecar/tests/acp_adapter_stderr.rs index 386f00df06..0c3f2434cc 100644 --- a/crates/agentos-sidecar/tests/acp_adapter_stderr.rs +++ b/crates/agentos-sidecar/tests/acp_adapter_stderr.rs @@ -70,6 +70,7 @@ fn adapter_stderr_and_exit_surface_to_caller() { agent_type: String::from("pi"), runtime: AcpRuntimeKind::JavaScript, cwd: cwd.to_string_lossy().into_owned(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), protocol_version: i32::from(ACP_PROTOCOL_VERSION), diff --git a/crates/agentos-sidecar/tests/acp_extension.rs b/crates/agentos-sidecar/tests/acp_extension.rs index 4f772e4e29..645a729a0a 100644 --- a/crates/agentos-sidecar/tests/acp_extension.rs +++ b/crates/agentos-sidecar/tests/acp_extension.rs @@ -96,6 +96,7 @@ fn acp_extension_creates_reports_and_closes_session_over_ext() { agent_type: String::from("pi"), runtime: AcpRuntimeKind::JavaScript, cwd: cwd.to_string_lossy().into_owned(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), protocol_version: i32::from(ACP_PROTOCOL_VERSION), @@ -340,6 +341,7 @@ fn acp_get_session_state_denies_cross_connection_session_id() { agent_type: String::from("pi"), runtime: AcpRuntimeKind::JavaScript, cwd: cwd.to_string_lossy().into_owned(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), protocol_version: i32::from(ACP_PROTOCOL_VERSION), @@ -443,6 +445,7 @@ fn acp_close_session_denies_cross_connection_session_id() { agent_type: String::from("pi"), runtime: AcpRuntimeKind::JavaScript, cwd: cwd.to_string_lossy().into_owned(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), protocol_version: i32::from(ACP_PROTOCOL_VERSION), @@ -556,6 +559,7 @@ fn acp_session_request_denies_cross_connection_prompt_and_cancel() { agent_type: String::from("pi"), runtime: AcpRuntimeKind::JavaScript, cwd: cwd.to_string_lossy().into_owned(), + additional_directories: Vec::new(), args: Vec::new(), env: HashMap::new(), protocol_version: i32::from(ACP_PROTOCOL_VERSION), diff --git a/crates/client/src/agent_os.rs b/crates/client/src/agent_os.rs index 7bfc806fb4..b550c2a47d 100644 --- a/crates/client/src/agent_os.rs +++ b/crates/client/src/agent_os.rs @@ -126,9 +126,10 @@ pub(crate) struct SessionEntry { pub agent_type: String, pub modes: parking_lot::Mutex>, pub config_options: parking_lot::Mutex>, + pub usage: parking_lot::Mutex>, pub capabilities: parking_lot::Mutex>, pub agent_info: parking_lot::Mutex>, - pub config_overrides: parking_lot::Mutex>, + pub config_overrides: parking_lot::Mutex>, pub event_tx: broadcast::Sender, pub permission_tx: broadcast::Sender, pub agent_exit_tx: broadcast::Sender, diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index f845e5f7bd..1b87748830 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -94,7 +94,8 @@ pub use session::{ AgentCapabilities, AgentExitEvent, AgentExitStream, AgentExitSubscription, AgentInfo, AgentRegistryEntry, ConfigAllowedValue, CreateSessionOptions, McpServerConfig, PermissionReply, PermissionRequest, PromptCapabilities, PromptResult, ResumeSessionOptions, ResumeSessionResult, - SessionConfigOption, SessionId, SessionInfo, SessionInitData, SessionMode, SessionModeState, + SessionConfigOption, SessionCost, SessionId, SessionInfo, SessionInitData, SessionMode, + SessionModeState, SessionUsage, }; pub use json_rpc::{ diff --git a/crates/client/src/session.rs b/crates/client/src/session.rs index c8107b383c..81d1f0bec3 100644 --- a/crates/client/src/session.rs +++ b/crates/client/src/session.rs @@ -42,6 +42,29 @@ pub(crate) const ACP_PERMISSION_METHOD: &str = "session/request_permission"; /// Maximum in-flight session RPC requests per session. const SESSION_PENDING_REQUEST_LIMIT: usize = 1024; +const MAX_ACP_ADDITIONAL_DIRECTORIES: usize = 128; +const MAX_ACP_GUEST_PATH_BYTES: usize = 4_096; + +fn validate_additional_directories(directories: &[String]) -> Result<(), ClientError> { + if directories.len() > MAX_ACP_ADDITIONAL_DIRECTORIES { + return Err(ClientError::Sidecar(format!( + "ACP additionalDirectories exceeds {MAX_ACP_ADDITIONAL_DIRECTORIES} entries" + ))); + } + for (index, directory) in directories.iter().enumerate() { + if !directory.starts_with('/') { + return Err(ClientError::Sidecar(format!( + "ACP additionalDirectories[{index}] must be an absolute guest path" + ))); + } + if directory.len() > MAX_ACP_GUEST_PATH_BYTES { + return Err(ClientError::Sidecar(format!( + "ACP additionalDirectories[{index}] exceeds {MAX_ACP_GUEST_PATH_BYTES} bytes" + ))); + } + } + Ok(()) +} pub(crate) struct PermissionRouteRequest { pub(crate) session_id: String, @@ -156,6 +179,8 @@ pub enum McpServerConfig { pub struct CreateSessionOptions { /// Default `"/workspace"`. pub cwd: Option, + /// Additional absolute guest workspace roots. Default `[]`. + pub additional_directories: Vec, pub env: BTreeMap, /// Default `[]`. pub mcp_servers: Vec, @@ -192,6 +217,10 @@ pub struct ResumeSessionOptions { pub transcript_path: Option, /// Default `"/workspace"`. pub cwd: Option, + /// Additional absolute guest workspace roots. Default `[]`. + pub additional_directories: Vec, + /// MCP servers to reconnect while resuming. Default `[]`. + pub mcp_servers: Vec, pub env: BTreeMap, } @@ -202,12 +231,12 @@ pub struct PromptResult { pub text: String, } -/// A single session mode (`{ id; name?; label?; description?; [k]: unknown }`). +/// A stable ACP session mode (`{ id; name; description?; _meta? }`). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SessionMode { pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, + #[serde(default)] + pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -219,9 +248,8 @@ pub struct SessionMode { /// Session mode state (`{ currentModeId; availableModes }`). /// -/// `currentModeId` and `availableModes` default so a loosely-shaped modes object (one missing either -/// field) still deserializes and is stored. Mirrors TS `toSessionModes`, which returns ANY non-array -/// object as `SessionModeState` with no field check. +/// `currentModeId` and `availableModes` are normalized before deserialization; +/// malformed entries are skipped and legacy `label` values become stable `name` values. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct SessionModeState { #[serde(default, rename = "currentModeId")] @@ -248,6 +276,10 @@ pub struct SessionConfigOption { #[serde(default)] pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub category: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option, @@ -258,7 +290,9 @@ pub struct SessionConfigOption { rename = "currentValue", skip_serializing_if = "Option::is_none" )] - pub current_value: Option, + pub current_value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option>, #[serde( default, rename = "allowedValues", @@ -267,6 +301,10 @@ pub struct SessionConfigOption { pub allowed_values: Option>, #[serde(default, rename = "readOnly", skip_serializing_if = "Option::is_none")] pub read_only: Option, + /// Preserve newer ACP fields such as stable `type` / `name` / `options` + /// while older AgentOS callers continue to use the normalized fields above. + #[serde(flatten)] + pub extra: BTreeMap, } /// Prompt capabilities sub-object. @@ -289,6 +327,26 @@ pub struct PromptCapabilities { /// Agent capabilities (all optional booleans + prompt capabilities + extras). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct AgentCapabilities { + #[serde( + default, + rename = "loadSession", + skip_serializing_if = "Option::is_none" + )] + pub load_session: Option, + #[serde( + default, + rename = "mcpCapabilities", + skip_serializing_if = "Option::is_none" + )] + pub mcp_capabilities: Option, + #[serde( + default, + rename = "sessionCapabilities", + skip_serializing_if = "Option::is_none" + )] + pub session_capabilities: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub permissions: Option, #[serde(default, rename = "plan_mode", skip_serializing_if = "Option::is_none")] @@ -361,6 +419,26 @@ pub struct AgentInfo { pub extra: BTreeMap, } +/// Cumulative cost reported by ACP `usage_update`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionCost { + pub amount: f64, + pub currency: String, + #[serde(flatten)] + pub extra: BTreeMap, +} + +/// Context-window and cost state reported by ACP `usage_update`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SessionUsage { + pub used: f64, + pub size: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + /// Initial hydration data for a session. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct SessionInitData { @@ -461,7 +539,11 @@ fn permission_reply_wire(reply: PermissionReply) -> &'static str { /// every modeled field is `None` and there are no extra keys. `toAgentCapabilities` stores `{}` for /// any non-object/empty state, and `getSessionCapabilities` returns `null` for that empty object. fn agent_capabilities_is_empty(caps: &AgentCapabilities) -> bool { - caps.permissions.is_none() + caps.load_session.is_none() + && caps.mcp_capabilities.is_none() + && caps.session_capabilities.is_none() + && caps.auth.is_none() + && caps.permissions.is_none() && caps.plan_mode.is_none() && caps.questions.is_none() && caps.tool_calls.is_none() @@ -523,6 +605,12 @@ fn apply_session_update(entry: &SessionEntry, notification: &JsonRpcNotification *entry.config_options.lock() = parsed; apply_synthetic_config_overrides(entry); } + Some("usage_update") => { + if let Ok(usage) = serde_json::from_value::(Value::Object(update.clone())) + { + *entry.usage.lock() = Some(usage); + } + } Some("agent_message_chunk") | None | Some(_) => {} } } @@ -644,11 +732,7 @@ const PENDING_METHOD_PREFIX: &str = "__pending_method::"; /// Apply the local cache mutations of `_syncSessionState`: modes, config options, capabilities, /// and agent info from a sidecar [`SessionStateResponse`]. fn sync_session_state(entry: &SessionEntry, state: &SessionStateResponse) { - *entry.modes.lock() = state - .modes - .as_ref() - .filter(|value| value.is_object()) - .and_then(|value| serde_json::from_value(value.clone()).ok()); + *entry.modes.lock() = state.modes.as_ref().and_then(normalize_session_modes); *entry.config_options.lock() = state .config_options @@ -671,6 +755,32 @@ fn sync_session_state(entry: &SessionEntry, state: &SessionStateResponse) { .and_then(|value| serde_json::from_value(value.clone()).ok()); } +fn normalize_session_modes(value: &Value) -> Option { + let object = value.as_object()?; + let current_mode_id = object.get("currentModeId")?.as_str()?.to_string(); + let available_modes = object + .get("availableModes")? + .as_array()? + .iter() + .filter_map(|mode| { + let mut mode = mode.as_object()?.clone(); + let id = mode.get("id")?.as_str()?.to_string(); + let name = mode + .get("name") + .and_then(Value::as_str) + .or_else(|| mode.get("label").and_then(Value::as_str)) + .unwrap_or(&id) + .to_string(); + mode.insert(String::from("name"), Value::String(name)); + serde_json::from_value(Value::Object(mode)).ok() + }) + .collect(); + Some(SessionModeState { + current_mode_id, + available_modes, + }) +} + /// Synthesize the unsupported-config JSON-RPC error response (`-32601`). Mirrors /// `_unsupportedConfigResponse`. fn unsupported_config_response(agent_type: &str, category: &str) -> JsonRpcResponse { @@ -983,7 +1093,7 @@ impl AgentOs { .config_overrides .lock() .entry(format!("{PENDING_METHOD_PREFIX}{resolver_id}")) - .or_insert_with(|| method.to_string()); + .or_insert_with(|| Value::String(method.to_string())); Ok(()) })??; let mut pending_request_guard = @@ -1071,12 +1181,12 @@ impl AgentOs { let config_id = params .and_then(|p| p.get("configId")) .and_then(Value::as_str); - let value = params.and_then(|p| p.get("value")).and_then(Value::as_str); + let value = params.and_then(|p| p.get("value")).cloned(); if let (Some(config_id), Some(value)) = (config_id, value) { let mut options = entry.config_options.lock(); for option in options.iter_mut() { if option.id == config_id { - option.current_value = Some(value.to_string()); + option.current_value = Some(value.clone()); } } } @@ -1165,6 +1275,7 @@ impl AgentOs { agent_type: &str, options: CreateSessionOptions, ) -> Result { + validate_additional_directories(&options.additional_directories)?; // The client is npm-agnostic: it sends only the agent name. The sidecar // resolves the name -> package -> entrypoint/env/launchArgs from the // projected `/opt/agentos//current/agentos-package.json` and spawns. @@ -1182,6 +1293,7 @@ impl AgentOs { let client_capabilities = json!({ "fs": { "readTextFile": true, "writeTextFile": true }, "terminal": true, + "session": { "configOptions": { "boolean": {} } }, }); let tool_reference = build_host_tool_reference(&self.config().tool_kits); let additional_instructions = @@ -1195,6 +1307,7 @@ impl AgentOs { args: Vec::new(), env: env.into_iter().collect(), cwd, + additional_directories: options.additional_directories, mcp_servers: serde_json::to_string(&mcp_servers).map_err(|error| { ClientError::Sidecar(format!("failed to encode MCP servers: {error}")) })?, @@ -1253,6 +1366,7 @@ impl AgentOs { agent_type: agent_type.to_string(), modes: parking_lot::Mutex::new(None), config_options: parking_lot::Mutex::new(Vec::new()), + usage: parking_lot::Mutex::new(None), capabilities: parking_lot::Mutex::new(None), agent_info: parking_lot::Mutex::new(None), config_overrides: parking_lot::Mutex::new(BTreeMap::new()), @@ -1294,6 +1408,7 @@ impl AgentOs { agent_type: &str, options: ResumeSessionOptions, ) -> Result { + validate_additional_directories(&options.additional_directories)?; // The client is npm-agnostic: it sends only the agent name. The sidecar // resolves the name -> package -> entrypoint/env/launchArgs from the // projected manifest, exactly as `create_session` does. @@ -1303,6 +1418,11 @@ impl AgentOs { .cwd .clone() .unwrap_or_else(|| "/workspace".to_string()); + let mcp_servers: Vec = options + .mcp_servers + .iter() + .filter_map(|server| serde_json::to_value(server).ok()) + .collect(); let response = self .send_acp_request(AcpRequest::AcpResumeSessionRequest( @@ -1311,6 +1431,10 @@ impl AgentOs { agent_type: agent_type.to_string(), transcript_path: options.transcript_path.clone(), cwd, + additional_directories: options.additional_directories, + mcp_servers: serde_json::to_string(&mcp_servers).map_err(|error| { + ClientError::Sidecar(format!("failed to encode MCP servers: {error}")) + })?, env: env.into_iter().collect(), }, )) @@ -1457,7 +1581,7 @@ impl AgentOs { let overrides = entry.config_overrides.lock(); for (key, method) in overrides.iter() { if let Some(id) = key.strip_prefix(PENDING_METHOD_PREFIX) { - if method == "session/prompt" { + if method.as_str() == Some("session/prompt") { if let Ok(id) = id.parse::() { prompt_resolver_ids.push(id); } @@ -1743,12 +1867,50 @@ impl AgentOs { .await?) } + /// Set a stable ACP select or boolean configuration option by id. + pub async fn set_session_config_option( + &self, + session_id: &str, + config_id: &str, + value: Value, + ) -> Result { + let (read_only, category, agent_type) = self.require_session(session_id, |entry| { + let options = entry.config_options.lock(); + let option = options.iter().find(|option| option.id == config_id); + ( + option.and_then(|option| option.read_only).unwrap_or(false), + option.and_then(|option| option.category.clone()), + entry.agent_type.clone(), + ) + })?; + if read_only { + return Ok(unsupported_config_response( + &agent_type, + category.as_deref().unwrap_or(config_id), + )); + } + Ok(self + .send_session_request( + session_id, + "session/set_config_option", + Some(json!({ "configId": config_id, "value": value })), + ) + .await?) + } + /// Get cached config options (shallow copy). pub fn get_session_config_options(&self, session_id: &str) -> Vec { self.require_session(session_id, |entry| entry.config_options.lock().clone()) .unwrap_or_default() } + /// Get the most recent stable ACP `usage_update` for this live session. + pub fn get_session_usage(&self, session_id: &str) -> Option { + self.require_session(session_id, |entry| entry.usage.lock().clone()) + .ok() + .flatten() + } + /// Get cached capabilities. Mirrors `getSessionCapabilities`: returns `null` (`None`) when the /// stored capabilities object has no keys (`Object.keys(caps).length === 0`). pub fn get_session_capabilities(&self, session_id: &str) -> Option { @@ -1953,6 +2115,23 @@ impl AgentOs { mod prompt_accumulation_tests { use super::*; + #[test] + fn session_modes_normalize_stable_names_and_legacy_labels() { + let modes = normalize_session_modes(&json!({ + "currentModeId": "plan", + "availableModes": [ + { "id": "default", "name": "Default", "_meta": { "source": "test" } }, + { "id": "plan", "label": "Plan" }, + { "name": "missing id" }, + ], + })) + .expect("valid modes"); + assert_eq!(modes.available_modes.len(), 2); + assert_eq!(modes.available_modes[0].name, "Default"); + assert_eq!(modes.available_modes[1].name, "Plan"); + assert_eq!(modes.available_modes[0].extra["_meta"]["source"], "test"); + } + fn notification(update: Value) -> JsonRpcNotification { JsonRpcNotification { jsonrpc: "2.0".to_string(), @@ -2026,6 +2205,7 @@ mod prompt_accumulation_tests { agent_type: "pi".to_string(), modes: parking_lot::Mutex::new(None), config_options: parking_lot::Mutex::new(Vec::new()), + usage: parking_lot::Mutex::new(None), capabilities: parking_lot::Mutex::new(None), agent_info: parking_lot::Mutex::new(None), config_overrides: parking_lot::Mutex::new(BTreeMap::new()), diff --git a/crates/native-sidecar/src/execution.rs b/crates/native-sidecar/src/execution.rs index ef336720d7..9691a3cf35 100644 --- a/crates/native-sidecar/src/execution.rs +++ b/crates/native-sidecar/src/execution.rs @@ -11249,7 +11249,9 @@ fn host_node_modules_root(path: &Path) -> Option { #[cfg(test)] mod runtime_guest_path_mapping_tests { - use super::{host_node_modules_root, javascript_sync_rpc_option_bool}; + use super::{ + host_node_modules_root, javascript_sync_rpc_option_bool, mount_config_runtime_path_mapping, + }; use serde_json::json; use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/examples/processes/node-shell.ts b/examples/processes/node-shell.ts new file mode 100644 index 0000000000..85369eb67c --- /dev/null +++ b/examples/processes/node-shell.ts @@ -0,0 +1,19 @@ +import { createClient } from "@rivet-dev/agentos/client"; +import { attachShell } from "@rivet-dev/agentos/node"; +import type { registry } from "./server"; + +const client = createClient({ + endpoint: "http://localhost:6420", +}); +const agent = client.vm.getOrCreate("node-shell"); +const connection = agent.connect(); + +try { + process.exitCode = await attachShell(connection, { + command: "bash", + args: ["--input-backend", "minimal", "-i"], + cwd: "/home/agentos", + }); +} finally { + await connection.dispose(); +} diff --git a/experiments/gigacode/COMPATIBILITY.md b/experiments/gigacode/COMPATIBILITY.md new file mode 100644 index 0000000000..cd89f45f8c --- /dev/null +++ b/experiments/gigacode/COMPATIBILITY.md @@ -0,0 +1,130 @@ +# OpenCode compatibility audit + +This document tracks Gigacode against the OpenCode 1.17.18 TUI and its +`@opencode-ai/sdk/v2` client. The compatibility target is the attached OpenCode +TUI backed by AgentOS sessions. It is not a promise to reproduce OpenCode's +unrelated local server facilities such as PTY hosting, provider OAuth, LSP +management, or worktree synchronization. + +The status column means: + +- **Supported**: exercised end to end through the real TUI or SDK. +- **Implemented**: added after the audit and covered by a regression test. +- **Client-local**: OpenCode implements the behavior without a server route. +- **Honest empty**: the schema is valid and an empty result accurately means + AgentOS has no objects of that type to expose. +- **Unsupported**: outside the compatibility target and not advertised as + working. + +## User workflows + +| Workflow | Baseline | Final status | Evidence or remaining boundary | +| --- | --- | --- | --- | +| Start `gigacode` without a daemon | Supported | Supported | CLI autospawn E2E | +| Reuse one daemon from multiple cwd values | Supported | Supported | Multi-workspace E2E proves one workspace actor per canonical cwd and multiple sessions per actor | +| List harnesses and models | Supported | Supported | Cold/warm provider and real `/models` TUI E2E | +| Create, list, select, rename, and delete sessions | Partial | Implemented | SDK create/list/update/delete against the SQLite coordinator, cwd isolation, and real TUI-created sessions | +| Resume a conversation after daemon restart | Partial | Implemented | Durable projected transcript; the next turn creates a fresh ACP process with bounded transcript handoff because process IDs are daemon-generation-local | +| Prompt synchronously and asynchronously | Partial | Implemented | SDK sync prompts and exact 204 async response | +| Stream text and reasoning | Missing | Implemented | ACP text/reasoning updates map to OpenCode deltas; text is exercised with tool streaming | +| Display agent tool calls and results | Missing | Implemented | Real Claude tool lifecycle projected into OpenCode tool parts | +| Queue prompts entered during an active turn | Missing | Implemented | FIFO SDK and real TUI queued-turn regression tests | +| Cancel a running turn and continue the conversation | Broken | Implemented | SDK/TUI abort returns immediately; the next turn awaits the real ACP cancellation and reuses the same AgentOS/ACP session | +| Run `!` shell commands | Supported | Supported | SDK and real TUI success, failure, cancellation, and reuse E2E | +| Show and answer permission requests | Partial | Implemented | Permission asked/replied lifecycle, both reply routes, and cleanup E2E | +| Use `@file` completion and file prompt parts | Missing | Implemented | Bounded v2 `/api/fs/find`; file/agent/subtask parts become ACP prompt context | +| Open the Rivet debugger from OpenCode | Missing | Implemented | `/command` discovery and real TUI `/gigacode-debugger` execution | +| `/new`, `/sessions`, `/models`, `/agents` | Partial | Supported | TUI owns dialogs; attached-server session/model/agent data is schema-compatible | +| `/help`, `/debug`, `/themes`, `/timestamps`, `/thinking` | Client-local | Client-local | OpenCode owns these dialogs/toggles | +| `/move`, `/editor`, `/copy`, `/export`, `/exit` | Client-local | Client-local | OpenCode owns these local terminal/filesystem actions | +| `/mcps`, `/skills` with no configured entries | Honest empty | Honest empty | Gigacode returns schema-correct empty collections | +| `/diff`, `/timeline`, todos, children | Honest empty | Honest empty | AgentOS does not currently project OpenCode snapshots/diffs/todos/subsessions | +| `/share` and `/unshare` | False advertisement | Unsupported | `/config` declares sharing disabled; Gigacode has no share host | +| `/compact`, `/init` | False success | Explicit boundary | Compact returns unsupported; init runs an actual AgentOS instruction-analysis prompt | +| `/fork`, `/undo`, `/redo` | Missing | Unsupported | Requires conversation/filesystem snapshot semantics AgentOS does not expose | +| Questions | Missing | Unsupported | AgentOS exposes permission callbacks but no question callback | + +## TUI bootstrap and events + +| Surface | Baseline | Final status | Notes | +| --- | --- | --- | --- | +| `GET /global/health` | Supported | Supported | Includes Gigacode/Rivet health extensions | +| `GET /global/event`, `GET /event` | Partial | Implemented | Monotonic SSE IDs, replay, and per-directory filtering E2E | +| `GET /config/providers`, `GET /provider` | Partial | Implemented | v2 provider/model schema plus legacy aliases; cold discovery and warm global cache E2E | +| `GET /config`, `PATCH /config` | Partial | Partial | Read is compatible and disables sharing; patches are accepted for the request but not persisted | +| `GET /agent` | Partial | Supported | One schema-compatible build agent; harnesses remain provider/model groups | +| `GET /path` | Supported | Supported | Correct machine and active-directory paths | +| `GET /project`, `/project/current` | Partial | Implemented | Local project, current project, and project directories route | +| `GET /command` | Honest empty | Implemented | Advertises the Gigacode debugger command | +| `GET /mcp`, `/lsp`, `/formatter`, `/skill` | Honest empty | Honest empty | No corresponding AgentOS projection exists yet | +| `GET /vcs`, `/vcs/diff`, `/vcs/status` | Partial | Honest empty | Branch remains a display fallback; no OpenCode-owned VCS snapshot source exists | +| `GET /provider/auth` | Honest empty | Honest empty | Host credentials are mounted; Gigacode does not broker provider OAuth | +| `/experimental/capabilities`, `/experimental/console` | Unsupported | Unsupported | OpenCode explicitly tolerates these 404 responses | + +## Session API + +| SDK method | HTTP route | Baseline | Final status | +| --- | --- | --- | --- | +| `session.list` | `GET /session` | Partial: leaked all cwd values and was unsorted | Implemented: SQLite-coordinator-backed, cwd-filtered, and stable-sorted | +| `session.create` | `POST /session` | Supported basic actor creation | Implemented: creates a coordinator record and reuses the cwd workspace actor | +| `session.get/update/delete` | `/session/{id}` | Supported basic paths | Implemented coordinator lifecycle and ACP-session cleanup coverage | +| `session.status` | `GET /session/status` | Partial and race-prone | Implemented FIFO prompt/shell busy-idle lifecycle | +| `session.messages/message` | `GET /session/{id}/message*` | Memory-only | Implemented bounded atomic persistence across daemon restart | +| `session.prompt` | `POST /session/{id}/message` | Completed text only | Implemented rich prompt parts, deltas, tools, permissions, and typed errors | +| `session.promptAsync` | `POST /session/{id}/prompt_async` | Wrong 200 JSON response and unsafe concurrency | Implemented exact 204 plus bounded FIFO | +| `session.abort` | `POST /session/{id}/abort` | Destructive | Implemented real turn/shell cancellation with a bounded per-session completion barrier | +| `session.shell` | `POST /session/{id}/shell` | Supported command/output | Implemented success/error/cancellation and consistent lifecycle | +| `session.command` | `POST /session/{id}/command` | Missing | Implemented debugger command | +| `session.children/todo/diff` | GET subroutes | Honest empty | Honest empty | +| `session.summarize/init` | POST subroutes | False success | Summarize is explicit 501; init runs a real prompt | +| `session.fork/share/unshare/revert/unrevert` | POST/DELETE subroutes | Missing | Unsupported until AgentOS exposes required semantics | +| Message/part mutation | DELETE/PATCH message/part routes | Missing | Unsupported; attached TUI does not need it for normal turns | + +## Permission API + +The OpenCode 1.17.18 TUI uses `GET /permission`, the `permission.asked` event, +and `POST /permission/{requestID}/reply`. The legacy SDK also exposes +`POST /session/{id}/permissions/{permissionID}`. Gigacode now supports both +reply paths, emits asked/replied events, rejects abandoned waits, and cleans up +requests after completion, cancellation, or deletion. The E2E suite performs a +real Claude tool request, grants it once, and verifies the resulting tool part. + +## Harness switching + +An OpenCode model change can also change the AgentOS harness. Gigacode closes +the previous ACP session, creates the requested harness session in the same +actor, and prefixes the next prompt with a bounded textual transcript. The +OpenCode session and its durable messages remain stable. Harness-private state +and tool-side state do not cross this boundary; the handoff is intentionally a +conversation projection rather than ACP session migration. + +## Deliberate non-goals + +These SDK groups belong to a full OpenCode server rather than the AgentOS-backed +TUI layer. They remain unsupported unless AgentOS gains a corresponding source +of truth: + +- PTY create/list/update/connect routes (Gigacode has its own `shell` command). +- Provider OAuth and credential mutation; host-native Claude/Codex/Pi auth is + mounted into each VM instead. +- MCP add/connect/disconnect/auth management. +- LSP, formatter, and tool-registry management. +- OpenCode worktree and cross-device sync APIs. +- Share hosting. +- OpenCode-owned VCS apply/diff snapshots, fork, revert, and unrevert semantics. +- TUI remote-control queue routes; an attached local TUI handles its own input. + +## Regression standard + +A feature is not marked implemented until it has the narrowest applicable SDK +test and, for visible terminal behavior, a tmux snapshot test using the real +OpenCode binary. The acceptance suite must cover at least: + +1. legacy and v2 SDK bootstrap, +2. session directory isolation and TUI switching, +3. transcript persistence across daemon restart, +4. streaming text/tool events, +5. FIFO queued turns, +6. cancellation followed by another successful turn in the same ACP session, +7. slash command discovery and the Gigacode debugger command, +8. shell mode and normal multi-turn ordering. diff --git a/experiments/gigacode/GOOSE_MIGRATION_PLAN.md b/experiments/gigacode/GOOSE_MIGRATION_PLAN.md new file mode 100644 index 0000000000..95c95669dd --- /dev/null +++ b/experiments/gigacode/GOOSE_MIGRATION_PLAN.md @@ -0,0 +1,481 @@ +# Gigacode Goose Migration Specification + +## Status + +This is the implementation specification. The product decisions below are +locked. Implementation has not started. + +## Product Decisions + +1. **Full Goose TUI over ACP.** Gigacode exists to put Goose's rich Ink TUI on + top of AgentOS. The Rust interactive CLI is not an acceptable substitute. +2. **Dynamic AgentOS agent discovery.** Gigacode never hard-codes agent IDs. + Every selectable agent comes from the live AgentOS instance's + `listAgents()` response. +3. **Turn cancellation is not session cancellation.** Cancelling interrupts + only the active prompt. The same session and actor remain usable. +4. **Persistent, resumable sessions.** There is exactly one Rivet actor per + Goose session. Exiting or disconnecting Goose detaches without destroying + it. Sessions can be listed, loaded, resumed, and explicitly deleted. +5. **Testing backends.** Automated integration and E2E tests use deterministic + LLMock fixtures. The mandatory manual tmux test uses a real authenticated + agent session. +6. **Platform scope.** Packaging and manual acceptance target this host only: + Linux x64 (`linux-x64`, currently Linux 6.1 x86_64). +7. **Remove all OpenCode functionality.** Remove OpenCode from the entire + repository and from every installed/transitive Gigacode dependency—not only + from `experiments/gigacode`. + +## Target Architecture + +```text +Pinned Goose Ink TUI + │ @aaif/goose-sdk / ACP + ▼ +Custom pinned `goose acp` server + │ custom Goose ProviderDef: gigacode-acp + ▼ +`gigacode acp` provider process + │ ACP ↔ Gigacode session/actor bridge + ▼ +Gigacode daemon + local Rivet engine + │ + ├── bounded durable session catalog (listing/index only) + │ + ├── Goose session A ──► AgentOS actor A ──► one inner ACP agent session + ├── Goose session B ──► AgentOS actor B ──► one inner ACP agent session + └── Goose session C ──► AgentOS actor C ──► one inner ACP agent session + │ + ▼ + live `/opt/agentos` agent registry +``` + +There are two deliberate ACP layers: + +- The Ink TUI is an ACP client of the custom Goose core. +- Goose's `gigacode-acp` provider is an ACP client of `gigacode acp`. + +This preserves the full Goose interface while making AgentOS—not Goose—the +owner of execution, tools, permissions, agent discovery, and durable agent +sessions. + +## Source-of-Truth Invariants + +- The projected `/opt/agentos` registry is the only agent catalog. +- `AgentOs.listAgents()` is called live; Gigacode does not parse package + manifests or infer agents from npm dependencies. +- One outer Goose session maps to exactly one Rivet actor. +- An actor owns at most one current inner AgentOS ACP session. +- The actor's durable state owns execution state and the outer-to-inner session + mapping. Process memory and Goose's local database are never authoritative. +- The daemon maintains a bounded durable catalog containing only list metadata + and actor IDs. This prevents `session/list` from waking every sleeping actor. + It is an index, not an execution coordinator. +- The catalog is reconciled with Rivet actor enumeration at daemon startup and + before paginated listings. Actor existence and actor-owned mappings win every + conflict; missing index rows are lazily rebuilt with bounded concurrency. +- Cancelling a prompt calls AgentOS turn cancellation and preserves the actor, + mapping, transcript, and ability to send the next prompt. +- Closing the TUI, closing an ACP transport, or stopping the bridge detaches; + none of those actions deletes an actor. +- Only an explicit user delete destroys the actor and its durable session + state. +- Host-side Goose profiles, built-in tools, and extensions are disabled. Tools + run through the selected AgentOS agent inside the VM; Goose must not provide + a host-side bypass around AgentOS. + +## Persistent Session Model + +Each actor stores one bounded canonical record equivalent to: + +```ts +type GigacodeSessionRecord = { + sessionId: string; // Stable outer Goose/ACP session ID and actor key + actorId: string; + agentType?: string; // Exact ID returned by AgentOS listAgents() + innerSessionId?: string; // AgentOS ACP session ID + cwd: string; + title: string; + createdAt: number; + updatedAt: number; + status: "new" | "idle" | "prompting" | "interrupted" | "unavailable"; +}; +``` + +The record and transcript/event history must be durable across bridge exit, +Goose exit, daemon restart, and actor sleep/wake. Metadata and event collections +remain bounded and expose typed limit errors. + +The daemon catalog denormalizes only `sessionId`, `actorId`, `agentType`, `cwd`, +`title`, timestamps, and availability for fast pagination. Every create, +metadata update, unavailable transition, and delete updates the actor first and +then the catalog with an idempotent reconciliation marker. A catalog loss must +be recoverable from Rivet actors without losing a session. + +### New session + +1. Goose calls `session/new` with a workspace. +2. Gigacode creates one actor whose stable key is the outer session ID. +3. The actor calls live `listAgents()` and returns those installed agents as an + ACP `Model`-category selection option. +4. `GIGACODE_DEFAULT_AGENT`, when set and present in the reported list, selects + the default. Otherwise the first stable-sorted reported ID is the default. +5. No inner session is created until the user selects an agent and sends the + first prompt. +6. On the first prompt, the actor creates one AgentOS session with the selected + reported ID and persists the returned inner session ID. + +If the AgentOS instance reports zero agents, session creation succeeds so the +TUI can explain the empty catalog, but prompting returns a typed +`no_agents_available` error. + +### List and restore + +1. Goose requests `session/list`. +2. Gigacode reads a bounded, paginated catalog page and reconciles its actor IDs + against non-destroyed Rivet actors. +3. It wakes actors only to rebuild missing/stale rows, with bounded concurrency, + and returns stable IDs, title, cwd, agent ID, timestamps, and availability. +4. Goose calls `session/load` or `session/resume` for the selected ID. +5. Gigacode resolves the existing actor by ID; it never creates a replacement + actor for a known session. +6. If the inner session is already live, the bridge attaches to it. After actor + sleep/wake or daemon restart, the actor calls AgentOS `resumeSession()` with + the persisted inner ID, reported agent ID, cwd, environment, and transcript + continuation path. +7. Native adapter resume is preferred; AgentOS transcript fallback is accepted + and the external session ID remains stable even if AgentOS returns a new live + inner ID. +8. If the persisted agent is no longer reported by `listAgents()`, listing marks + the session `unavailable`; restore returns a typed `agent_unavailable` error + without changing or deleting its state. + +### Cancel, detach, and delete + +- `session/cancel` forwards to AgentOS `cancelSession()` for the current inner + session. The in-flight prompt returns ACP `cancelled`; the actor returns to + `interrupted`/`idle`, and the next prompt reuses the same actor and session. +- ACP EOF, Goose exit, and TUI exit detach listeners and release process-local + resources. The actor remains resumable. +- `session/close` means detach, not delete, for the Gigacode provider. +- The Goose TUI delete action must reach an explicit Gigacode delete operation. + Delete closes the inner session if live, destroys the Rivet actor, removes it + from listings, and is idempotent. + +## Implementation Workstreams + +### 1. Remove OpenCode repository-wide + +Delete OpenCode product functionality and references, including: + +- `registry/agent/opencode` and its upstream patch/build machinery. +- `examples/opencode` and custom/process example references. +- OpenCode session/headless helpers and tests under `packages/core/tests`. +- OpenCode-specific branches in core runtime/session behavior. +- `@agentos-software/opencode`, `opencode-ai`, and `@opencode-ai/sdk` + dependencies and lockfile edges. +- Root/package READMEs, website agent docs, generated registry/routes, marketing + copy, logos, and architecture/session-persistence examples. +- Workspace lifecycle allowlists and publish/registry discovery references. +- Gigacode compatibility routes, state projections, imports, launcher logic, + installer repair, environment variables, and documentation. + +Regenerate derived website/registry output and the secure-exec compatibility +mirror when affected public surfaces require it. Do not remove unrelated words +that merely contain the character sequence `opencode` unless they refer to the +OpenCode product. + +### 2. Extend the AgentOS Rivet actor action surface + +AgentOS core already implements live discovery, turn cancellation, and resume, +but the native Rivet actor currently exposes only create/prompt/close and +persisted-event reads. Add actor actions for: + +- `listAgents()` returning the live sidecar registry entries. +- `cancelSession(innerSessionId)` preserving session usability. +- `resumeSession(innerSessionId, agentType, options)` returning native/fallback + mode and the current live inner ID. +- Read/write of the single actor-owned `GigacodeSessionRecord` or a generic + bounded metadata facility suitable for it. +- Explicit actor/session deletion and any transcript export path required by + resume fallback. + +Update the native actor plugin, generated TypeScript action declarations, +inspector surfaces, error mapping, and tests together. Preserve TypeScript/Rust +client behavioral parity for any core public API changes. + +### 3. Replace Gigacode's OpenCode HTTP API with ACP + +Implement `gigacode acp` using the ACP SDK compatibility version pinned with +Goose: + +- `initialize`, `session/new`, `session/list`, `session/load`, + `session/resume`, `session/prompt`, `session/cancel`, and detach/close. +- A Gigacode extension/custom request for explicit deletion if the pinned ACP + version lacks a standard delete operation. +- Live AgentOS agent selection surfaced as ACP `Model` config values without + hard-coded IDs. +- Streaming text, thought, plan, tool-call, and tool-result notifications rich + enough for the Goose TUI. +- Permission request/reply forwarding with Goose in `approve` mode. +- Stable outer IDs and remapping of resumed inner session IDs. +- A bounded SQLite session catalog under Gigacode state with pagination, + idempotent actor-first updates, startup reconciliation against Rivet actors, + and lazy recovery from actor-owned metadata. +- Bounded concurrent listing, sessions, prompts, event buffers, pending + permissions, input, stderr, and shutdown. +- JSON-RPC-only stdout and bounded diagnostics on stderr. +- Typed errors for unavailable agents, unknown sessions, limits, invalid state, + unsupported content, and failed native/fallback resume. + +Keep only the daemon health/admin endpoints needed for startup, debugger, +session observation, and explicit deletion. Do not create a Goose-compatible +REST API. + +### 4. Build the full Goose ACP/TUI integration + +Pin one exact Goose source commit/release and matching: + +- `@aaif/goose-sdk`. +- Ink TUI package from `@aaif/goose`/`ui/text`. +- Goose Rust binary. +- `@agentclientprotocol/sdk` compatibility version. +- Gigacode Goose patch checksum. + +The custom Goose patch must: + +- Register `gigacode-acp` as a real Rust `ProviderDef` and inventory/catalog + entry whose command is `gigacode acp`. +- Delegate dynamic agent/model config, list, load, resume, cancel, and delete to + the Gigacode ACP provider. +- Preserve the rich TUI's session browser, transcript, streaming tool UI, + permission prompts, interruption controls, and session restoration. +- Avoid provider assumptions that ACP sessions cannot be listed or resumed. +- Disable host-side Goose tools/extensions for Gigacode sessions. + +`gigacode` launches the pinned Ink TUI connected to the explicit custom +`goose acp` binary. It must not fall back to a global Goose, optional upstream +binary, or `npx @aaif/goose@latest`. `gigacode run` uses the same custom Goose +core in headless mode. + +### 5. Package, document, and operate + +- Package the custom Goose binary, Ink TUI, SDK runtime, Gigacode ACP bridge, + AgentOS sidecar/plugin, source lock, checksums, licenses, and modification + notice for this Linux x64 host. +- Keep installation self-contained and recoverable without checkout-local + `node_modules` or post-install network fallback. +- Isolate Goose configuration with a Gigacode-owned `GOOSE_PATH_ROOT` while + keeping Gigacode's session list actor-backed. +- Mount real host credentials only for the explicit live/manual path. Automated + tests use synthetic credentials and isolated HOME/XDG paths. +- Document the full-host trust model, dynamic agent catalog, persistence, + cancellation, resume modes, deletion, and recovery behavior. + +## Acceptance Gate 1: Goose SDK Full-Stack E2E + +```bash +pnpm --dir experiments/gigacode test:e2e +``` + +Install into temporary deployment directories and use only installed artifacts. +The test explicitly spawns the installed custom `goose acp`, wraps its +stdin/stdout with `ndJsonStream`, and constructs `GooseClient` from the pinned +`@aaif/goose-sdk`. + +Required chain: + +```text +GooseClient + -> custom goose acp + -> gigacode-acp provider + -> installed gigacode acp + -> daemon/Rivet + -> persistent AgentOS actor/session + -> LLMock +``` + +The gate passes only if it proves: + +1. Exact Goose/SDK/TUI/ACP versions, patch identity, and binary hashes. +2. Isolated HOME, XDG, Goose, Gigacode, Rivet, ports, and synthetic credentials; + no user-global Goose profile, tool, extension, or binary is loaded. +3. SDK initialization negotiates the pinned ACP version and exposes + `gigacode-acp`. +4. A new session creates exactly one actor. Its selectable agents exactly equal + that actor's live `listAgents()` response—no missing or extra IDs. +5. Selecting an LLMock-configured reported agent creates one inner session. + Two ordered streaming chunks and rich tool notifications arrive before the + exact successful prompt stop reason. +6. A second prompt reuses the same outer ID, actor ID, agent ID, and inner ID. +7. A held third prompt is cancelled only after a recorded request/first chunk. + It resolves `cancelled`; actor and inner session IDs remain unchanged; a + sentinel fourth prompt succeeds in the same session. +8. Goose and its bridge exit without deleting the actor. A fresh custom Goose + process lists the session, loads it, restores its transcript, resolves the + same actor, resumes the inner session through native or documented fallback, + and completes another deterministic prompt. +9. A second separately created Goose session produces a different actor, proving + the one-to-one mapping. Listing returns both exactly once. +10. Explicit deletion removes only the selected actor/session. The remaining + session still prompts successfully; deleting it returns actor count to the + baseline. +11. Owned processes exit, ports can be rebound, no unhandled rejection appears, + and successful temporary test state is removed only after explicit deletes. +12. Runtime recovery restores removed deployed dependencies without consulting + the checkout or network and repeats the session-list smoke check. + +LLMock must use deterministic multi-chunk, cancellation, tool-call, and resume +fixtures. Failure artifacts include bounded stdout/stderr, daemon logs, LLMock +requests, session/actor/inner IDs, resume mode, revisions, hashes, PIDs, ports, +and the named timeout that fired. + +## Acceptance Gate 2: Direct ACP and Actor Integration + +```bash +pnpm --dir experiments/gigacode test:integration +``` + +Drive `gigacode acp` directly with `@agentclientprotocol/sdk`, without Goose. +Use independent temporary state for each group. + +### Dynamic discovery + +- Test AgentOS instances reporting zero, one, and multiple synthetic agents. +- Assert the ACP selection values exactly match each live report and update on a + new session after the projected registry changes. +- Assert default selection uses a valid `GIGACODE_DEFAULT_AGENT` or the stable + sorted fallback, never a hard-coded ID. +- Assert a removed persisted agent makes an existing session unavailable but + preserves it for later recovery. + +### Actor/session lifecycle + +- Assert new, prompt, detach, list, load, resume, explicit delete, and idempotent + delete transitions against stable outer/actor IDs. +- Force actor sleep/wake, daemon restart, bridge EOF, bridge `SIGTERM`, and + bridge force-kill. None may destroy or duplicate the actor. +- Delete/corrupt the daemon catalog, restart it, and assert bounded + reconciliation rebuilds listings from the existing actors without creating + actors or losing outer-to-inner mappings. +- Assert native resume preserves the inner ID; fallback resume atomically stores + the replacement live inner ID while preserving the outer ID. +- Saturate the session/list limit and assert warning, typed error/pagination + behavior, bounded concurrency, and no unbounded actor fan-out. + +### Turn cancellation + +- Synchronize on an LLMock request or first chunk, then call `session/cancel`. +- Assert AgentOS receives `cancelSession()`, the prompt resolves `cancelled`, no + post-cancel chunks leak into the next turn, and the same inner session answers + a sentinel prompt. +- Test idle/unknown cancel, repeated cancel, cancellation during a permission + request, and cancellation racing prompt completion. + +### Mounts and permissions + +- Use disposable workspaces and synthetic Claude/Codex-style credential + directories; never automated access to real home credentials. +- Verify workspace/full-host projection and both read-write/read-only credential + policies with exact host-visible effects and POSIX errors. +- Script deterministic reject and approve tool calls. Rejection has no side + effect; approval completes; permission and tool event IDs remain correlated + through Goose-compatible ACP notifications. + +### Protocol hygiene and errors + +- Test invalid version, unknown method/session/agent, invalid params, concurrent + prompts, unsupported content/MCP servers, each configured limit plus one, + child exit, broken pipe, invalid JSON, and stdout pollution. +- Recoverable cases return exact JSON-RPC code plus `data.type`, followed by a + successful valid request. Unparseable framing may close with bounded stderr. +- Every operation and poll has a named timeout; no promise, listener, permission, + child, actor, or buffer leaks. + +## Acceptance Gate 3: Live Goose TUI in tmux + +This mandatory test uses the installed rich Ink TUI, an agent reported by the +real AgentOS instance, and the tester's real authenticated session. It must run +in a disposable workspace. Evidence must redact credentials, prompts containing +secrets, and provider response metadata that could identify the account. + +Start a persistent shell and continuous capture before Goose: + +```bash +artifact_dir=$(mktemp -d /tmp/gigacode-goose-live.XXXXXX) +session=gigacode-goose-live +tmux new-session -d -s "$session" "$SHELL" +tmux set-option -t "$session" remain-on-exit on +tmux pipe-pane -o -t "$session" "cat >> '$artifact_dir/tmux.log'" +tmux send-keys -t "$session" \ + "GIGACODE_STATE_DIR='$artifact_dir/state' GOOSE_MODE=approve gigacode" Enter +tmux attach-session -t "$session" +``` + +The tester records pass/fail for each item: + +1. The full Goose TUI renders without onboarding and shows `gigacode-acp` plus + exactly the agents reported by the real AgentOS actor. +2. Selecting one reported agent and sending a real prompt creates one actor and + streams text/tool progress through the rich TUI. +3. Workspace read and write prompts affect only known disposable marker files; + a second pane verifies exact contents. +4. A real permission request can be rejected with no side effect, then approved + with the expected side effect. +5. Interrupt a visibly active turn. The turn reports cancellation, then a new + prompt succeeds in the same TUI session and debugger-visible actor/inner IDs + remain unchanged. +6. Exit Goose to the persistent shell without deleting the actor. Relaunch + `gigacode`, open the session browser, find the prior session, restore it, see + prior transcript context, and complete another real prompt using the same + actor. +7. Create a second session and verify it owns a different actor. List both, + switch between them, and confirm isolation. +8. Delete one session in the TUI and verify only its actor disappears. Delete the + second, stop the daemon, and verify no actors, owned PIDs, ports, or marker + files remain. + +Continuous `pipe-pane` output is primary evidence; `capture-pane` is +supplemental. The redacted bundle records commands/exit codes, host OS/arch, +Goose/Gigacode revisions and hashes, reported agent IDs, actor/inner/outer IDs, +resume mode, and checklist results. + +```bash +pnpm --dir experiments/gigacode manual:verify --artifact-dir "$artifact_dir" +``` + +## Final Validation + +All commands exit zero: + +```bash +pnpm --dir experiments/gigacode check-types +pnpm --dir experiments/gigacode test:integration +pnpm --dir experiments/gigacode test:e2e +bash -n experiments/gigacode/install-global.sh +node scripts/verify-fixed-versions.mjs +``` + +Repository-wide removal criteria: + +- `registry/agent/opencode`, `examples/opencode`, OpenCode-specific tests, + helpers, docs, images, and generated registry entries do not exist. +- No committed package manifest or lockfile edge references + `@agentos-software/opencode`, `opencode-ai`, or `@opencode-ai/sdk`. +- No runtime, client, website, example, installer, publish, or documentation + surface references the OpenCode product. The migration specification is the + only allowed historical reference. +- Generated website/registry artifacts and the secure-exec mirror are current. + +Goose/Gigacode criteria: + +- The TUI agent catalog equals live AgentOS reports and contains no hard-coded + agent allowlist. +- One actor per session holds across create, cancel, detach, list, restore, + daemon restart, actor sleep/wake, and delete. +- Automated E2E, direct integration, and verified live tmux evidence all refer + to the same Gigacode revision and custom Goose build. +- The installed Linux x64 runtime is self-contained, version/checksum verified, + license-complete, and has no checkout or network fallback. +- AgentOS product versions remain `0.0.1` in committed files. +- Unrelated working-copy changes remain untouched. diff --git a/experiments/gigacode/README.md b/experiments/gigacode/README.md new file mode 100644 index 0000000000..287d0b4881 --- /dev/null +++ b/experiments/gigacode/README.md @@ -0,0 +1,256 @@ +# Gigacode (AgentOS experiment) + +This is the next-generation Gigacode experiment: the vanilla OpenCode TUI and +GUI backed by AgentOS actors over RivetKit. + +```text +OpenCode attach + -> Gigacode OpenCode-compatible API (:2468) + -> RivetKit client + -> one SQLite coordinator actor (:2469) + -> one AgentOS workspace actor per canonical cwd + -> many logical sessions using Claude Code, Codex, Pi, or OpenCode +``` + +The implementation intentionally lives in one TypeScript entrypoint, +[`gigacode.ts`](./gigacode.ts). Running it with no subcommand is the client; +`daemon` is the server. The client health-checks and autospawns the daemon before +running `opencode attach`. `gigacode run ...` uses OpenCode's headless +`run --attach` mode against the same autospawned daemon. + +The daemon is machine-wide rather than workspace-bound. It exposes the OpenCode +API before the Rivet engine finishes booting so OpenCode can render immediately. +The coordinator actor owns the durable session index in embedded SQLite. A +canonical host cwd maps to one durable AgentOS actor whose creation input mounts +that directory at `/workspace`; every OpenCode session has independent durable +metadata and transcript state inside the shared workspace actor. Gigacode keeps +at most one ACP harness process active in an actor at a time because the current +AgentOS event stream does not reliably multiplex multiple live Claude ACP +processes in one VM. Switching logical sessions closes the prior ACP process and +rehydrates the selected one with a bounded transcript handoff. Starting +Gigacode from another directory reuses the same daemon and does not restart +Rivet. + +Agent model catalogs are discovered from each harness's ACP session config and +stored in one machine-wide cache at `$GIGACODE_STATE_DIR/models.json`. OpenCode +loads providers before rendering its TUI, so the first `/provider` request waits +for discovery when that cache does not exist. Later daemon starts serve the +cache immediately and refresh it in the background. + +When the client autospawns the daemon, it mirrors plain `[gigacode]` startup +milestones to the terminal until provider bootstrap finishes. Durations are +shown beside completed phases, while detailed output remains in +`$GIGACODE_STATE_DIR/daemon.log`; per-session Pino JSONL is never mixed into the +interactive startup display. + +## Local workspace mode + +**Gigacode intentionally has read-write access to the selected host workspace. +It is not a security boundary and must not be used to run untrusted prompts or +software.** Other host directories are not mounted, apart from the explicit +credential directories below. + +Every AgentOS VM receives these mounts: + +```text +Guest path Host path Access +/workspace read-write +/home/agentos/.claude ~/.claude read-write +/home/agentos/.codex ~/.codex read-write +/home/agentos/.pi ~/.pi read-write +``` + +Gigacode adds AgentOS system instructions explaining that commands execute in +the local AgentOS VM, not Docker, and that `/workspace` is the active project. +Writes through `/workspace` affect the real project immediately using the +daemon user's Unix permissions. + +## Run + +From the repository root: + +```bash +pnpm install +pnpm --filter @rivet-dev/agentos-experiment-gigacode... build +pnpm --dir experiments/gigacode gigacode +``` + +To install the `gigacode` command globally from this checkout, run the bundled +installer. It creates a self-contained production deployment under +`~/.local/share/gigacode` and writes a launcher to `~/.local/bin` by default: + +```bash +pnpm --dir experiments/gigacode install-global +gigacode +gc +``` + +Set `GIGACODE_INSTALL_BIN_DIR` to choose a different executable directory or +`GIGACODE_INSTALL_ROOT` to choose a different runtime directory. The installer +also creates `gc` as a short alias for `gigacode`. The installed command does +not depend on the checkout's `node_modules` remaining present. The deployment +keeps a runtime archive and automatically restores its own `node_modules` if a +machine cleanup removes generated dependency trees. + +Useful commands: + +```bash +gigacode daemon # foreground daemon +gigacode daemon start # detached daemon +gigacode daemon status +gigacode daemon stop +gigacode shell # shell in the cwd's shared workspace actor +gigacode shell sh # optionally choose the guest command and arguments +gigacode run --model claude/default "Summarize this workspace" +gigacode debugger [actorID] # open the local Rivet inspector +``` + +`gigacode shell` attaches a PTY to the same per-cwd workspace actor used by +OpenCode sessions. Exiting the shell closes the connection but leaves the +durable workspace actor available for later sessions. + +Configuration: + +- `GIGACODE_PORT` — OpenCode-compatible API port (default `2468`) +- `GIGACODE_RIVET_PORT` — local Rivet engine port (default `2469`, deliberately + not RivetKit's conventional `6420`) +- `GIGACODE_OPENCODE_BIN` — OpenCode executable (default `opencode`, with an + `npx --yes opencode-ai` fallback) +- `GIGACODE_INSPECTOR_URL` — local inspector base URL (default + `http://localhost:43708/`) +- `GIGACODE_WORKSPACE` — fallback directory when an OpenCode request does not + send its cwd; it does not bind or restart the central daemon +- `GIGACODE_SESSION_ENV_JSON` — bounded JSON string map forwarded to every ACP + harness session (for example, provider credentials or a test endpoint) +- `GIGACODE_LOOPBACK_EXEMPT_PORTS` — comma-separated host loopback ports made + reachable from the AgentOS VM +- `GIGACODE_NETWORK_PERMISSION` — optional AgentOS network override, `allow` or + `deny`; unset preserves AgentOS's restricted default policy +- `GIGACODE_SHARE_HOST_CREDENTIALS=0` — disable the default host credential + mounts +- `GIGACODE_CREDENTIALS_READ_ONLY=1` — mount host credentials read-only; this + prevents agent writes but may prevent OAuth refresh persistence +- `GIGACODE_CLAUDE_CONFIG_DIR` / `GIGACODE_CODEX_HOME` / `GIGACODE_PI_HOME` — + override the host credential directories (defaults: `~/.claude`, `~/.codex`, + and `~/.pi`) +- `GIGACODE_PI_API_KEY` / `GIGACODE_PI_BASE_URL` — optional Pi-only Anthropic + provider overrides, primarily useful for local gateways and deterministic + tests; these values are not added to other harness sessions +- `GIGACODE_LOG_LEVEL` — Pino level for per-session structured logs (default + `info`) +- `GIGACODE_MAX_PROMPT_QUEUE_PER_SESSION` — maximum queued turns for one + session (default `64`) +- `GIGACODE_MAX_MESSAGES_PER_SESSION` — maximum projected OpenCode messages in + one session (default `10000`) +- `GIGACODE_MAX_MESSAGE_STORE_BYTES` — maximum durable message-store size + (default `67108864`) +- `GIGACODE_MAX_FS_FIND_SCAN` / `GIGACODE_MAX_FS_FIND_RESULTS` — bounds for + OpenCode's recursive file completion (defaults `50000` and `200`) +- `GIGACODE_MAX_HARNESS_HANDOFF_BYTES` — maximum transcript supplied when a + session switches harnesses (default `65536`) +- `GIGACODE_CANCEL_QUIESCE_TIMEOUT_MS` — maximum time the next turn waits for + the same AgentOS session to acknowledge cancellation (default `30000`) + +Each OpenCode session receives an asynchronous Pino JSONL log at +`$GIGACODE_STATE_DIR/session-logs/.jsonl` (by default under +`~/.local/state/gigacode`). Phase events include `durationMs` fields for actor +setup, ACP session creation, prompting, idle delivery, and connection disposal. + +## Host Claude, Codex, and Pi authentication + +By default, Gigacode mounts the host's existing `~/.claude`, `~/.codex`, and +`~/.pi` directories at their corresponding `/home/agentos` paths in every +AgentOS VM. The mounts are writable so each harness can refresh its own OAuth +tokens and persist them back to the host. This makes the native logins available +without copying credentials into Gigacode state. + +The Claude AgentOS adapter reads OAuth credentials by default. Set +`CLAUDE_CODE_BARE=1` or `CLAUDE_CODE_SIMPLE=1` on the daemon only when +intentionally opting into Claude's API-key-only minimal modes. + +Pi uses its own native host authentication. Install and log in to Pi once: + +```bash +npm install -g @mariozechner/pi-coding-agent +pi +# Enter /login and select Anthropic Claude Pro/Max, OpenAI Codex, or another provider. +``` + +Pi stores OAuth credentials in `~/.pi/agent/auth.json`. Gigacode mounts the +entire `~/.pi` directory into every VM, so Pi reads that file directly and +persists refreshes back to the host. Gigacode does not translate or copy Claude +or Codex tokens into Pi's credential format. + +Gigacode also forwards only a fixed allowlist of provider variables when they +are set on the daemon: Anthropic/OpenAI keys and base URLs, Claude Bedrock or +Vertex switches, and the AWS credential-chain variables. Values in +`GIGACODE_SESSION_ENV_JSON` override this allowlist. + +These are trusted host mounts exposed to an untrusted agent VM. A harness can +read and modify the mounted Claude, Codex, and Pi configuration, not only the +token files. Set `GIGACODE_CREDENTIALS_READ_ONLY=1` to prevent writes, or +`GIGACODE_SHARE_HOST_CREDENTIALS=0` to isolate Gigacode completely. + +The current AgentOS `@agentos-software/codex` package projects Codex commands +but deliberately has no ACP entrypoint. The host Codex login is mounted and +ready, but a runnable Codex choice requires wiring an upstream Codex ACP adapter +into that AgentOS registry package; credential sharing alone cannot supply the +missing harness. + +## End-to-end tests + +The E2E suite starts LLMock, invokes the Gigacode client in headless `run` mode, +and verifies that the client autospawns a fresh daemon and local Rivet engine. +The OpenCode v2 TypeScript SDK exercises provider discovery, file search, +session CRUD and directory isolation, durable resume, multiple turns, FIFO +queueing, cancellation, permissions, streamed tool calls, shell success/error/ +cancellation, Claude/Pi prompting, harness switching, SSE replay, and debugger +command discovery. The reusable [`TmuxTerminal`](./tmux-terminal.ts) driver then +launches the globally installed `gigacode` command and drives the real OpenCode +TUI through ordered multi-turn prompts, model selection, cancellation, queued +input, `!` shell mode, and slash commands while retaining terminal snapshots. + +Build the native AgentOS artifacts once, then run the suite: + +```bash +cargo build -p agentos-actor-plugin -p agentos-sidecar +pnpm --dir experiments/gigacode test:e2e +``` + +No model API keys are read. The E2E daemon receives an isolated session +environment and allows network access only for that test process so Claude can +reach LLMock on its random loopback port. + +## Deliberate experiment boundaries + +- The OpenCode provider groups are AgentOS harnesses, not inference providers. + Models within each group come from the harness's ACP model config option. A + harness that does not advertise models retains a `default` fallback. Changing + harnesses closes the old ACP session and creates a new one with a bounded text + transcript handoff; this preserves conversational context but not harness- + private state. +- Session listing comes from a dedicated coordinator actor whose embedded + SQLite database stores the OpenCode session-to-workspace/ACP-session mapping. + The bounded OpenCode message projection remains beside Rivet state, while + AgentOS persists the underlying ACP event history in each workspace actor. + ACP process IDs are valid only for one daemon generation; after a restart, + Gigacode preserves the logical session and projected messages, then creates a + fresh ACP process with transcript handoff on the next turn. +- One machine-wide daemon serves every cwd. Each request supplies its directory; + a canonical cwd selects one workspace actor and its creation input mounts only + that host directory at `/workspace`. Deleting a session closes its ACP session + but intentionally retains the reusable workspace actor. +- Recursive file completion and file prompt parts are supported. OpenCode PTY + hosting, LSP/formatter management, sharing, questions, worktrees, fork, + revert, and OpenCode filesystem snapshots remain explicit non-goals rather + than successful no-ops. +- `gigacode debugger` opens the inspector directly. OpenCode also discovers a + `gigacode-debugger` custom command through `/command`; invoking it asks the + daemon to open the inspector for the current AgentOS actor. +- `/diff`, todos, children, MCPs, skills, LSPs, and formatters return schema- + correct empty collections because Gigacode has no corresponding AgentOS + objects. Sharing is disabled in `/config`; unsupported mutation routes return + an explicit error. + +The audited route and workflow matrix, including intentional omissions, is in +[`COMPATIBILITY.md`](./COMPATIBILITY.md). diff --git a/experiments/gigacode/gigacode.e2e.test.ts b/experiments/gigacode/gigacode.e2e.test.ts new file mode 100644 index 0000000000..88e19a9cb8 --- /dev/null +++ b/experiments/gigacode/gigacode.e2e.test.ts @@ -0,0 +1,1924 @@ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { + mkdir, + mkdtemp, + open, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { homedir, tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { type Fixture, LLMock } from "@copilotkit/llmock"; +import { createOpencodeClient } from "@opencode-ai/sdk"; +import { createOpencodeClient as createV2OpencodeClient } from "@opencode-ai/sdk/v2"; +import { createClient } from "@rivet-dev/agentos/client"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { TmuxTerminal } from "./tmux-terminal"; + +const ROOT = resolve(import.meta.dirname, "../.."); +const ENTRYPOINT = resolve(import.meta.dirname, "gigacode.ts"); +const TSX_IMPORT = createRequire(import.meta.url).resolve("tsx"); +const OPENCODE_BIN = resolve(import.meta.dirname, "node_modules/.bin/opencode"); +const GLOBAL_GIGACODE_BIN = resolve(homedir(), ".local/bin/gigacode"); +const RESPONSE_TEXT = "GIGACODE_E2E_OK"; +const QUEUE_FIRST_RESPONSE = "GIGACODE_QUEUE_FIRST_OK"; +const QUEUE_SECOND_RESPONSE = "GIGACODE_QUEUE_SECOND_OK"; +const CANCEL_FOLLOWUP_RESPONSE = "GIGACODE_CANCEL_FOLLOWUP_OK"; +const TOOL_FINAL_RESPONSE = "GIGACODE_TOOL_FINAL_OK"; +const TUI_QUEUE_FIRST_RESPONSE = "GIGACODE_TUI_QUEUE_FIRST_OK"; +const TUI_QUEUE_SECOND_RESPONSE = "GIGACODE_TUI_QUEUE_SECOND_OK"; +const MAX_DAEMON_LOG_BYTES = 1024 * 1024; + +type RunningDaemon = { + apiUrl: string; + env: NodeJS.ProcessEnv; + logs: () => string; + stateDir: string; +}; + +type StartedGigacode = { + cliOutput: string; + daemon: RunningDaemon; +}; + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolveReady, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveReady); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("could not allocate a local test port"); + } + await new Promise((resolveClosed, reject) => + server.close((error) => (error ? reject(error) : resolveClosed())), + ); + return address.port; +} + +function appendLog(current: string, chunk: Buffer): string { + const next = current + chunk.toString("utf8"); + return next.length > MAX_DAEMON_LOG_BYTES + ? next.slice(-MAX_DAEMON_LOG_BYTES) + : next; +} + +async function within( + promise: Promise, + label: string, + daemon: RunningDaemon, + timeoutMs = 30_000, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `${label} timed out after ${timeoutMs}ms:\n${daemon.logs()}`, + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +async function startGigacode(mockUrl: string): Promise { + const apiPort = await freePort(); + const rivetPort = await freePort(); + const stateDir = await mkdtemp(resolve(tmpdir(), "gigacode-e2e-")); + const claudeConfigDir = resolve(stateDir, "host-claude"); + const codexHome = resolve(stateDir, "host-codex"); + const piHome = resolve(stateDir, "host-pi"); + await mkdir(claudeConfigDir); + await mkdir(codexHome); + await mkdir(piHome); + await writeFile( + resolve(claudeConfigDir, ".credentials.json"), + `${JSON.stringify({ + gigacodeTest: "claude-host-credentials", + claudeAiOauth: { + accessToken: "sk-ant-oat01-gigacode-e2e", + refreshToken: "sk-ant-ort01-gigacode-e2e", + expiresAt: Date.now() + 60 * 60 * 1_000, + scopes: ["user:inference"], + subscriptionType: "max", + rateLimitTier: null, + }, + })}\n`, + ); + await writeFile( + resolve(codexHome, "auth.json"), + '{"gigacodeTest":"codex-host-credentials"}\n', + ); + await writeFile( + resolve(piHome, "settings.json"), + '{"gigacodeTest":"pi-host-configuration"}\n', + ); + const pluginPath = + process.env.AGENTOS_PLUGIN_BIN ?? + resolve(ROOT, "target/debug/libagentos_actor_plugin.so"); + const sidecarPath = + process.env.AGENTOS_SIDECAR_BIN ?? + resolve(ROOT, "target/debug/agentos-sidecar"); + for (const path of [pluginPath, sidecarPath]) { + if (!existsSync(path)) { + throw new Error( + `Gigacode E2E requires ${path}; build the native AgentOS artifacts first`, + ); + } + } + + const mockPort = Number(new URL(mockUrl).port); + const env: NodeJS.ProcessEnv = { + ...process.env, + AGENTOS_PLUGIN_BIN: pluginPath, + AGENTOS_SIDECAR_BIN: sidecarPath, + GIGACODE_PORT: String(apiPort), + GIGACODE_RIVET_PORT: String(rivetPort), + GIGACODE_STATE_DIR: stateDir, + GIGACODE_WORKSPACE: ROOT, + GIGACODE_SOFTWARE_DIR: resolve(homedir(), ".local/share/gigacode/software"), + GIGACODE_LOOPBACK_EXEMPT_PORTS: String(mockPort), + GIGACODE_NETWORK_PERMISSION: "allow", + GIGACODE_CLAUDE_CONFIG_DIR: claudeConfigDir, + GIGACODE_CODEX_HOME: codexHome, + GIGACODE_PI_HOME: piHome, + GIGACODE_OPENCODE_BIN: OPENCODE_BIN, + GIGACODE_DISABLE_OPEN_URL: "1", + GIGACODE_SESSION_ENV_JSON: JSON.stringify({ + ANTHROPIC_BASE_URL: mockUrl, + CLAUDE_CODE_TRACE_ADAPTER_MESSAGES: + process.env.GIGACODE_E2E_TRACE === "1" ? "1" : "0", + GIGACODE_TUI_SHELL_VALUE: "GIGACODE_TUI_SHELL_OK", + }), + GIGACODE_PI_API_KEY: "mock-key", + GIGACODE_PI_BASE_URL: mockUrl, + }; + const daemon: RunningDaemon = { + apiUrl: `http://127.0.0.1:${apiPort}/opencode`, + env, + logs: () => { + try { + return readFileSync(resolve(stateDir, "daemon.log"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } + }, + stateDir, + }; + const child = spawn( + process.execPath, + [ + "--import", + TSX_IMPORT, + ENTRYPOINT, + "run", + "--model", + "claude/default", + "Return the deterministic test response.", + ], + { + cwd: ROOT, + stdio: ["ignore", "pipe", "pipe"], + env, + }, + ); + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output = appendLog(output, chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output = appendLog(output, chunk); + }); + + const status = await within( + new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", resolveExit); + }), + "gigacode run with daemon autospawn", + daemon, + 120_000, + ).catch((error) => { + child.kill("SIGKILL"); + return stopDaemon(daemon).then( + () => Promise.reject(error), + (cleanupError) => + Promise.reject( + new Error( + `${String(error)}\nstartup cleanup failed: ${String(cleanupError)}`, + ), + ), + ); + }); + if (status !== 0) { + const error = new Error( + `gigacode run exited with status ${status}:\n${output}\n${daemon.logs()}`, + ); + await stopDaemon(daemon).catch((cleanupError) => { + error.message += `\nstartup cleanup failed: ${String(cleanupError)}`; + }); + throw error; + } + const health = await fetch(`${daemon.apiUrl}/global/health`, { + signal: AbortSignal.timeout(1_000), + }); + if (!health.ok) { + const error = new Error( + `Gigacode client exited without a healthy daemon:\n${output}\n${daemon.logs()}`, + ); + await stopDaemon(daemon).catch((cleanupError) => { + error.message += `\nstartup cleanup failed: ${String(cleanupError)}`; + }); + throw error; + } + return { cliOutput: output, daemon }; +} + +async function stopDaemon(daemon: RunningDaemon | undefined): Promise { + if (!daemon) return; + await terminateDaemon(daemon); + await rm(daemon.stateDir, { recursive: true, force: true }); +} + +async function terminateDaemon(daemon: RunningDaemon): Promise { + const health = await fetch(`${daemon.apiUrl}/global/health`) + .then((response) => response.json() as Promise<{ rivetEndpoint?: string }>) + .catch(() => undefined); + const pid = Number( + (await readFile(resolve(daemon.stateDir, "daemon.pid"), "utf8")).trim(), + ); + process.kill(pid, "SIGTERM"); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") break; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + try { + process.kill(pid, 0); + throw new Error(`Gigacode daemon shutdown timed out:\n${daemon.logs()}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + if (health?.rivetEndpoint) { + const engine = await fetch(`${health.rivetEndpoint}/health`, { + signal: AbortSignal.timeout(1_000), + }).catch(() => undefined); + if (engine?.ok) { + throw new Error( + `Gigacode daemon exited but its Rivet engine remained at ${health.rivetEndpoint}`, + ); + } + } +} + +async function restartDaemon(daemon: RunningDaemon): Promise { + await terminateDaemon(daemon); + const log = await open(resolve(daemon.stateDir, "daemon.log"), "a", 0o600); + const child = spawn( + process.execPath, + ["--import", TSX_IMPORT, ENTRYPOINT, "daemon"], + { + cwd: ROOT, + detached: true, + stdio: ["ignore", log.fd, log.fd], + env: daemon.env, + }, + ); + child.unref(); + await log.close(); + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + try { + const response = await fetch(`${daemon.apiUrl}/global/health`, { + signal: AbortSignal.timeout(1_000), + }); + if (response.ok) return; + } catch { + // The old socket may still be closing or the restarted engine is starting. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Gigacode daemon did not restart:\n${daemon.logs()}`); +} + +async function readSessionLog( + daemon: RunningDaemon, + sessionId: string, + ready: (records: Array>) => boolean = () => true, +): Promise>> { + const path = resolve(daemon.stateDir, "session-logs", `${sessionId}.jsonl`); + const deadline = Date.now() + 10_000; + let raw = ""; + while (Date.now() < deadline) { + try { + raw = await readFile(path, "utf8"); + if (raw.trim()) { + const records = raw + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + if (ready(records)) return records; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`session log was not written at ${path}:\n${raw}`); +} + +async function activeActorIds(daemon: RunningDaemon): Promise { + const healthResponse = await fetch(`${daemon.apiUrl}/global/health`); + if (!healthResponse.ok) { + throw new Error(`Gigacode health failed: ${healthResponse.status}`); + } + const health = (await healthResponse.json()) as { rivetEndpoint: string }; + const response = await fetch( + `${health.rivetEndpoint}/actors?namespace=default&name=vm`, + ); + if (!response.ok) { + throw new Error(`Rivet actor list failed: ${response.status}`); + } + const body = (await response.json()) as { + actors?: Array<{ actor_id: string; destroy_ts?: number | null }>; + }; + return (body.actors ?? []) + .filter((actor) => !actor.destroy_ts) + .map((actor) => actor.actor_id) + .sort(); +} + +async function sessionActorId( + daemon: RunningDaemon, + sessionId: string, +): Promise { + const records = await readSessionLog(daemon, sessionId, (items) => + items.some( + (record) => + record.event === "rivet.actor.resolved" && + typeof record.actorId === "string", + ), + ); + const actorId = records.find( + (record) => record.event === "rivet.actor.resolved", + )?.actorId; + if (typeof actorId !== "string") { + throw new Error(`session ${sessionId} did not log its workspace actor ID`); + } + return actorId; +} + +async function waitForCondition( + condition: () => Promise | boolean, + label: string, + daemon: RunningDaemon, + timeoutMs = 90_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`${label} timed out after ${timeoutMs}ms:\n${daemon.logs()}`); +} + +async function postPromptAsync( + daemon: RunningDaemon, + sessionId: string, + text: string, +): Promise { + return fetch(`${daemon.apiUrl}/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: { providerID: "claude", modelID: "default" }, + parts: [{ type: "text", text }], + }), + }); +} + +async function readSseUntil( + response: Response, + predicate: (value: unknown) => boolean, + timeoutMs = 10_000, +): Promise> { + if (!response.body) throw new Error("SSE response has no body"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const records: Array<{ id?: string; value: unknown }> = []; + let buffer = ""; + const deadline = Date.now() + timeoutMs; + try { + while (Date.now() < deadline) { + const remaining = deadline - Date.now(); + let timer: NodeJS.Timeout | undefined; + const result = await Promise.race([ + reader.read(), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error("SSE read timed out")), + remaining, + ); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); + if (result.done) break; + buffer += decoder.decode(result.value, { stream: true }); + for (;;) { + const boundary = buffer.indexOf("\n\n"); + if (boundary === -1) break; + const block = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const data = block + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()) + .join("\n"); + if (!data) continue; + const id = block + .split("\n") + .find((line) => line.startsWith("id:")) + ?.slice(3) + .trim(); + const value: unknown = JSON.parse(data); + records.push({ ...(id ? { id } : {}), value }); + if (predicate(value)) return records; + } + } + throw new Error( + `SSE predicate was not satisfied: ${JSON.stringify(records)}`, + ); + } finally { + await reader.cancel().catch(() => undefined); + } +} + +describe("Gigacode OpenCode remote API", () => { + let mock: LLMock; + let daemon: RunningDaemon; + let cliOutput: string; + let cliSessionId: string; + let sdkSessionId: string; + let piModelID: string; + let piModelName: string; + + beforeAll(async () => { + const fixtures: Fixture[] = [ + { + match: { + predicate: (request) => + request.messages.at(-1)?.role === "tool" && + JSON.stringify(request.messages).includes("GIGACODE_TOOL_STREAM"), + }, + response: { content: TOOL_FINAL_RESPONSE }, + }, + { + match: { userMessage: "GIGACODE_TOOL_STREAM" }, + response: { + toolCalls: [ + { + name: "Bash", + arguments: '{"command":"printf GIGACODE_TOOL_EXECUTION_OK"}', + }, + ], + }, + }, + { + match: { userMessage: "GIGACODE_QUEUE_FIRST" }, + response: { content: QUEUE_FIRST_RESPONSE }, + latency: 750, + }, + { + match: { userMessage: "GIGACODE_QUEUE_SECOND" }, + response: { content: QUEUE_SECOND_RESPONSE }, + }, + { + match: { userMessage: "GIGACODE_CANCEL_SLOW" }, + response: { content: "THIS_RESPONSE_MUST_BE_CANCELLED" }, + latency: 10_000, + }, + { + match: { userMessage: "GIGACODE_TUI_CANCEL" }, + response: { content: "THIS_TUI_RESPONSE_MUST_BE_CANCELLED" }, + latency: 10_000, + }, + { + match: { userMessage: "GIGACODE_TUI_QUEUE_FIRST" }, + response: { content: TUI_QUEUE_FIRST_RESPONSE }, + latency: 2_000, + }, + { + match: { userMessage: "GIGACODE_TUI_QUEUE_SECOND" }, + response: { content: TUI_QUEUE_SECOND_RESPONSE }, + }, + { + match: { + predicate: (request) => + JSON.stringify(request.messages.at(-1)).includes( + "GIGACODE_CANCEL_FOLLOWUP", + ), + }, + response: { content: CANCEL_FOLLOWUP_RESPONSE }, + }, + { + match: { predicate: () => true }, + response: { content: RESPONSE_TEXT }, + }, + ]; + mock = new LLMock({ port: 0, logLevel: "silent" }); + mock.addFixtures(fixtures); + const mockUrl = await mock.start(); + ({ cliOutput, daemon } = await startGigacode(mockUrl)); + const sessions = (await ( + await fetch( + `${daemon.apiUrl}/session?directory=${encodeURIComponent(ROOT)}`, + ) + ).json()) as Array<{ id: string }>; + cliSessionId = sessions[0]?.id as string; + }, 90_000); + + afterAll(async () => { + await stopDaemon(daemon); + await mock?.stop(); + }, 45_000); + + test("autospawns the daemon and prompts through the Gigacode CLI", async () => { + expect(cliOutput).toContain(RESPONSE_TEXT); + expect(cliOutput).toContain("[gigacode] starting the local daemon; log:"); + expect(cliOutput).toContain( + "[gigacode] loading AgentOS and RivetKit modules", + ); + expect(cliOutput).toContain( + "[gigacode] SQLite session coordinator is ready", + ); + expect(cliOutput).toContain( + "[gigacode] discovering models from AgentOS harnesses", + ); + expect(cliOutput).not.toContain('"service":"gigacode"'); + const startupOutput = daemon.logs(); + expect(startupOutput).toContain("[gigacode] OpenCode API is listening at"); + expect(startupOutput).toContain( + "[gigacode] loading AgentOS and RivetKit modules", + ); + expect(startupOutput).toContain( + "[gigacode] SQLite session coordinator is ready", + ); + expect(startupOutput).toContain( + "[gigacode] discovering models from AgentOS harnesses", + ); + expect(mock.getRequests().length).toBeGreaterThan(0); + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const listed = await within( + client.session.list(), + "CLI session.list", + daemon, + ); + expect(listed.error).toBeUndefined(); + expect(listed.data).toHaveLength(1); + expect( + (listed.data?.[0] as { providerID?: string } | undefined)?.providerID, + ).toBe("claude"); + expect(listed.data?.[0]?.id).toBe(cliSessionId); + }, 90_000); + + test("serves the exact v2 SDK bootstrap, file search, and debugger command", async () => { + const client = createV2OpencodeClient({ + baseUrl: daemon.apiUrl, + directory: ROOT, + }); + const health = await within( + client.global.health(), + "v2 global.health", + daemon, + ); + expect(health.error).toBeUndefined(); + expect(health.data?.healthy).toBe(true); + const providers = await within( + client.provider.list(), + "v2 provider.list", + daemon, + ); + expect(providers.error).toBeUndefined(); + expect(providers.data?.all).toHaveLength(4); + expect(providers.data?.all[0]?.source).toBe("env"); + const files = await within( + client.v2.fs.find({ + location: { directory: ROOT }, + query: "gigacode.ts", + type: "file", + limit: "10", + }), + "v2 fs.find", + daemon, + ); + expect(files.error).toBeUndefined(); + expect(files.data?.data.map((entry) => entry.path)).toContain( + "experiments/gigacode/gigacode.ts", + ); + const commands = await within( + client.command.list(), + "v2 command.list", + daemon, + ); + expect(commands.data).toContainEqual( + expect.objectContaining({ name: "gigacode-debugger" }), + ); + const sessions = await client.session.list(); + const commandSessionId = sessions.data?.[0]?.id; + expect(commandSessionId).toBeTruthy(); + const executed = await fetch( + `${daemon.apiUrl}/session/${commandSessionId}/command`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + command: "gigacode-debugger", + arguments: "", + }), + }, + ); + expect(executed.ok, await executed.clone().text()).toBe(true); + expect(await executed.text()).toContain("Rivet inspector"); + const diff = await fetch(`${daemon.apiUrl}/vcs/diff?mode=git`); + expect(diff.ok).toBe(true); + expect(await diff.json()).toEqual([]); + }, 30_000); + + test("replays SSE events by ID and filters session events by directory", async () => { + const sessions = (await ( + await fetch( + `${daemon.apiUrl}/session?directory=${encodeURIComponent(ROOT)}`, + ) + ).json()) as Array<{ id: string }>; + const rootSessionId = sessions[0]?.id; + expect(rootSessionId).toBeTruthy(); + const eventUrl = `${daemon.apiUrl}/event?directory=${encodeURIComponent(ROOT)}`; + const firstResponse = await fetch(eventUrl); + const firstEvents = readSseUntil(firstResponse, (value) => { + const event = value as { + type?: string; + properties?: { info?: { title?: string } }; + }; + return ( + event.type === "session.updated" && + event.properties?.info?.title === "SSE first" + ); + }); + await fetch(`${daemon.apiUrl}/session/${rootSessionId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "SSE first" }), + }); + const firstRecords = await firstEvents; + const firstUpdated = firstRecords.find((record) => { + const event = record.value as { type?: string }; + return event.type === "session.updated"; + }); + expect(firstUpdated?.id).toBeTruthy(); + expect((firstUpdated?.value as { id?: string } | undefined)?.id).toBe( + firstUpdated?.id, + ); + + await fetch(`${daemon.apiUrl}/session/${rootSessionId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "SSE replayed" }), + }); + const replayResponse = await fetch(eventUrl, { + headers: { "last-event-id": firstUpdated?.id as string }, + }); + const replayed = await readSseUntil(replayResponse, (value) => { + const event = value as { + type?: string; + properties?: { info?: { title?: string } }; + }; + return ( + event.type === "session.updated" && + event.properties?.info?.title === "SSE replayed" + ); + }); + expect( + replayed.some( + (record) => Number(record.id) > Number(firstUpdated?.id as string), + ), + ).toBe(true); + + const otherDirectory = resolve(daemon.stateDir, "sse-other"); + await mkdir(otherDirectory); + const otherSession = (await ( + await fetch( + `${daemon.apiUrl}/session?directory=${encodeURIComponent(otherDirectory)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "SSE other" }), + }, + ) + ).json()) as { id: string }; + const filteredResponse = await fetch(eventUrl); + const filteredEvents = readSseUntil(filteredResponse, (value) => { + const event = value as { + type?: string; + properties?: { info?: { title?: string } }; + }; + return ( + event.type === "session.updated" && + event.properties?.info?.title === "SSE root final" + ); + }); + await fetch(`${daemon.apiUrl}/session/${otherSession.id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "SSE must be filtered" }), + }); + await fetch(`${daemon.apiUrl}/session/${rootSessionId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "SSE root final" }), + }); + const filtered = await filteredEvents; + expect(JSON.stringify(filtered)).not.toContain("SSE must be filtered"); + const removed = await fetch(`${daemon.apiUrl}/session/${otherSession.id}`, { + method: "DELETE", + }); + expect(removed.ok).toBe(true); + }, 60_000); + + test("opens a shell in the cwd workspace actor without creating another", async () => { + const actorsBefore = await activeActorIds(daemon); + const child = spawn( + process.execPath, + ["--import", TSX_IMPORT, ENTRYPOINT, "shell"], + { + cwd: ROOT, + stdio: ["pipe", "pipe", "pipe"], + env: daemon.env, + }, + ); + let output = ""; + child.stdout?.on("data", (chunk: Buffer) => { + output = appendLog(output, chunk); + }); + child.stderr?.on("data", (chunk: Buffer) => { + output = appendLog(output, chunk); + }); + child.stdin?.end("printf 'GIGACODE_SHELL_OK\\n'\nexit\n"); + const status = await within( + new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", resolveExit); + }), + "gigacode shell", + daemon, + 90_000, + ); + expect(status, output).toBe(0); + expect(output).toContain("GIGACODE_SHELL_OK"); + + const deadline = Date.now() + 10_000; + let actorsAfter = await activeActorIds(daemon); + while ( + Date.now() < deadline && + JSON.stringify(actorsAfter) !== JSON.stringify(actorsBefore) + ) { + await new Promise((resolve) => setTimeout(resolve, 100)); + actorsAfter = await activeActorIds(daemon); + } + expect(actorsAfter).toEqual(actorsBefore); + }, 120_000); + + test("lists harnesses, creates and lists a Rivet actor, and prompts through AgentOS", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const healthResponse = await fetch(`${daemon.apiUrl}/global/health`); + expect(healthResponse.ok).toBe(true); + const health = (await healthResponse.json()) as { + rivetEndpoint: string; + workspaceRoot: string; + rivetStartupStage: string; + modelCatalogStage: string; + }; + expect(health).toMatchObject({ + workspaceRoot: "/workspace", + rivetStartupStage: "Rivet runtime is ready", + }); + + const providers = await within( + client.provider.list(), + "provider.list", + daemon, + ); + expect(providers.error).toBeUndefined(); + const readyHealth = (await ( + await fetch(`${daemon.apiUrl}/global/health`) + ).json()) as { modelCatalogStage: string }; + expect(readyHealth.modelCatalogStage).toBe("model catalog is ready"); + expect(providers.data?.all.map((provider) => provider.id).sort()).toEqual([ + "claude", + "codex", + "opencode", + "pi", + ]); + const piProvider = providers.data?.all.find( + (provider) => provider.id === "pi", + ) as + | { models?: Record } + | undefined; + const piModels = Object.values(piProvider?.models ?? {}); + expect(piModels.length, daemon.logs()).toBeGreaterThan(1); + expect(piModels.some((model) => model.id?.startsWith("anthropic/"))).toBe( + true, + ); + const piTestModel = + piModels.find( + (model) => model.id === "anthropic/claude-3-5-haiku-latest", + ) ?? + piModels.find((model) => model.id?.startsWith("anthropic/")) ?? + piModels[0]; + piModelID = piTestModel?.id as string; + piModelName = piTestModel?.name as string; + expect(piModelID).toBeTruthy(); + expect(piModelName).toBeTruthy(); + const cache = JSON.parse( + await readFile(resolve(daemon.stateDir, "models.json"), "utf8"), + ) as { version?: number; providers?: Record }; + expect(cache.version).toBe(1); + expect(Object.keys(cache.providers ?? {}).sort()).toEqual([ + "claude", + "codex", + "opencode", + "pi", + ]); + + const initial = await within( + client.session.list(), + "initial session.list", + daemon, + ); + expect(initial.error).toBeUndefined(); + expect(initial.data?.map((session) => session.id)).toEqual([cliSessionId]); + + const created = await within( + client.session.create({ body: { title: "LLMock E2E" } }), + "session.create", + daemon, + 60_000, + ); + expect(created.error, daemon.logs()).toBeUndefined(); + const sessionId = created.data?.id; + expect(sessionId).toBeTruthy(); + sdkSessionId = sessionId as string; + const sdkActorId = await sessionActorId(daemon, sdkSessionId); + const cliActorId = await sessionActorId(daemon, cliSessionId); + expect(sdkSessionId).not.toBe(sdkActorId); + expect(sdkActorId).toBe(cliActorId); + + const listed = await within( + client.session.list(), + "populated session.list", + daemon, + ); + expect(listed.data?.map((session) => session.id)).toContain(sessionId); + const coordinators = (await ( + await fetch( + `${health.rivetEndpoint}/actors?namespace=default&name=coordinator`, + ) + ).json()) as { + actors?: Array<{ actor_id: string; destroy_ts?: number | null }>; + }; + const coordinatorId = coordinators.actors?.find( + (actor) => !actor.destroy_ts, + )?.actor_id; + expect(coordinatorId).toBeTruthy(); + const coordinatorSessions = await createClient({ + endpoint: health.rivetEndpoint, + }) + .coordinator.getForId(coordinatorId as string) + .listSessions(); + expect( + coordinatorSessions.map((session: { id: string }) => session.id), + ).toEqual(expect.arrayContaining([cliSessionId, sdkSessionId])); + + const actor = createClient({ + endpoint: health.rivetEndpoint, + }).vm.getForId(sdkActorId); + expect( + new TextDecoder().decode( + await actor.readFile("/home/agentos/.claude/.credentials.json"), + ), + ).toContain("claude-host-credentials"); + expect( + new TextDecoder().decode( + await actor.readFile("/home/agentos/.codex/auth.json"), + ), + ).toContain("codex-host-credentials"); + expect( + new TextDecoder().decode( + await actor.readFile("/home/agentos/.pi/settings.json"), + ), + ).toContain("pi-host-configuration"); + expect( + new TextDecoder().decode( + await actor.readFile("/workspace/experiments/gigacode/package.json"), + ), + ).toContain("@rivet-dev/agentos-experiment-gigacode"); + await expect(actor.readFile("/host/etc/hosts")).rejects.toThrow(); + await actor.writeFile( + "/home/agentos/.codex/gigacode-write-through", + "credential refresh persistence", + ); + expect( + await readFile( + resolve(daemon.stateDir, "host-codex/gigacode-write-through"), + "utf8", + ), + ).toBe("credential refresh persistence"); + + for (const turn of ["first", "second"]) { + const prompted = await within( + client.session.prompt({ + path: { id: sessionId as string }, + body: { + model: { providerID: "claude", modelID: "default" }, + parts: [ + { + type: "text", + text: `Return the deterministic test response for the ${turn} turn.`, + }, + ], + }, + }), + `${turn} session.prompt`, + daemon, + 60_000, + ).catch((error) => { + throw new Error( + `${String(error)}\nLLMock requests: ${JSON.stringify(mock.getRequests())}`, + ); + }); + expect(prompted.error).toBeUndefined(); + expect(JSON.stringify(prompted.data)).toContain(RESPONSE_TEXT); + } + expect(mock.getRequests().length).toBeGreaterThan(0); + + const messages = await within( + client.session.messages({ path: { id: sessionId as string } }), + "session.messages", + daemon, + ); + expect(messages.error).toBeUndefined(); + expect(messages.data?.map((message) => message.info.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + const messageIds = + messages.data?.map((message) => message.info.id as string) ?? []; + expect(messageIds).toEqual([...messageIds].sort()); + expect( + messageIds.every((id) => /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(id)), + ).toBe(true); + const records = await readSessionLog( + daemon, + sdkSessionId, + (items) => + items.filter((record) => record.event === "prompt.completed").length === + 2, + ); + expect( + records.filter((record) => record.event === "prompt.completed"), + ).toHaveLength(2); + expect( + records.filter((record) => record.event === "agentos.session.created"), + ).toHaveLength(1); + }, 180_000); + + test("queues promptAsync turns in FIFO order and reports busy until both finish", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "FIFO E2E" } }), + "FIFO session.create", + daemon, + 60_000, + ); + const sessionId = created.data?.id as string; + const first = await postPromptAsync( + daemon, + sessionId, + "GIGACODE_QUEUE_FIRST", + ); + expect(first.status, await first.clone().text()).toBe(204); + const second = await postPromptAsync( + daemon, + sessionId, + "GIGACODE_QUEUE_SECOND", + ); + expect(second.status, await second.clone().text()).toBe(204); + + const earlyStatus = (await ( + await fetch(`${daemon.apiUrl}/session/status`) + ).json()) as Record; + expect(earlyStatus[sessionId]?.type).toBe("busy"); + const earlyMessages = (await ( + await fetch(`${daemon.apiUrl}/session/${sessionId}/message`) + ).json()) as Array<{ info: { role: string }; parts: unknown[] }>; + expect(earlyMessages.map((message) => message.info.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + expect(earlyMessages[1]?.parts).toEqual([]); + expect(earlyMessages[3]?.parts).toEqual([]); + + await waitForCondition( + async () => { + const statuses = (await ( + await fetch(`${daemon.apiUrl}/session/status`) + ).json()) as Record; + return statuses[sessionId]?.type === "idle"; + }, + "FIFO session idle", + daemon, + 120_000, + ); + const completed = (await ( + await fetch(`${daemon.apiUrl}/session/${sessionId}/message`) + ).json()) as Array<{ + info: { id: string; role: string }; + parts: Array<{ type?: string; text?: string }>; + }>; + expect(completed[1]?.parts.map((part) => part.text).join("")).toContain( + QUEUE_FIRST_RESPONSE, + ); + expect(completed[3]?.parts.map((part) => part.text).join("")).toContain( + QUEUE_SECOND_RESPONSE, + ); + const log = await readSessionLog( + daemon, + sessionId, + (records) => + records.filter((record) => record.event === "prompt.completed") + .length === 2, + ); + const completedPrompts = log.filter( + (record) => record.event === "prompt.completed", + ); + expect(completedPrompts).toHaveLength(2); + expect(completedPrompts[0]?.messageId).toBe(completed[1]?.info.id); + expect(completedPrompts[1]?.messageId).toBe(completed[3]?.info.id); + }, 180_000); + + test("streams a tool call, answers its permission, and records the result", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "Tool and permission E2E" } }), + "tool session.create", + daemon, + 60_000, + ); + const sessionId = created.data?.id as string; + const prompt = client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "claude", modelID: "default" }, + parts: [{ type: "text", text: "GIGACODE_TOOL_STREAM" }], + }, + }); + let permissionId = ""; + await waitForCondition( + async () => { + const response = await fetch(`${daemon.apiUrl}/permission`); + const permissions = (await response.json()) as Array<{ + id: string; + sessionID: string; + }>; + permissionId = + permissions.find((item) => item.sessionID === sessionId)?.id ?? ""; + return Boolean(permissionId); + }, + "tool permission request", + daemon, + 90_000, + ); + const reply = await fetch( + `${daemon.apiUrl}/permission/${encodeURIComponent(permissionId)}/reply`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reply: "once" }), + }, + ); + expect(reply.ok, await reply.clone().text()).toBe(true); + const result = await within(prompt, "tool session.prompt", daemon, 90_000); + expect(result.error, daemon.logs()).toBeUndefined(); + expect(JSON.stringify(result.data)).toContain(TOOL_FINAL_RESPONSE); + const messages = await within( + client.session.messages({ path: { id: sessionId } }), + "tool session.messages", + daemon, + ); + const assistant = messages.data?.[1]; + expect(assistant?.parts.some((part) => part.type === "tool")).toBe(true); + expect(JSON.stringify(assistant)).toContain("GIGACODE_TOOL_EXECUTION_OK"); + expect(JSON.stringify(assistant)).toContain(TOOL_FINAL_RESPONSE); + const pending = await fetch(`${daemon.apiUrl}/permission`).then( + (response) => response.json() as Promise>, + ); + expect(pending.some((item) => item.sessionID === sessionId)).toBe(false); + }, 180_000); + + test("cancels an active turn and resumes the same AgentOS session", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "Cancellation E2E" } }), + "cancel session.create", + daemon, + 60_000, + ); + const sessionId = created.data?.id as string; + const requestCount = mock.getRequests().length; + const submitted = await postPromptAsync( + daemon, + sessionId, + "GIGACODE_CANCEL_SLOW", + ); + expect(submitted.status, await submitted.clone().text()).toBe(204); + await waitForCondition( + () => + mock.getRequests().length > requestCount && + JSON.stringify(mock.getRequests().slice(requestCount)).includes( + "GIGACODE_CANCEL_SLOW", + ), + "slow LLMock request", + daemon, + 90_000, + ); + const aborted = await within( + client.session.abort({ path: { id: sessionId } }), + "session.abort", + daemon, + ); + expect(aborted.error).toBeUndefined(); + expect(aborted.data).toBe(true); + await waitForCondition( + async () => { + const statuses = (await ( + await fetch(`${daemon.apiUrl}/session/status`) + ).json()) as Record; + return statuses[sessionId]?.type === "idle"; + }, + "cancelled session idle", + daemon, + 30_000, + ); + let messages = (await ( + await fetch(`${daemon.apiUrl}/session/${sessionId}/message`) + ).json()) as Array<{ info: { role: string; error?: { name?: string } } }>; + expect(messages[1]?.info.error?.name).toBe("MessageAbortedError"); + + const resumed = await within( + client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "claude", modelID: "default" }, + parts: [{ type: "text", text: "GIGACODE_CANCEL_FOLLOWUP" }], + }, + }), + "post-cancel session.prompt", + daemon, + 90_000, + ); + expect(resumed.error, daemon.logs()).toBeUndefined(); + const resumedJson = JSON.stringify(resumed.data); + if (!resumedJson.includes(CANCEL_FOLLOWUP_RESPONSE)) { + const recentMockMessages = mock + .getRequests() + .slice(-4) + .map((request) => ({ + fixture: request.response.fixture?.match, + messages: request.body?.messages?.slice(-3), + })); + throw new Error( + `post-cancel response was ${resumedJson}\nLLMock messages: ${JSON.stringify(recentMockMessages)}\n${daemon.logs()}`, + ); + } + messages = (await ( + await fetch(`${daemon.apiUrl}/session/${sessionId}/message`) + ).json()) as Array<{ info: { role: string; error?: { name?: string } } }>; + expect(messages.map((message) => message.info.role)).toEqual([ + "user", + "assistant", + "user", + "assistant", + ]); + const log = await readSessionLog(daemon, sessionId, (records) => + records.some((record) => record.event === "prompt.completed"), + ); + expect( + log.filter((record) => record.event === "agentos.session.created"), + ).toHaveLength(1); + expect(log).toContainEqual( + expect.objectContaining({ + event: "agentos.turn.cancel_started", + }), + ); + expect(log).toContainEqual( + expect.objectContaining({ + event: "agentos.turn.cancelled", + }), + ); + expect( + log.some( + (record) => record.event === "agentos.session.transcript_handoff", + ), + ).toBe(false); + }, 180_000); + + test("resumes a logical session after a daemon restart", async () => { + const beforeClient = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const persistedBefore = await within( + beforeClient.session.messages({ path: { id: sdkSessionId } }), + "pre-restart session.messages", + daemon, + ); + expect(persistedBefore.error).toBeUndefined(); + const before = await readSessionLog(daemon, sdkSessionId); + const createdBefore = before.filter( + (record) => record.event === "agentos.session.created", + ); + expect(createdBefore.length).toBeGreaterThan(0); + + await restartDaemon(daemon); + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const providerStarted = performance.now(); + const warmProviders = await within( + client.provider.list(), + "warm provider.list", + daemon, + 2_000, + ); + expect(warmProviders.error).toBeUndefined(); + expect(performance.now() - providerStarted).toBeLessThan(1_000); + const listed = await within( + client.session.list(), + "resumed session.list", + daemon, + ); + expect(listed.data?.map((session) => session.id)).toContain(sdkSessionId); + const persistedAfter = await within( + client.session.messages({ path: { id: sdkSessionId } }), + "post-restart session.messages", + daemon, + ); + expect(persistedAfter.data).toEqual(persistedBefore.data); + + const prompted = await within( + client.session.prompt({ + path: { id: sdkSessionId }, + body: { + model: { providerID: "claude", modelID: "default" }, + parts: [ + { + type: "text", + text: "Return the deterministic response after resuming.", + }, + ], + }, + }), + "resumed session.prompt", + daemon, + 90_000, + ); + expect(prompted.error, daemon.logs()).toBeUndefined(); + expect(JSON.stringify(prompted.data)).toContain(RESPONSE_TEXT); + + const after = await readSessionLog( + daemon, + sdkSessionId, + (items) => + items.filter((record) => record.event === "prompt.completed").length === + 3, + ); + const promptStarts = after.filter( + (record) => record.event === "agentos.prompt.started", + ); + const createdAfter = after.filter( + (record) => record.event === "agentos.session.created", + ); + expect(createdAfter).toHaveLength(createdBefore.length + 1); + expect(promptStarts.at(-1)?.actorSessionId).toBe( + createdAfter.at(-1)?.actorSessionId, + ); + expect(after).toContainEqual( + expect.objectContaining({ + event: "agentos.session.transcript_handoff", + }), + ); + expect( + after.filter((record) => record.event === "prompt.completed"), + ).toHaveLength(3); + }, 180_000); + + test("prompts Pi through the OpenCode SDK", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "Pi SDK E2E" } }), + "Pi session.create", + daemon, + 60_000, + ); + expect(created.error).toBeUndefined(); + const sessionId = created.data?.id as string; + const prompted = await within( + client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "pi", modelID: piModelID }, + parts: [ + { type: "text", text: "Return the deterministic test response." }, + ], + }, + }), + "Pi session.prompt", + daemon, + 90_000, + ); + expect(prompted.error, daemon.logs()).toBeUndefined(); + expect(JSON.stringify(prompted.data)).toContain(RESPONSE_TEXT); + const records = await readSessionLog(daemon, sessionId); + expect(records).toContainEqual( + expect.objectContaining({ + event: "agentos.session.model.selected", + model: piModelID, + }), + ); + }, 120_000); + + test("switches harnesses in one OpenCode session with a bounded transcript handoff", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "Harness handoff E2E" } }), + "handoff session.create", + daemon, + 60_000, + ); + const sessionId = created.data?.id as string; + const claude = await within( + client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "claude", modelID: "default" }, + parts: [{ type: "text", text: "GIGACODE_HANDOFF_CLAUDE" }], + }, + }), + "pre-handoff Claude prompt", + daemon, + 90_000, + ); + expect(claude.error).toBeUndefined(); + const pi = await within( + client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "pi", modelID: piModelID }, + parts: [{ type: "text", text: "GIGACODE_HANDOFF_PI" }], + }, + }), + "Claude-to-Pi handoff prompt", + daemon, + 120_000, + ); + expect(pi.error, daemon.logs()).toBeUndefined(); + expect(JSON.stringify(pi.data)).toContain(RESPONSE_TEXT); + const log = await readSessionLog(daemon, sessionId, (records) => + records.some( + (record) => record.event === "agentos.session.harness_switched", + ), + ); + expect(log).toContainEqual( + expect.objectContaining({ + event: "agentos.session.harness_switched", + from: "claude", + to: "pi", + }), + ); + expect( + log.filter((record) => record.event === "agentos.session.created"), + ).toHaveLength(2); + }, 180_000); + + test("runs shell mode commands through the OpenCode SDK", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "Shell SDK E2E" } }), + "shell session.create", + daemon, + 60_000, + ); + const sessionId = created.data?.id as string; + const shell = await within( + client.session.shell({ + path: { id: sessionId }, + body: { + agent: "build", + model: { providerID: "claude", modelID: "default" }, + command: "printf GIGACODE_SDK_SHELL_OK && pwd", + }, + }), + "session.shell", + daemon, + 90_000, + ); + expect(shell.error, daemon.logs()).toBeUndefined(); + expect(shell.data?.role).toBe("assistant"); + const messages = await within( + client.session.messages({ path: { id: sessionId } }), + "shell session.messages", + daemon, + ); + expect(messages.data?.map((message) => message.info.role)).toEqual([ + "user", + "assistant", + ]); + const tool = messages.data?.[1]?.parts[0]; + expect(JSON.stringify(tool)).toContain("GIGACODE_SDK_SHELL_OK"); + expect(JSON.stringify(tool)).toContain("/workspace"); + expect(tool).toMatchObject({ + type: "tool", + tool: "bash", + state: { status: "completed" }, + }); + + const failed = await within( + client.session.shell({ + path: { id: sessionId }, + body: { + agent: "build", + model: { providerID: "claude", modelID: "default" }, + command: "printf GIGACODE_EXPECTED_FAILURE >&2; exit 7", + }, + }), + "failed session.shell", + daemon, + 30_000, + ); + expect(failed.error).toBeUndefined(); + const afterFailure = await within( + client.session.messages({ path: { id: sessionId } }), + "failed shell session.messages", + daemon, + ); + expect(afterFailure.data?.[3]?.parts[0]).toMatchObject({ + type: "tool", + state: { status: "error" }, + }); + expect(JSON.stringify(afterFailure.data?.[3])).toContain( + "GIGACODE_EXPECTED_FAILURE", + ); + }, 120_000); + + test("cancels a long shell command and runs another command in the same session", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const created = await within( + client.session.create({ body: { title: "Shell cancellation E2E" } }), + "shell cancel session.create", + daemon, + 60_000, + ); + const sessionId = created.data?.id as string; + const running = fetch(`${daemon.apiUrl}/session/${sessionId}/shell`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + agent: "build", + model: { providerID: "claude", modelID: "default" }, + command: "exec sleep 30", + }), + }); + await waitForCondition( + async () => { + const statuses = (await ( + await fetch(`${daemon.apiUrl}/session/status`) + ).json()) as Record; + return statuses[sessionId]?.type === "busy"; + }, + "shell busy status", + daemon, + 30_000, + ); + const aborted = await within( + client.session.abort({ path: { id: sessionId } }), + "shell session.abort", + daemon, + 30_000, + ); + expect(aborted.data).toBe(true); + const cancelledResponse = await within( + running, + "cancelled shell response", + daemon, + 30_000, + ); + expect(cancelledResponse.ok, await cancelledResponse.clone().text()).toBe( + true, + ); + const resumed = await within( + client.session.shell({ + path: { id: sessionId }, + body: { + agent: "build", + model: { providerID: "claude", modelID: "default" }, + command: "printf GIGACODE_AFTER_SHELL_CANCEL", + }, + }), + "post-cancel session.shell", + daemon, + 30_000, + ); + expect(resumed.error).toBeUndefined(); + const messages = await within( + client.session.messages({ path: { id: sessionId } }), + "post-cancel shell messages", + daemon, + ); + expect(JSON.stringify(messages.data)).toContain( + "GIGACODE_AFTER_SHELL_CANCEL", + ); + }, 120_000); + + test("uses one daemon and one actor per cwd for multiple sessions", async () => { + const pidBefore = Number( + (await readFile(resolve(daemon.stateDir, "daemon.pid"), "utf8")).trim(), + ); + const otherWorkspace = resolve(daemon.stateDir, "other-workspace"); + await mkdir(otherWorkspace); + await writeFile( + resolve(otherWorkspace, "workspace-marker.txt"), + "WORKSPACE_TWO", + ); + const response = await fetch( + `${daemon.apiUrl}/session?directory=${encodeURIComponent(otherWorkspace)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Second workspace" }), + }, + ); + expect(response.ok, await response.clone().text()).toBe(true); + const session = (await response.json()) as { + id: string; + directory: string; + }; + expect(session.directory).toBe(otherWorkspace); + const actorsAfterFirst = await activeActorIds(daemon); + const secondResponse = await fetch( + `${daemon.apiUrl}/session?directory=${encodeURIComponent(otherWorkspace)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Second session, same workspace" }), + }, + ); + expect(secondResponse.ok, await secondResponse.clone().text()).toBe(true); + const secondSession = (await secondResponse.json()) as { id: string }; + const actorsAfterSecond = await activeActorIds(daemon); + expect(actorsAfterSecond).toEqual(actorsAfterFirst); + const actorId = await sessionActorId(daemon, session.id); + expect(await sessionActorId(daemon, secondSession.id)).toBe(actorId); + const health = (await ( + await fetch(`${daemon.apiUrl}/global/health`) + ).json()) as { workspaceRoot: string }; + expect(health.workspaceRoot).toBe("/workspace"); + const pidAfter = Number( + (await readFile(resolve(daemon.stateDir, "daemon.pid"), "utf8")).trim(), + ); + expect(pidAfter).toBe(pidBefore); + const actor = createClient({ + endpoint: `http://127.0.0.1:${daemon.env.GIGACODE_RIVET_PORT}`, + }).vm.getForId(actorId); + expect( + new TextDecoder().decode( + await actor.readFile("/workspace/workspace-marker.txt"), + ), + ).toBe("WORKSPACE_TWO"); + await expect( + actor.readFile("/workspace/experiments/gigacode/package.json"), + ).rejects.toThrow(); + for (const sessionId of [session.id, secondSession.id]) { + const deleted = await fetch(`${daemon.apiUrl}/session/${sessionId}`, { + method: "DELETE", + }); + expect(deleted.ok).toBe(true); + } + expect(await activeActorIds(daemon)).toEqual(actorsAfterSecond); + }, 120_000); + + test("keeps three Claude turns ordered in the real OpenCode TUI", async () => { + const terminal = await TmuxTerminal.launch({ + command: [GLOBAL_GIGACODE_BIN], + cwd: ROOT, + env: daemon.env, + }); + try { + await terminal.waitForText("Build · Claude Code Claude Code", { + timeoutMs: 30_000, + }); + const prompts = ["ORDER_USER_1", "ORDER_USER_2", "ORDER_USER_3"]; + for (const [index, prompt] of prompts.entries()) { + await terminal.type(prompt); + await terminal.press("Enter"); + await terminal.waitForTextOccurrences(RESPONSE_TEXT, index + 1, { + timeoutMs: 120_000, + }); + await terminal.waitForTextAbsent("esc interrupt", { + timeoutMs: 30_000, + }); + const snapshot = await terminal.snapshot(`claude-turn-${index + 1}`); + console.log( + `\n--- terminal snapshot: ${snapshot.label} ---\n${snapshot.text}\n`, + ); + } + const snapshot = await terminal.snapshot("claude-three-turn-order"); + const responseIndexes: number[] = []; + let responseIndex = snapshot.text.indexOf(RESPONSE_TEXT); + while (responseIndex !== -1) { + responseIndexes.push(responseIndex); + responseIndex = snapshot.text.indexOf( + RESPONSE_TEXT, + responseIndex + RESPONSE_TEXT.length, + ); + } + const promptIndexes = prompts.map((prompt) => + snapshot.text.indexOf(prompt), + ); + expect(responseIndexes).toHaveLength(3); + expect(promptIndexes.every((index) => index >= 0)).toBe(true); + expect([ + promptIndexes[0], + responseIndexes[0], + promptIndexes[1], + responseIndexes[1], + promptIndexes[2], + responseIndexes[2], + ]).toEqual( + [ + promptIndexes[0], + responseIndexes[0], + promptIndexes[1], + responseIndexes[1], + promptIndexes[2], + responseIndexes[2], + ].toSorted((left, right) => left - right), + ); + expect(snapshot.text).not.toContain("esc interrupt"); + + await terminal.type("/gigacode-debugger"); + await terminal.press("Enter"); + // The first Enter accepts the custom command from autocomplete; the + // second submits it, matching OpenCode 1.17.18's real TUI behavior. + await terminal.press("Enter"); + await terminal.waitForText("Rivet inspector", { timeoutMs: 30_000 }); + const debuggerSnapshot = await terminal.snapshot("debugger-command"); + console.log( + `\n--- terminal snapshot: ${debuggerSnapshot.label} ---\n${debuggerSnapshot.text}\n`, + ); + expect(debuggerSnapshot.text).toContain("Rivet inspector"); + + await terminal.type("/diff"); + await terminal.press("Enter"); + await new Promise((resolve) => setTimeout(resolve, 500)); + const diffSnapshot = await terminal.snapshot("empty-diff"); + console.log( + `\n--- terminal snapshot: ${diffSnapshot.label} ---\n${diffSnapshot.text}\n`, + ); + expect(diffSnapshot.text).not.toContain("panic:"); + } finally { + await terminal.close(); + } + }, 180_000); + + test("cancels and queues turns through the real OpenCode TUI", async () => { + const terminal = await TmuxTerminal.launch({ + command: [GLOBAL_GIGACODE_BIN], + cwd: ROOT, + env: daemon.env, + }); + try { + await terminal.waitForText("Build · Claude Code Claude Code", { + timeoutMs: 30_000, + }); + await terminal.type("GIGACODE_TUI_CANCEL"); + await terminal.press("Enter"); + await terminal.waitForText("esc interrupt", { timeoutMs: 90_000 }); + await terminal.press("Escape"); + await terminal.waitForText("esc again to interrupt", { + timeoutMs: 30_000, + }); + await terminal.press("Escape"); + await terminal.waitForTextAbsent("esc again to interrupt", { + timeoutMs: 30_000, + stableMs: 500, + }); + const cancelled = await terminal.snapshot("tui-turn-cancelled"); + console.log( + `\n--- terminal snapshot: ${cancelled.label} ---\n${cancelled.text}\n`, + ); + expect(cancelled.text).not.toContain( + "THIS_TUI_RESPONSE_MUST_BE_CANCELLED", + ); + + await terminal.type("GIGACODE_CANCEL_FOLLOWUP"); + await terminal.press("Enter"); + await terminal.waitForText(CANCEL_FOLLOWUP_RESPONSE, { + timeoutMs: 120_000, + }); + await terminal.waitForTextAbsent("esc interrupt", { timeoutMs: 30_000 }); + + await terminal.type("GIGACODE_TUI_QUEUE_FIRST"); + await terminal.press("Enter"); + await terminal.waitForText("esc interrupt", { timeoutMs: 30_000 }); + await terminal.type("GIGACODE_TUI_QUEUE_SECOND"); + await terminal.press("Enter"); + await terminal.waitForText(TUI_QUEUE_FIRST_RESPONSE, { + timeoutMs: 120_000, + }); + await terminal.waitForText(TUI_QUEUE_SECOND_RESPONSE, { + timeoutMs: 120_000, + }); + await terminal.waitForTextAbsent("esc interrupt", { timeoutMs: 30_000 }); + const queued = await terminal.snapshot("tui-turns-queued"); + console.log( + `\n--- terminal snapshot: ${queued.label} ---\n${queued.text}\n`, + ); + const order = [ + queued.text.indexOf("GIGACODE_TUI_QUEUE_FIRST"), + queued.text.indexOf(TUI_QUEUE_FIRST_RESPONSE), + queued.text.indexOf("GIGACODE_TUI_QUEUE_SECOND"), + queued.text.indexOf(TUI_QUEUE_SECOND_RESPONSE), + ]; + expect(order.every((index) => index >= 0)).toBe(true); + expect(order).toEqual([...order].sort((left, right) => left - right)); + expect(queued.text).not.toContain("THIS_TUI_RESPONSE_MUST_BE_CANCELLED"); + } finally { + await terminal.close(); + } + }, 300_000); + + test("drives the real OpenCode TUI, selects Pi, and receives a response", async () => { + const terminal = await TmuxTerminal.launch({ + command: [GLOBAL_GIGACODE_BIN], + cwd: ROOT, + env: daemon.env, + }); + const showSnapshot = async (label: string) => { + const snapshot = await terminal.snapshot(label); + console.log(`\n--- terminal snapshot: ${label} ---\n${snapshot.text}\n`); + return snapshot.text; + }; + try { + await terminal.waitForText("Ask anything", { timeoutMs: 30_000 }); + expect(await showSnapshot("01-ready")).toContain("Claude Code"); + + await terminal.type("/models"); + await terminal.press("Enter"); + await terminal.waitForText("Select model"); + expect(await showSnapshot("02-model-selector")).toContain("Pi"); + + await terminal.type(piModelName); + await terminal.waitForTextAbsent("AI21: Jamba Large 1.7"); + await terminal.waitForText(piModelName); + expect(await showSnapshot("03-pi-filtered")).toContain(piModelName); + await terminal.press("Enter"); + await terminal.waitForText(/Build · .* Pi/); + expect(await showSnapshot("04-pi-selected")).toMatch(/Build · .* Pi/); + + const prompt = "Return the deterministic test response."; + await terminal.type(prompt); + await terminal.waitForText(prompt); + expect(await showSnapshot("05-prompt-entered")).toContain(prompt); + await terminal.press("Enter"); + await terminal.waitForText(RESPONSE_TEXT, { timeoutMs: 120_000 }); + await terminal.waitForTextAbsent("esc interrupt", { timeoutMs: 30_000 }); + expect(await showSnapshot("06-pi-response")).toContain(RESPONSE_TEXT); + } finally { + await terminal.close(); + } + }, 180_000); + + test("runs ! shell mode in the real OpenCode TUI", async () => { + const terminal = await TmuxTerminal.launch({ + command: [GLOBAL_GIGACODE_BIN], + cwd: ROOT, + env: daemon.env, + }); + try { + await terminal.waitForText("Build · Claude Code Claude Code", { + timeoutMs: 30_000, + }); + await terminal.type("!printenv GIGACODE_TUI_SHELL_VALUE"); + await terminal.press("Enter"); + await terminal.waitForText("GIGACODE_TUI_SHELL_OK", { + timeoutMs: 90_000, + }); + const snapshot = await terminal.snapshot("shell-mode-complete"); + console.log( + `\n--- terminal snapshot: ${snapshot.label} ---\n${snapshot.text}\n`, + ); + expect(snapshot.text).toContain("GIGACODE_TUI_SHELL_OK"); + } finally { + await terminal.close(); + } + }, 120_000); + + test("deletes SDK and CLI sessions from the coordinator", async () => { + const client = createOpencodeClient({ baseUrl: daemon.apiUrl }); + const listed = await within( + client.session.list(), + "cleanup session.list", + daemon, + ); + expect(listed.data?.map((session) => session.id)).toContain(sdkSessionId); + expect(listed.data?.map((session) => session.id)).toContain(cliSessionId); + for (const session of listed.data ?? []) { + const records = await readSessionLog(daemon, session.id); + expect(records.every((record) => record.sessionId === session.id)).toBe( + true, + ); + const events = records.map((record) => record.event); + expect( + records.some( + (record) => + record.event === "rivet.actor.resolved" && + typeof record.durationMs === "number", + ), + ).toBe(true); + if ( + events.includes("prompt.completed") || + events.includes("agentos.shell.completed") + ) { + expect( + records.some( + (record) => + (record.event === "agentos.prompt.completed" || + record.event === "agentos.shell.completed") && + typeof record.durationMs === "number", + ), + ).toBe(true); + } + const removed = await within( + client.session.delete({ path: { id: session.id } }), + `session.delete(${session.id})`, + daemon, + ); + expect(removed.data).toBe(true); + } + const final = await within( + client.session.list(), + "final session.list", + daemon, + ); + expect(final.data).toEqual([]); + const health = (await ( + await fetch(`${daemon.apiUrl}/global/health`) + ).json()) as { rivetEndpoint: string }; + const coordinatorActors = (await ( + await fetch( + `${health.rivetEndpoint}/actors?namespace=default&name=coordinator`, + ) + ).json()) as { + actors?: Array<{ actor_id: string; destroy_ts?: number | null }>; + }; + const coordinatorId = coordinatorActors.actors?.find( + (actor) => !actor.destroy_ts, + )?.actor_id as string; + const coordinatorRows = await createClient({ + endpoint: health.rivetEndpoint, + }) + .coordinator.getForId(coordinatorId) + .listSessions(); + expect(coordinatorRows).toEqual([]); + expect((await activeActorIds(daemon)).length).toBeGreaterThan(0); + }, 60_000); +}); diff --git a/experiments/gigacode/gigacode.ts b/experiments/gigacode/gigacode.ts new file mode 100755 index 0000000000..c80fca744c --- /dev/null +++ b/experiments/gigacode/gigacode.ts @@ -0,0 +1,3728 @@ +#!/usr/bin/env -S node --import tsx + +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { type Dirent, existsSync, realpathSync, statSync } from "node:fs"; +import { + mkdir, + open, + readdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import pino, { type Logger } from "pino"; + +// Keep daemon-only dependencies out of tsx's eager dynamic-import rewrite. +// The rewrite currently fails to parse this single-file client/server entrypoint; +// the native runtime import is still standard ESM and remains fully lazy. +const importKeyword = ["im", "port"].join(""); +const importModule = new Function( + "specifier", + `return ${importKeyword}(specifier)`, +) as (specifier: string) => Promise; + +const HOST = "127.0.0.1"; +const API_PORT = boundedPort("GIGACODE_PORT", 2468); +const RIVET_PORT = boundedPort("GIGACODE_RIVET_PORT", 2469); +if (RIVET_PORT === 6420) { + throw new Error("GIGACODE_RIVET_PORT must not be 6420"); +} +const API_ENDPOINT = `http://${HOST}:${API_PORT}`; +const RIVET_ENDPOINT = `http://${HOST}:${RIVET_PORT}`; +const INSPECTOR_URL = + process.env.GIGACODE_INSPECTOR_URL ?? "http://localhost:43708/"; +const STATE_DIR = + process.env.GIGACODE_STATE_DIR ?? + join(homedir(), ".local", "state", "gigacode"); +// Rivet's default ~/.rivetkit database is process-global across ports. Give the +// central Gigacode daemon an isolated, versioned engine database so unrelated +// local Rivet apps cannot hold its RocksDB lock and actors from an incompatible +// experiment generation cannot prevent new actors from scheduling. +const RIVET_STORAGE_PATH = resolve( + process.env.RIVETKIT_STORAGE_PATH ?? join(STATE_DIR, "engine-v1"), +); +process.env.RIVETKIT_STORAGE_PATH = RIVET_STORAGE_PATH; +const PID_FILE = join(STATE_DIR, "daemon.pid"); +const LOG_FILE = join(STATE_DIR, "daemon.log"); +const LEGACY_SESSION_METADATA_FILE = join(STATE_DIR, "sessions.json"); +const MESSAGE_STORE_FILE = join(STATE_DIR, "messages.json"); +const MODEL_CACHE_FILE = join(STATE_DIR, "models.json"); +const SESSION_LOG_DIR = join(STATE_DIR, "session-logs"); +const COORDINATOR_NAME = "coordinator"; +const COORDINATOR_KEY = "global"; +const RUNNER_NAME = "default"; +const MAX_BODY_BYTES = 4 * 1024 * 1024; +const MAX_SESSIONS = 1_000; +const MAX_EVENTS = 4_096; +const MAX_PROMPT_QUEUE_PER_SESSION = boundedInteger( + "GIGACODE_MAX_PROMPT_QUEUE_PER_SESSION", + 64, +); +const MAX_MESSAGES_PER_SESSION = boundedInteger( + "GIGACODE_MAX_MESSAGES_PER_SESSION", + 10_000, +); +const MAX_MESSAGE_STORE_BYTES = boundedInteger( + "GIGACODE_MAX_MESSAGE_STORE_BYTES", + 64 * 1024 * 1024, +); +const MAX_FS_FIND_SCAN = boundedInteger("GIGACODE_MAX_FS_FIND_SCAN", 50_000); +const MAX_FS_FIND_RESULTS = boundedInteger("GIGACODE_MAX_FS_FIND_RESULTS", 200); +const MAX_HARNESS_HANDOFF_BYTES = boundedInteger( + "GIGACODE_MAX_HARNESS_HANDOFF_BYTES", + 64 * 1024, +); +const CANCEL_QUIESCE_TIMEOUT_MS = boundedInteger( + "GIGACODE_CANCEL_QUIESCE_TIMEOUT_MS", + 30_000, +); +const STARTUP_TIMEOUT_MS = boundedInteger( + "GIGACODE_STARTUP_TIMEOUT_MS", + 120_000, +); +const MAX_SESSION_ENV_ENTRIES = 128; +const MAX_SESSION_ENV_BYTES = 64 * 1024; +const MAX_PI_CONFIG_BYTES = 64 * 1024; +const MAX_MODEL_CACHE_BYTES = 1024 * 1024; +const VERSION = "0.0.1"; +const WORKSPACE_MOUNT_PATH = "/workspace"; +const DEFAULT_DIRECTORY = canonicalDirectory( + process.env.GIGACODE_WORKSPACE ?? process.cwd(), +); +function localSandboxInstructions(directory: string) { + return ` +Gigacode is a local AgentOS VM experiment, not a security boundary. +The host project ${directory} is mounted read-write at ${WORKSPACE_MOUNT_PATH}, +which is your working directory. + +Use your normal shell and execution tools for commands; they execute inside the +local AgentOS VM, not Docker. Writes under ${WORKSPACE_MOUNT_PATH} affect the +real project immediately, subject to the daemon user's operating-system +permissions. Other host directories are not mounted into this VM. +`.trim(); +} + +function startupLog(message: string, startedAt?: number): void { + const elapsed = + startedAt === undefined + ? "" + : ` (${Math.round(performance.now() - startedAt)} ms)`; + console.log(`[gigacode] ${message}${elapsed}`); +} + +const harnesses = { + claude: { label: "Claude Code" }, + codex: { label: "Codex" }, + pi: { label: "Pi" }, + opencode: { label: "OpenCode" }, +} as const; +type Harness = keyof typeof harnesses; + +const HOST_CREDENTIAL_ENV_NAMES = [ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "CODEX_API_KEY", + "AWS_REGION", + "AWS_PROFILE", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", +] as const; + +function hostCredentialEnvironment(): Record { + return Object.fromEntries( + HOST_CREDENTIAL_ENV_NAMES.flatMap((name) => { + const value = process.env[name]; + return value === undefined ? [] : [[name, value]]; + }), + ); +} + +function hostCredentialMounts() { + if (process.env.GIGACODE_SHARE_HOST_CREDENTIALS === "0") return []; + const readOnly = process.env.GIGACODE_CREDENTIALS_READ_ONLY === "1"; + const candidates = [ + { + hostPath: + process.env.GIGACODE_CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), + guestPath: "/home/agentos/.claude", + }, + { + hostPath: process.env.GIGACODE_CODEX_HOME ?? join(homedir(), ".codex"), + guestPath: "/home/agentos/.codex", + }, + { + hostPath: process.env.GIGACODE_PI_HOME ?? join(homedir(), ".pi"), + guestPath: "/home/agentos/.pi", + }, + ]; + return candidates.flatMap(({ hostPath, guestPath }) => { + if (!existsSync(hostPath)) return []; + if (!statSync(hostPath).isDirectory()) { + throw new Error(`host credential path is not a directory: ${hostPath}`); + } + return [ + { + path: guestPath, + plugin: { + id: "host_dir", + config: { hostPath, readOnly }, + }, + readOnly, + }, + ]; + }); +} + +function loopbackExemptPorts(): number[] { + const raw = process.env.GIGACODE_LOOPBACK_EXEMPT_PORTS; + if (!raw) return []; + const ports = raw.split(",").map((entry) => { + const port = Number(entry.trim()); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error( + "GIGACODE_LOOPBACK_EXEMPT_PORTS must be a comma-separated list of ports", + ); + } + return port; + }); + return [...new Set(ports)]; +} + +function networkPermission(): "allow" | "deny" | undefined { + const value = process.env.GIGACODE_NETWORK_PERMISSION; + if (value === undefined || value === "") return undefined; + if (value === "allow" || value === "deny") return value; + throw new Error('GIGACODE_NETWORK_PERMISSION must be "allow" or "deny"'); +} + +function sessionEnvironment(): Record { + const raw = process.env.GIGACODE_SESSION_ENV_JSON; + if (!raw) return {}; + if (Buffer.byteLength(raw) > MAX_SESSION_ENV_BYTES) { + throw new Error( + `GIGACODE_SESSION_ENV_JSON exceeds ${MAX_SESSION_ENV_BYTES} bytes`, + ); + } + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("GIGACODE_SESSION_ENV_JSON must be a JSON object"); + } + const entries = Object.entries(value); + if (entries.length > MAX_SESSION_ENV_ENTRIES) { + throw new Error( + `GIGACODE_SESSION_ENV_JSON exceeds ${MAX_SESSION_ENV_ENTRIES} entries`, + ); + } + for (const [key, entry] of entries) { + if (!key || typeof entry !== "string") { + throw new Error( + "GIGACODE_SESSION_ENV_JSON keys must be non-empty and values must be strings", + ); + } + } + return Object.fromEntries(entries) as Record; +} + +const SESSION_ENV = { + HOME: "/home/agentos", + CLAUDE_CONFIG_DIR: "/home/agentos/.claude", + CODEX_HOME: "/home/agentos/.codex", + ...hostCredentialEnvironment(), + ...sessionEnvironment(), +}; +const NETWORK_PERMISSION = networkPermission(); + +function boundedPiConfig(name: string): string | undefined { + const value = process.env[name]; + if (value === undefined || value === "") return undefined; + if (Buffer.byteLength(value) > MAX_PI_CONFIG_BYTES) { + throw new Error(`${name} exceeds ${MAX_PI_CONFIG_BYTES} bytes`); + } + return value; +} + +const PI_API_KEY = boundedPiConfig("GIGACODE_PI_API_KEY"); +const PI_BASE_URL = boundedPiConfig("GIGACODE_PI_BASE_URL"); +const SESSION_LOG_LEVEL = process.env.GIGACODE_LOG_LEVEL ?? "info"; + +type SessionLog = { + logger: Logger; + destination: ReturnType; +}; + +const sessionLogs = new Map(); + +function sessionLog(sessionId: string): SessionLog["logger"] { + const existing = sessionLogs.get(sessionId); + if (existing) return existing.logger; + if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) { + throw new Error( + `session id cannot be used as a log filename: ${sessionId}`, + ); + } + if (sessionLogs.size >= MAX_SESSIONS) { + throw new Error( + `session logger limit reached (${MAX_SESSIONS}); delete a session or restart the daemon`, + ); + } + const destination = pino.destination({ + dest: join(SESSION_LOG_DIR, `${sessionId}.jsonl`), + mkdir: true, + sync: false, + }); + const logger = pino( + { + level: SESSION_LOG_LEVEL, + base: { service: "gigacode", sessionId }, + }, + destination, + ) as Logger; + sessionLogs.set(sessionId, { logger, destination }); + return logger; +} + +function closeSessionLog(sessionId: string): void { + const entry = sessionLogs.get(sessionId); + if (!entry) return; + entry.logger.flush(); + entry.destination.end(); + sessionLogs.delete(sessionId); +} + +function closeAllSessionLogs(): void { + for (const sessionId of [...sessionLogs.keys()]) closeSessionLog(sessionId); +} + +type GigacodeClient = any; +type GigacodeHandle = any; + +type SessionMeta = { + id: string; + actorId?: string; + directory: string; + title: string; + createdAt: number; + updatedAt: number; + harness?: Harness; + model?: string; + actorSessionId?: string; + needsTranscriptHandoff?: boolean; +}; +type OpenCodeMessage = { + info: Record; + parts: Record[]; +}; +type PromptJob = { + body: Record; + text: string; + harness: Harness; + requestedModel: string; + cancelled: boolean; + started: boolean; + user: OpenCodeMessage; + assistant: OpenCodeMessage; + cancelSignal: Promise; + cancel: () => void; + resolve: (message: OpenCodeMessage) => void; +}; +type PromptQueue = { + jobs: PromptJob[]; + running: boolean; +}; +type PermissionRecord = { + id: string; + sessionID: string; + actorSessionId: string; + messageID: string; + createdAt: number; + description?: string; + params: Record; +}; +type AgentModel = { + id: string; + name: string; + family?: string; +}; +type AgentModelCatalog = { + defaultModel: string; + models: AgentModel[]; +}; +type ModelCache = { + version: 1; + updatedAt: number; + providers: Partial>; +}; + +class CompatState { + readonly sessions = new Map(); + readonly messages = new Map(); + readonly statuses = new Map(); + readonly permissions = new Map(); + readonly promptQueues = new Map(); + readonly cancellationBarriers = new Map>(); + readonly activeShells = new Set(); + readonly activeShellPids = new Map(); + readonly eventClients = new Map< + ServerResponse, + { global: boolean; directory: string } + >(); + readonly events: Array<{ id: number; payload: unknown }> = []; + messageSaveTail: Promise = Promise.resolve(); + sessionSyncTail?: Promise; + sessionsInitialized = false; + messagesLoaded = false; + nextId = 1; + + emit(payload: unknown) { + const eventId = this.nextId++; + const identified = + payload && typeof payload === "object" && !Array.isArray(payload) + ? { ...(payload as Record), id: String(eventId) } + : payload; + const item = { id: eventId, payload: identified }; + this.events.push(item); + if (this.events.length > MAX_EVENTS) this.events.shift(); + const directory = this.eventDirectory(identified); + for (const [client, subscription] of this.eventClients) { + if (!subscription.global && subscription.directory !== directory) + continue; + const data = subscription.global + ? { directory, payload: identified } + : identified; + client.write(`id: ${item.id}\ndata: ${JSON.stringify(data)}\n\n`); + } + } + + replay( + client: ServerResponse, + subscription: { global: boolean; directory: string }, + afterId: number, + ): void { + for (const item of this.events) { + if (item.id <= afterId) continue; + const directory = this.eventDirectory(item.payload); + if (!subscription.global && subscription.directory !== directory) + continue; + const data = subscription.global + ? { directory, payload: item.payload } + : item.payload; + client.write(`id: ${item.id}\ndata: ${JSON.stringify(data)}\n\n`); + } + } + + private eventDirectory(payload: unknown): string { + if (!payload || typeof payload !== "object") return DEFAULT_DIRECTORY; + const properties = (payload as { properties?: unknown }).properties; + if (!properties || typeof properties !== "object") return DEFAULT_DIRECTORY; + const record = properties as Record; + const info = + record.info && typeof record.info === "object" + ? (record.info as Record) + : undefined; + const sessionId = + (typeof record.sessionID === "string" && record.sessionID) || + (typeof info?.sessionID === "string" && info.sessionID) || + (typeof info?.id === "string" && info.id); + return sessionId + ? (this.sessions.get(sessionId)?.directory ?? DEFAULT_DIRECTORY) + : DEFAULT_DIRECTORY; + } +} + +async function readLegacySessionMetadata(): Promise { + let raw: string; + try { + raw = await readFile(LEGACY_SESSION_METADATA_FILE, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } + const value: unknown = JSON.parse(raw); + if (!Array.isArray(value)) + throw new Error("Gigacode session metadata is not an array"); + const sessions: SessionMeta[] = []; + for (const entry of value.slice(0, MAX_SESSIONS)) { + if (!entry || typeof entry !== "object") continue; + const meta = entry as Partial; + if ( + typeof meta.id !== "string" || + typeof meta.directory !== "string" || + typeof meta.title !== "string" || + typeof meta.createdAt !== "number" || + typeof meta.updatedAt !== "number" + ) + continue; + sessions.push(meta as SessionMeta); + } + return sessions; +} + +async function loadMessages(state: CompatState): Promise { + let raw: string; + try { + raw = await readFile(MESSAGE_STORE_FILE, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + if (Buffer.byteLength(raw) > MAX_MESSAGE_STORE_BYTES) { + throw new Error( + `Gigacode message store exceeds ${MAX_MESSAGE_STORE_BYTES} bytes; delete old sessions or raise GIGACODE_MAX_MESSAGE_STORE_BYTES`, + ); + } + const value: unknown = JSON.parse(raw); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Gigacode message store is not an object"); + } + for (const [sessionId, messages] of Object.entries(value)) { + if (!state.sessions.has(sessionId) || !Array.isArray(messages)) continue; + state.messages.set( + sessionId, + messages + .filter((message): message is OpenCodeMessage => + Boolean( + message && + typeof message === "object" && + (message as OpenCodeMessage).info && + Array.isArray((message as OpenCodeMessage).parts), + ), + ) + .slice(0, MAX_MESSAGES_PER_SESSION), + ); + } +} + +function saveMessages(state: CompatState): Promise { + const save = state.messageSaveTail.then(async () => { + const raw = `${JSON.stringify(Object.fromEntries(state.messages), null, 2)}\n`; + if (Buffer.byteLength(raw) > MAX_MESSAGE_STORE_BYTES) { + throw new Error( + `Gigacode message store would exceed ${MAX_MESSAGE_STORE_BYTES} bytes; delete old sessions or raise GIGACODE_MAX_MESSAGE_STORE_BYTES`, + ); + } + await mkdir(STATE_DIR, { recursive: true }); + const temporary = `${MESSAGE_STORE_FILE}.${process.pid}.tmp`; + await writeFile(temporary, raw, { mode: 0o600 }); + await rename(temporary, MESSAGE_STORE_FILE); + }); + state.messageSaveTail = save.catch(() => undefined); + return save; +} + +function actorCreateInput(directory: string) { + return { + options: { + additionalInstructions: localSandboxInstructions(directory), + mounts: [ + { + path: WORKSPACE_MOUNT_PATH, + plugin: { + id: "host_dir", + config: { hostPath: directory, readOnly: false }, + }, + readOnly: false, + }, + ], + }, + }; +} + +function canonicalDirectory(directory: string): string { + const absolute = resolve(directory); + const canonical = realpathSync(absolute); + if (!statSync(canonical).isDirectory()) { + throw new Error(`workspace is not a directory: ${canonical}`); + } + return canonical; +} + +function workspaceKey(directory: string): string { + return `cwd-${createHash("sha256").update(directory).digest("hex")}`; +} + +function boundedPort(name: string, fallback: number): number { + const raw = process.env[name]; + const value = raw === undefined ? fallback : Number(raw); + if (!Number.isInteger(value) || value < 1 || value > 65_535) { + throw new Error(`${name} must be an integer from 1 through 65535`); + } + return value; +} + +function boundedInteger(name: string, fallback: number): number { + const raw = process.env[name]; + const value = raw === undefined ? fallback : Number(raw); + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error(`${name} must be a positive safe integer`); + } + return value; +} + +function now() { + return Date.now(); +} + +async function withinTimeout( + promise: Promise, + timeoutMs: number, + label: string, +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new Error( + `${label} timed out after ${timeoutMs}ms; raise GIGACODE_CANCEL_QUIESCE_TIMEOUT_MS if this harness needs longer`, + ), + ), + timeoutMs, + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function id(prefix: string) { + return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; +} + +function runtimeActorId(meta: SessionMeta): string { + return meta.actorId ?? meta.id; +} + +function sessionFromCoordinatorRow(row: Record): SessionMeta { + if ( + typeof row.id !== "string" || + typeof row.actorId !== "string" || + typeof row.directory !== "string" || + typeof row.title !== "string" || + typeof row.createdAt !== "number" || + typeof row.updatedAt !== "number" + ) { + throw new Error("Gigacode coordinator returned invalid session metadata"); + } + return { + id: row.id, + actorId: row.actorId, + directory: row.directory, + title: row.title, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(isHarness(row.harness) ? { harness: row.harness } : {}), + ...(typeof row.model === "string" ? { model: row.model } : {}), + ...(typeof row.actorSessionId === "string" + ? { actorSessionId: row.actorSessionId } + : {}), + ...(row.needsTranscriptHandoff ? { needsTranscriptHandoff: true } : {}), + }; +} + +// OpenCode stores message and part updates in ID order. Its native IDs encode +// creation time in the first 12 hex characters, so random UUID-based IDs cause +// later SSE updates to be inserted at arbitrary positions in the TUI. +const OPENCODE_ID_LENGTH = 26; +let lastOpenCodeIdTimestamp = 0; +let openCodeIdCounter = 0; + +function ascendingOpenCodeId(prefix: "msg" | "prt"): string { + const timestamp = Date.now(); + if (timestamp !== lastOpenCodeIdTimestamp) { + lastOpenCodeIdTimestamp = timestamp; + openCodeIdCounter = 0; + } + openCodeIdCounter += 1; + const encoded = BigInt(timestamp) * 0x1000n + BigInt(openCodeIdCounter); + const timeBytes = Buffer.alloc(6); + for (let index = 0; index < timeBytes.length; index += 1) { + timeBytes[index] = Number((encoded >> BigInt(40 - 8 * index)) & 0xffn); + } + const alphabet = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const random = randomBytes(OPENCODE_ID_LENGTH - 12); + let suffix = ""; + for (const byte of random) suffix += alphabet[byte % alphabet.length]; + return `${prefix}_${timeBytes.toString("hex")}${suffix}`; +} + +function isHarness(value: unknown): value is Harness { + return typeof value === "string" && value in harnesses; +} + +function json(res: ServerResponse, value: unknown, status = 200) { + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "access-control-allow-origin": "*", + "access-control-allow-headers": + "content-type,last-event-id,x-opencode-directory", + "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", + }); + res.end(JSON.stringify(value)); +} + +function noContent(res: ServerResponse) { + res.writeHead(204, { + "access-control-allow-origin": "*", + "access-control-allow-headers": + "content-type,last-event-id,x-opencode-directory", + "access-control-allow-methods": "GET,POST,PATCH,DELETE,OPTIONS", + }); + res.end(); +} + +function errorJson(res: ServerResponse, status: number, message: string) { + json(res, { name: "GigacodeError", data: { message } }, status); +} + +async function readJson( + req: IncomingMessage, +): Promise> { + let size = 0; + const chunks: Buffer[] = []; + for await (const chunk of req) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > MAX_BODY_BYTES) + throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`); + chunks.push(buffer); + } + if (chunks.length === 0) return {}; + const value: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("request body must be a JSON object"); + } + return value as Record; +} + +function directoryFor(req: IncomingMessage, url: URL) { + const requested = + url.searchParams.get("directory") ?? + url.searchParams.get("location[directory]") ?? + url.searchParams.get("location.directory") ?? + (typeof req.headers["x-opencode-directory"] === "string" + ? req.headers["x-opencode-directory"] + : DEFAULT_DIRECTORY); + return canonicalDirectory(requested); +} + +function providerPayload(cache?: ModelCache) { + const all = Object.entries(harnesses).map(([name, entry]) => ({ + id: name, + name: entry.label, + source: "env", + env: [], + options: {}, + models: Object.fromEntries( + ( + cache?.providers[name as Harness]?.models ?? [ + { id: "default", name: entry.label }, + ] + ).map((model) => [ + model.id, + { + id: model.id, + providerID: name, + name: model.name, + family: model.family ?? name, + status: "active", + api: { id: model.id, url: "", npm: "@ai-sdk/openai-compatible" }, + capabilities: { + temperature: false, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: true, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + limit: { context: 1_000_000, output: 1_000_000 }, + options: {}, + headers: {}, + release_date: "1970-01-01", + }, + ]), + ), + })); + return { + all, + providers: all, + default: Object.fromEntries( + Object.keys(harnesses).map((name) => [ + name, + cache?.providers[name as Harness]?.defaultModel ?? "default", + ]), + ), + connected: Object.keys(harnesses), + }; +} + +function modelOptionValues(option: Record): AgentModel[] { + const legacy = Array.isArray(option.allowedValues) + ? option.allowedValues + : []; + const legacyModels = legacy.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const record = value as Record; + if (typeof record.id !== "string") return []; + return [ + { + id: record.id, + name: typeof record.label === "string" ? record.label : record.id, + }, + ]; + }); + if (legacyModels.length > 0) return legacyModels; + + const stable = Array.isArray(option.options) ? option.options : []; + return stable.flatMap((value) => { + if (!value || typeof value !== "object") return []; + const record = value as Record; + if (typeof record.value === "string") { + return [ + { + id: record.value, + name: typeof record.name === "string" ? record.name : record.value, + }, + ]; + } + if (!Array.isArray(record.options)) return []; + const family = + typeof record.name === "string" + ? record.name + : typeof record.group === "string" + ? record.group + : undefined; + return record.options.flatMap((nested) => { + if (!nested || typeof nested !== "object") return []; + const item = nested as Record; + if (typeof item.value !== "string") return []; + return [ + { + id: item.value, + name: typeof item.name === "string" ? item.name : item.value, + ...(family ? { family } : {}), + }, + ]; + }); + }); +} + +function catalogFromConfigOptions( + value: unknown, +): AgentModelCatalog | undefined { + if (!Array.isArray(value)) return undefined; + const option = value.find((entry) => { + if (!entry || typeof entry !== "object") return false; + const record = entry as Record; + return ( + record.category === "model" || + record.id === "model" || + record.id === "models" + ); + }) as Record | undefined; + if (!option) return undefined; + const models = modelOptionValues(option); + const currentValue = + typeof option.currentValue === "string" ? option.currentValue : undefined; + if (models.length === 0 && currentValue) { + models.push({ id: currentValue, name: currentValue }); + } + if (models.length === 0) return undefined; + const deduplicated = [ + ...new Map(models.map((model) => [model.id, model])).values(), + ]; + return { + models: deduplicated, + defaultModel: + currentValue && deduplicated.some((model) => model.id === currentValue) + ? currentValue + : deduplicated[0].id, + }; +} + +function sessionValue(meta: SessionMeta) { + return { + id: meta.id, + slug: `gigacode-${meta.id.slice(0, 8)}`, + version: VERSION, + projectID: "gigacode-local", + directory: meta.directory, + title: meta.title, + time: { created: meta.createdAt, updated: meta.updatedAt }, + ...(meta.harness + ? { + providerID: meta.harness, + model: { + id: meta.model ?? "default", + providerID: meta.harness, + }, + agent: "build", + } + : {}), + }; +} + +function assistantInfo( + meta: SessionMeta, + messageId: string, + parentId: string, + completed?: number, +) { + return { + id: messageId, + sessionID: meta.id, + role: "assistant", + time: { created: now(), ...(completed ? { completed } : {}) }, + parentID: parentId, + modelID: meta.model ?? "default", + providerID: meta.harness ?? "claude", + mode: "build", + agent: "build", + path: { cwd: meta.directory, root: meta.directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + }; +} + +async function ensureRunnerReady(): Promise { + const datacentersResponse = await fetch( + `${RIVET_ENDPOINT}/datacenters?namespace=default`, + ); + if (!datacentersResponse.ok) { + throw new Error( + `Rivet datacenter list failed (${datacentersResponse.status}): ${await datacentersResponse.text()}`, + ); + } + const datacenters = (await datacentersResponse.json()) as { + datacenters?: Array<{ name?: string }>; + }; + const datacenter = datacenters.datacenters?.[0]?.name; + if (!datacenter) throw new Error("Rivet engine returned no local datacenter"); + const configureDeadline = Date.now() + 30_000; + while (true) { + const configureResponse = await fetch( + `${RIVET_ENDPOINT}/runner-configs/${RUNNER_NAME}?namespace=default`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + datacenters: { [datacenter]: { normal: {} } }, + }), + }, + ); + if (configureResponse.ok) break; + const body = await configureResponse.text(); + const namespacePending = + (configureResponse.status === 400 || configureResponse.status === 404) && + body.includes('"group":"namespace"') && + body.includes('"code":"not_found"'); + if (!namespacePending || Date.now() >= configureDeadline) { + throw new Error( + `Rivet runner configuration failed (${configureResponse.status}): ${body}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const response = await fetch( + `${RIVET_ENDPOINT}/envoys?namespace=default&name=${RUNNER_NAME}`, + ).catch((error) => { + if (process.env.GIGACODE_DEBUG) + console.error("waiting for Rivet runner", error); + return undefined; + }); + if (response?.ok) { + const body = (await response.json()) as { envoys?: unknown[] }; + if ((body.envoys?.length ?? 0) > 0) return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Rivet runner ${RUNNER_NAME} did not become ready`); +} + +class RivetRuntime { + #client?: GigacodeClient; + #coordinator?: GigacodeHandle; + #connections = new Map>(); + #actorTurnTails = new Map>(); + #registry?: { shutdown(): Promise }; + #ready = false; + #started?: Promise; + #startupStage = "waiting to start RivetKit"; + + start(): Promise { + this.#started ??= this.#initialize(); + return this.#started; + } + + isReady(): boolean { + return this.#ready; + } + + startupStage(): string { + return this.#startupStage; + } + + #setStartupStage(stage: string, startedAt?: number): void { + this.#startupStage = stage; + startupLog(stage, startedAt); + } + + async client(): Promise { + await this.start(); + if (!this.#client) throw new Error("Gigacode Rivet client is unavailable"); + return this.#client; + } + + async workspace(directory: string): Promise<{ + handle: GigacodeHandle; + actorId: string; + }> { + const canonical = canonicalDirectory(directory); + const client = await this.client(); + const handle = client.vm.getOrCreate(workspaceKey(canonical), { + createWithInput: actorCreateInput(canonical), + }); + return { handle, actorId: await handle.resolve() }; + } + + async listSessions(): Promise { + const rows = (await ( + await this.#coordinatorHandle() + ).listSessions()) as Array>; + return rows.map(sessionFromCoordinatorRow); + } + + connection(actorId: string): Promise { + let pending = this.#connections.get(actorId); + if (!pending) { + pending = (async () => { + const client = await this.client(); + const connection = client.vm.getForId(actorId).connect(); + // Keep one subscription for each actor-wide event alive for the daemon's + // lifetime. Per-request listeners come and go without racing a subscribe. + connection.on("sessionEvent", () => {}); + connection.on("promptResult", () => {}); + connection.on("permissionRequest", () => {}); + connection.on("processOutput", () => {}); + await connection.ready; + await connection.exists(WORKSPACE_MOUNT_PATH); + return connection; + })().catch((error) => { + this.#connections.delete(actorId); + throw error; + }); + this.#connections.set(actorId, pending); + } + return pending; + } + + async withActorTurn(actorId: string, run: () => Promise): Promise { + const previous = this.#actorTurnTails.get(actorId) ?? Promise.resolve(); + let release = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => current); + this.#actorTurnTails.set(actorId, tail); + await previous; + try { + return await run(); + } finally { + release(); + if (this.#actorTurnTails.get(actorId) === tail) { + this.#actorTurnTails.delete(actorId); + } + } + } + + async saveSession(meta: SessionMeta): Promise { + await (await this.#coordinatorHandle()).putSession({ + ...meta, + actorSessionId: meta.actorSessionId ?? null, + harness: meta.harness ?? null, + model: meta.model ?? null, + needsTranscriptHandoff: meta.needsTranscriptHandoff ? 1 : 0, + }); + } + + async deleteSession(sessionId: string): Promise { + return Boolean( + await (await this.#coordinatorHandle()).deleteSession(sessionId), + ); + } + + async #coordinatorHandle(): Promise { + if (!this.#coordinator) { + const client = await this.client(); + this.#coordinator = client[COORDINATOR_NAME].getOrCreate(COORDINATOR_KEY); + await this.#coordinator.resolve(); + } + return this.#coordinator; + } + + async shutdown(): Promise { + const connections = [...this.#connections.values()]; + this.#connections.clear(); + await Promise.allSettled( + connections.map(async (pending) => (await pending).dispose()), + ); + await this.#registry?.shutdown(); + await stopRivetEngine(); + } + + async #initialize(): Promise { + const dependencyStarted = performance.now(); + this.#setStartupStage("loading AgentOS and RivetKit modules"); + const [ + agentos, + clientModule, + rivetkit, + rivetDatabase, + claude, + codex, + opencode, + pi, + ] = await Promise.all([ + importModule("@rivet-dev/agentos"), + importModule("@rivet-dev/agentos/client"), + importModule("rivetkit"), + importModule("rivetkit/db"), + importModule("@agentos-software/claude-code"), + importModule("@agentos-software/codex"), + importModule("@agentos-software/opencode"), + importModule("@agentos-software/pi"), + ]); + this.#setStartupStage( + "loaded AgentOS and RivetKit modules", + dependencyStarted, + ); + const softwareStarted = performance.now(); + this.#setStartupStage("resolving AgentOS software packages"); + const stableSoftwareDirectory = process.env.GIGACODE_SOFTWARE_DIR; + const software = stableSoftwareDirectory + ? [ + "coreutils", + "sed", + "grep", + "gawk", + "findutils", + "diffutils", + "tar", + "gzip", + "claude-code", + "codex-cli", + "opencode", + "pi", + ].map((name) => ({ + packagePath: join(stableSoftwareDirectory, `${name}.aospkg`), + })) + : [claude.default, codex.default, opencode.default, pi.default]; + this.#setStartupStage( + "resolved AgentOS software packages", + softwareStarted, + ); + const actorOptions = { + software, + defaultSoftware: stableSoftwareDirectory ? false : undefined, + loopbackExemptPorts: loopbackExemptPorts(), + permissions: NETWORK_PERMISSION + ? { network: NETWORK_PERMISSION } + : undefined, + mounts: hostCredentialMounts(), + }; + const coordinator = rivetkit.actor({ + db: rivetDatabase.db({ + onMigrate: async (db: any) => { + await db.execute(` + CREATE TABLE IF NOT EXISTS workspaces ( + directory TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS workspaces_actor_id + ON workspaces(actor_id); + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + directory TEXT NOT NULL, + actor_id TEXT NOT NULL, + title TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + harness TEXT, + model TEXT, + actor_session_id TEXT, + needs_transcript_handoff INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS sessions_directory_updated + ON sessions(directory, updated_at DESC); + `); + }, + }), + actions: { + listSessions: async (c: any) => + await c.db.execute(` + SELECT id, directory, actor_id AS actorId, title, + created_at AS createdAt, updated_at AS updatedAt, + harness, model, actor_session_id AS actorSessionId, + needs_transcript_handoff AS needsTranscriptHandoff + FROM sessions + ORDER BY updated_at DESC + LIMIT ${MAX_SESSIONS} + `), + putSession: async (c: any, meta: Record) => { + const existing = await c.db.execute( + "SELECT id FROM sessions WHERE id = ? LIMIT 1", + meta.id, + ); + if (existing.length === 0) { + const [{ count = 0 } = {}] = await c.db.execute( + "SELECT COUNT(*) AS count FROM sessions", + ); + if (Number(count) >= MAX_SESSIONS) { + throw new Error( + `session limit reached (${MAX_SESSIONS}); delete a session before creating another`, + ); + } + } + await c.db.execute( + `INSERT INTO workspaces (directory, actor_id, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(directory) DO UPDATE SET + actor_id = excluded.actor_id, + updated_at = excluded.updated_at`, + meta.directory, + meta.actorId, + meta.createdAt, + meta.updatedAt, + ); + await c.db.execute( + `INSERT INTO sessions ( + id, directory, actor_id, title, created_at, updated_at, + harness, model, actor_session_id, needs_transcript_handoff + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + directory = excluded.directory, + actor_id = excluded.actor_id, + title = excluded.title, + updated_at = excluded.updated_at, + harness = excluded.harness, + model = excluded.model, + actor_session_id = excluded.actor_session_id, + needs_transcript_handoff = excluded.needs_transcript_handoff`, + meta.id, + meta.directory, + meta.actorId, + meta.title, + meta.createdAt, + meta.updatedAt, + meta.harness, + meta.model, + meta.actorSessionId, + meta.needsTranscriptHandoff, + ); + }, + deleteSession: async (c: any, sessionId: string) => { + const deleted = await c.db.execute( + "DELETE FROM sessions WHERE id = ? RETURNING id", + sessionId, + ); + return deleted.length > 0; + }, + }, + }); + for (let attempt = 1; attempt <= 2; attempt++) { + const engineStarted = performance.now(); + this.#setStartupStage( + `starting Rivet engine on ${HOST}:${RIVET_PORT} (attempt ${attempt}/2)`, + ); + const vm = agentos.agentOS(actorOptions); + const catalog = agentos.agentOS(actorOptions); + const registry = agentos.setup({ + use: { vm, catalog, coordinator }, + startEngine: true, + engineHost: HOST, + enginePort: RIVET_PORT, + shutdown: { disableSignalHandlers: true, gracePeriodMs: 30_000 }, + }); + this.#registry = registry; + try { + registry.start(); + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + const response = await fetch(`${RIVET_ENDPOINT}/health`); + if (response.ok) break; + } catch (error) { + if (process.env.GIGACODE_DEBUG) + console.error("waiting for Rivet engine", error); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const health = await fetch(`${RIVET_ENDPOINT}/health`).catch( + () => undefined, + ); + if (!health?.ok) + throw new Error( + `Rivet engine did not become ready at ${RIVET_ENDPOINT}`, + ); + this.#setStartupStage("Rivet engine is healthy", engineStarted); + const runnerStarted = performance.now(); + this.#setStartupStage("configuring the local Rivet runner"); + await ensureRunnerReady(); + this.#setStartupStage("local Rivet runner is ready", runnerStarted); + this.#client = clientModule.createClient({ endpoint: RIVET_ENDPOINT }); + const coordinatorStarted = performance.now(); + this.#setStartupStage("starting the SQLite session coordinator"); + this.#coordinator = + this.#client[COORDINATOR_NAME].getOrCreate(COORDINATOR_KEY); + await this.#coordinator.resolve(); + this.#setStartupStage( + "SQLite session coordinator is ready", + coordinatorStarted, + ); + this.#ready = true; + this.#setStartupStage("Rivet runtime is ready"); + return; + } catch (error) { + await registry + .shutdown() + .catch((shutdownError: unknown) => + console.error( + "failed to reset RivetKit after startup", + shutdownError, + ), + ); + await stopRivetEngine(); + this.#registry = undefined; + if ( + attempt === 2 || + !(error instanceof Error) || + !error.message.startsWith("Rivet engine did not become ready") + ) { + this.#setStartupStage(`Rivet startup failed: ${String(error)}`); + throw error; + } + startupLog("cold Rivet start failed; retrying once"); + } + } + } +} + +class GlobalModelCatalog { + #cache?: ModelCache; + #loaded?: Promise; + #refreshing?: Promise; + #startupStage = "waiting to load the model catalog"; + + constructor(private readonly runtime: RivetRuntime) {} + + start(): void { + void this.#load() + .then(() => this.refresh()) + .catch((error) => + console.error("Gigacode model catalog refresh failed", error), + ); + } + + startupStage(): string { + return this.#startupStage; + } + + #setStartupStage(stage: string, startedAt?: number): void { + this.#startupStage = stage; + startupLog(stage, startedAt); + } + + async payload(): Promise> { + await this.#load(); + if (!this.#cache) await this.refresh(); + return providerPayload(this.#cache); + } + + refresh(): Promise { + this.#refreshing ??= this.#refresh().finally(() => { + this.#refreshing = undefined; + }); + return this.#refreshing; + } + + async #load(): Promise { + this.#loaded ??= (async () => { + let raw: string; + try { + raw = await readFile(MODEL_CACHE_FILE, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + this.#setStartupStage( + "no model cache found; /provider will wait for discovery", + ); + return; + } + throw error; + } + if (Buffer.byteLength(raw) > MAX_MODEL_CACHE_BYTES) { + console.warn( + `ignoring oversized Gigacode model cache at ${MODEL_CACHE_FILE}`, + ); + return; + } + try { + const parsed = JSON.parse(raw) as Partial; + if ( + parsed.version === 1 && + typeof parsed.updatedAt === "number" && + parsed.providers && + typeof parsed.providers === "object" + ) { + this.#cache = parsed as ModelCache; + this.#setStartupStage("loaded the cached model catalog"); + } + } catch (error) { + console.warn(`ignoring invalid Gigacode model cache: ${String(error)}`); + } + })(); + await this.#loaded; + } + + async #refresh(): Promise { + await this.#load(); + const startedAt = performance.now(); + this.#setStartupStage("discovering models from AgentOS harnesses"); + const client = await this.runtime.client(); + const handle = client.catalog.getOrCreate("global"); + const providers: Partial> = { + ...this.#cache?.providers, + }; + for (const [agentType, harness] of Object.entries(harnesses) as Array< + [Harness, (typeof harnesses)[Harness]] + >) { + try { + if (agentType === "pi") await preparePiSession(handle); + const configOptions = await handle.probeAgentConfig(agentType, { + cwd: "/", + env: SESSION_ENV, + }); + providers[agentType] = catalogFromConfigOptions(configOptions) ?? { + defaultModel: "default", + models: [{ id: "default", name: harness.label }], + }; + } catch (error) { + console.warn(`model probe failed for ${agentType}: ${String(error)}`); + providers[agentType] ??= { + defaultModel: "default", + models: [{ id: "default", name: harness.label }], + }; + } + } + const next: ModelCache = { version: 1, updatedAt: now(), providers }; + await mkdir(STATE_DIR, { recursive: true }); + const temporary = `${MODEL_CACHE_FILE}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temporary, MODEL_CACHE_FILE); + this.#cache = next; + this.#setStartupStage("model catalog is ready", startedAt); + return next; + } +} + +async function syncSessions(state: CompatState, runtime: RivetRuntime) { + state.sessionSyncTail ??= (async () => { + let sessions = await runtime.listSessions(); + if (sessions.length === 0) { + const legacy = await readLegacySessionMetadata(); + if (legacy.length > 0) { + for (const old of legacy) { + let directory: string; + try { + directory = canonicalDirectory(old.directory); + } catch (error) { + console.error( + `skipping legacy Gigacode session ${old.id}: invalid workspace`, + error, + ); + continue; + } + const { actorId } = await runtime.workspace(directory); + const migrated: SessionMeta = { + ...old, + directory, + actorId, + actorSessionId: undefined, + needsTranscriptHandoff: Boolean(old.actorSessionId), + }; + await runtime.saveSession(migrated); + } + await rename( + LEGACY_SESSION_METADATA_FILE, + `${LEGACY_SESSION_METADATA_FILE}.migrated`, + ); + console.warn( + `migrated ${legacy.length} legacy Gigacode sessions into the coordinator actor`, + ); + sessions = await runtime.listSessions(); + } + } + if (sessions.length >= Math.floor(MAX_SESSIONS * 0.9)) { + console.warn( + `gigacode session count is near limit (${sessions.length}/${MAX_SESSIONS})`, + ); + } + if (!state.sessionsInitialized) { + for (const session of sessions) { + if (!session.actorSessionId) continue; + session.actorSessionId = undefined; + session.needsTranscriptHandoff = true; + await runtime.saveSession(session); + } + state.sessionsInitialized = true; + } + const active = new Set(sessions.map((session) => session.id)); + for (const session of sessions) { + const cached = state.sessions.get(session.id); + if (cached) Object.assign(cached, session); + else state.sessions.set(session.id, session); + state.statuses.set(session.id, state.statuses.get(session.id) ?? "idle"); + } + for (const sessionId of [...state.sessions.keys()]) { + if (active.has(sessionId)) continue; + state.sessions.delete(sessionId); + state.messages.delete(sessionId); + state.statuses.delete(sessionId); + } + if (!state.messagesLoaded) { + await loadMessages(state); + state.messagesLoaded = true; + } + })().finally(() => { + state.sessionSyncTail = undefined; + }); + await state.sessionSyncTail; +} + +function extractText(body: Record) { + const parts = Array.isArray(body.parts) ? body.parts : []; + return parts + .map((part) => { + if (!part || typeof part !== "object") return ""; + const value = part as Record; + if (typeof value.text === "string") return value.text; + if (value.type === "file") { + const source = + value.source && typeof value.source === "object" + ? (value.source as Record) + : undefined; + const location = + (typeof source?.path === "string" && source.path) || + (typeof value.filename === "string" && value.filename) || + (typeof value.url === "string" && value.url); + return location ? `[Attached file: ${location}]` : "[Attached file]"; + } + if (value.type === "agent" && typeof value.name === "string") { + return `[Requested agent: ${value.name}]`; + } + if (value.type === "subtask" && typeof value.prompt === "string") { + return value.prompt; + } + return ""; + }) + .filter(Boolean) + .join("\n"); +} + +function locationValue(directory: string) { + return { + directory, + project: { id: "gigacode-local", directory }, + }; +} + +async function findFileSystemEntries( + directory: string, + query: string, + type: string | null, + requestedLimit: string | null, +) { + const parsedLimit = Number(requestedLimit); + const limit = + Number.isSafeInteger(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, MAX_FS_FIND_RESULTS) + : Math.min(50, MAX_FS_FIND_RESULTS); + const root = resolve(directory); + const pending = [root]; + const matches: Array<{ path: string; type: "file" | "directory" }> = []; + const needle = query.toLocaleLowerCase(); + let scanned = 0; + const excludedDirectories = new Set([ + ".git", + ".jj", + ".turbo", + "node_modules", + "target", + ]); + while (pending.length > 0 && matches.length < limit) { + const current = pending.shift() as string; + let entries: Dirent[]; + try { + entries = await readdir(current, { withFileTypes: true }); + } catch (error) { + if (current === root) throw error; + console.warn( + `skipping unreadable file-search directory ${current}`, + error, + ); + continue; + } + for (const entry of entries) { + scanned += 1; + if (scanned > MAX_FS_FIND_SCAN) { + throw new Error( + `file search exceeded ${MAX_FS_FIND_SCAN} entries; narrow the query or raise GIGACODE_MAX_FS_FIND_SCAN`, + ); + } + const isDirectory = entry.isDirectory(); + if (isDirectory && excludedDirectories.has(entry.name)) continue; + if (!isDirectory && !entry.isFile()) continue; + const absolute = join(current, entry.name); + const relative = absolute.slice(root.length + 1); + if (isDirectory) pending.push(absolute); + const entryType = isDirectory ? "directory" : "file"; + if (type && type !== entryType) continue; + if (needle && !relative.toLocaleLowerCase().includes(needle)) continue; + matches.push({ path: relative, type: entryType }); + if (matches.length >= limit) break; + } + } + return matches; +} + +function selectedHarness( + body: Record, + fallback?: Harness, +): Harness { + const model = + body.model && typeof body.model === "object" + ? (body.model as Record) + : {}; + const candidate = body.providerID ?? model.providerID ?? body.agent; + if (isHarness(candidate)) return candidate; + return fallback ?? "claude"; +} + +function selectedModel( + body: Record, + fallback?: string, +): string { + const model = + body.model && typeof body.model === "object" + ? (body.model as Record) + : {}; + const candidate = body.modelID ?? model.modelID; + return typeof candidate === "string" && candidate + ? candidate + : (fallback ?? "default"); +} + +async function preparePiSession(handle: GigacodeHandle): Promise { + const agentDir = "/home/agentos/.pi/agent"; + await handle.mkdir(agentDir); + + if (PI_API_KEY || PI_BASE_URL) { + await handle.writeFile( + `${agentDir}/models.json`, + `${JSON.stringify({ + providers: { + anthropic: { + ...(PI_BASE_URL ? { baseUrl: PI_BASE_URL } : {}), + ...(PI_API_KEY ? { apiKey: PI_API_KEY } : {}), + }, + }, + })}\n`, + ); + } +} + +class TurnCancelledError extends Error { + constructor() { + super("The user cancelled this turn"); + this.name = "TurnCancelledError"; + } +} + +function structuredUnknownError(error: unknown) { + return { + name: "UnknownError", + data: { message: error instanceof Error ? error.message : String(error) }, + }; +} + +function structuredAbortedError() { + return { + name: "MessageAbortedError", + data: { message: "The user cancelled this turn" }, + }; +} + +function updateAssistantFailure( + state: CompatState, + meta: SessionMeta, + job: PromptJob, + error: unknown, +): void { + const info = job.assistant.info; + const completed = now(); + info.time = { + ...((info.time as Record | undefined) ?? {}), + completed, + }; + delete info.finish; + info.error = + error instanceof TurnCancelledError + ? structuredAbortedError() + : structuredUnknownError(error); + for (const part of job.assistant.parts) { + if (part.time && typeof part.time === "object") { + part.time = { ...(part.time as Record), end: completed }; + } + } + state.emit({ + type: "message.updated", + properties: { sessionID: meta.id, info }, + }); + state.emit({ + type: "session.error", + properties: { sessionID: meta.id, error: info.error }, + }); +} + +function enqueuePrompt( + state: CompatState, + runtime: RivetRuntime, + meta: SessionMeta, + body: Record, +): { accepted: Promise; completion: Promise } { + const text = extractText(body); + if (!text) throw new Error("prompt must contain at least one text part"); + if (state.activeShells.has(meta.id)) { + throw new Error("session is busy running a shell command"); + } + const existingQueue = state.promptQueues.get(meta.id); + if ((existingQueue?.jobs.length ?? 0) >= MAX_PROMPT_QUEUE_PER_SESSION) { + throw new Error( + `prompt queue reached ${MAX_PROMPT_QUEUE_PER_SESSION} turns; wait for work to finish or raise GIGACODE_MAX_PROMPT_QUEUE_PER_SESSION`, + ); + } + const harness = selectedHarness( + body, + existingQueue?.jobs.at(-1)?.harness ?? meta.harness, + ); + if (!meta.harness) meta.harness = harness; + const requestedModel = selectedModel( + body, + existingQueue?.jobs.at(-1)?.requestedModel ?? meta.model, + ); + meta.updatedAt = now(); + + const messages = state.messages.get(meta.id) ?? []; + if (messages.length + 2 > MAX_MESSAGES_PER_SESSION) { + throw new Error( + `session reached ${MAX_MESSAGES_PER_SESSION} messages; create a new session or raise GIGACODE_MAX_MESSAGES_PER_SESSION`, + ); + } + const userId = + typeof body.messageID === "string" + ? body.messageID + : ascendingOpenCodeId("msg"); + const userPart = { + id: ascendingOpenCodeId("prt"), + sessionID: meta.id, + messageID: userId, + type: "text", + text, + }; + const user: OpenCodeMessage = { + info: { + id: userId, + sessionID: meta.id, + role: "user", + time: { created: now() }, + agent: "build", + model: { providerID: harness, modelID: requestedModel }, + }, + parts: [userPart], + }; + const assistantId = ascendingOpenCodeId("msg"); + const assistantInfoValue = assistantInfo(meta, assistantId, userId) as Record< + string, + unknown + >; + assistantInfoValue.providerID = harness; + assistantInfoValue.modelID = requestedModel; + delete assistantInfoValue.finish; + const assistant: OpenCodeMessage = { + info: assistantInfoValue, + parts: [], + }; + + let resolveCompletion!: (message: OpenCodeMessage) => void; + let cancelJob!: () => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const cancelSignal = new Promise((resolve) => { + cancelJob = resolve; + }); + const job: PromptJob = { + body, + text, + harness, + requestedModel, + cancelled: false, + started: false, + user, + assistant, + cancelSignal, + cancel: cancelJob, + resolve: resolveCompletion, + }; + const queue = existingQueue ?? { jobs: [], running: false }; + queue.jobs.push(job); + state.promptQueues.set(meta.id, queue); + messages.push(user, assistant); + state.messages.set(meta.id, messages); + state.emit({ + type: "message.updated", + properties: { sessionID: meta.id, info: user.info }, + }); + state.emit({ + type: "message.part.updated", + properties: { sessionID: meta.id, part: userPart, time: now() }, + }); + state.emit({ + type: "message.updated", + properties: { sessionID: meta.id, info: assistant.info }, + }); + if (state.statuses.get(meta.id) !== "busy") { + state.statuses.set(meta.id, "busy"); + state.emit({ + type: "session.status", + properties: { sessionID: meta.id, status: { type: "busy" } }, + }); + } + const accepted = Promise.all([ + runtime.saveSession(meta), + saveMessages(state), + ]).then(() => undefined); + if (!queue.running) { + queue.running = true; + void drainPromptQueue(state, runtime, meta, queue); + } + return { accepted, completion }; +} + +async function drainPromptQueue( + state: CompatState, + runtime: RivetRuntime, + meta: SessionMeta, + queue: PromptQueue, +): Promise { + try { + while (queue.jobs.length > 0) { + const job = queue.jobs[0] as PromptJob; + job.started = true; + try { + if (job.cancelled) throw new TurnCancelledError(); + if (job.cancelled) throw new TurnCancelledError(); + await runPrompt(state, runtime, meta, job); + } catch (error) { + const normalized = job.cancelled ? new TurnCancelledError() : error; + updateAssistantFailure(state, meta, job, normalized); + sessionLog(meta.id).error( + { event: "prompt.failed", err: normalized }, + "Gigacode prompt failed", + ); + } finally { + queue.jobs.shift(); + await saveMessages(state).catch((error) => + console.error(`failed to persist messages for ${meta.id}`, error), + ); + job.resolve(job.assistant); + } + } + } finally { + queue.running = false; + if (state.promptQueues.get(meta.id) === queue) { + state.promptQueues.delete(meta.id); + } + if (state.sessions.has(meta.id)) { + state.statuses.set(meta.id, "idle"); + state.emit({ + type: "session.status", + properties: { sessionID: meta.id, status: { type: "idle" } }, + }); + state.emit({ + type: "session.idle", + properties: { sessionID: meta.id }, + }); + sessionLog(meta.id).info({ event: "session.idle" }); + } + } +} + +async function abortPromptQueue( + state: CompatState, + runtime: RivetRuntime, + meta: SessionMeta, +): Promise { + const queue = state.promptQueues.get(meta.id); + for (const job of queue?.jobs ?? []) { + job.cancelled = true; + job.cancel(); + } + for (const [permissionId, permission] of state.permissions) { + if (permission.sessionID !== meta.id) continue; + state.permissions.delete(permissionId); + state.emit({ + type: "permission.replied", + properties: { + sessionID: meta.id, + requestID: permissionId, + reply: "reject", + }, + }); + } + const active = + Boolean(queue?.jobs.some((job) => job.started)) || + state.activeShells.has(meta.id); + if (active && !state.cancellationBarriers.has(meta.id)) { + const actorId = runtimeActorId(meta); + const actorSessionId = meta.actorSessionId; + const shellPid = state.activeShellPids.get(meta.id); + const barrier = withinTimeout( + (async () => { + const client = await runtime.client(); + const handle = client.vm.getForId(actorId); + const cancellations: Promise[] = []; + if (actorSessionId) { + cancellations.push(handle.cancelSession(actorSessionId)); + } + if (shellPid !== undefined) { + cancellations.push(handle.killProcess(shellPid)); + } + await Promise.all(cancellations); + sessionLog(meta.id).info({ + event: "agentos.turn.cancelled", + actorSessionId, + shellPid, + }); + })(), + CANCEL_QUIESCE_TIMEOUT_MS, + `AgentOS cancellation for ${meta.id}`, + ); + state.cancellationBarriers.set(meta.id, barrier); + void barrier.catch((error) => { + sessionLog(meta.id).error( + { event: "agentos.turn.cancel_failed", err: error }, + "AgentOS cancellation failed", + ); + }); + sessionLog(meta.id).info({ + event: "agentos.turn.cancel_started", + actorId, + actorSessionId, + shellPid, + }); + } + return active || Boolean(queue?.jobs.length); +} + +function acpContentText(value: unknown): string { + if (!value || typeof value !== "object") return ""; + const content = value as Record; + return typeof content.text === "string" ? content.text : ""; +} + +function acpToolOutput(value: unknown): string { + if (!Array.isArray(value)) return ""; + return value + .map((item) => { + if (!item || typeof item !== "object") return ""; + const record = item as Record; + return acpContentText(record.content) || acpContentText(record); + }) + .filter(Boolean) + .join("\n"); +} + +function updateStreamingPart( + state: CompatState, + meta: SessionMeta, + job: PromptJob, + type: "text" | "reasoning", + delta: string, +): void { + if (!delta) return; + const assistantId = job.assistant.info.id as string; + let part = job.assistant.parts.find((candidate) => candidate.type === type); + if (!part) { + part = { + id: ascendingOpenCodeId("prt"), + sessionID: meta.id, + messageID: assistantId, + type, + text: "", + time: { start: now() }, + }; + job.assistant.parts.push(part); + } + part.text = `${typeof part.text === "string" ? part.text : ""}${delta}`; + state.emit({ + type: "message.part.updated", + properties: { sessionID: meta.id, part, delta, time: now() }, + }); +} + +function updateStreamingTool( + state: CompatState, + meta: SessionMeta, + job: PromptJob, + update: Record, +): void { + const callId = + typeof update.toolCallId === "string" && update.toolCallId + ? update.toolCallId + : id("call"); + let part = job.assistant.parts.find( + (candidate) => candidate.type === "tool" && candidate.callID === callId, + ); + const timestamp = now(); + const previousState = + part?.state && typeof part.state === "object" + ? (part.state as Record) + : undefined; + const input = + update.rawInput && typeof update.rawInput === "object" + ? (update.rawInput as Record) + : ((previousState?.input as Record | undefined) ?? {}); + const title = + typeof update.title === "string" + ? update.title + : typeof part?.tool === "string" + ? part.tool + : "tool"; + const started = + (previousState?.time as { start?: number } | undefined)?.start ?? timestamp; + const output = + acpToolOutput(update.content) || + (typeof update.rawOutput === "string" + ? update.rawOutput + : update.rawOutput === undefined + ? "" + : JSON.stringify(update.rawOutput)); + const status = String(update.status ?? "pending"); + let toolState: Record; + if (status === "completed") { + toolState = { + status: "completed", + input, + output, + title, + metadata: {}, + time: { start: started, end: timestamp }, + }; + } else if (status === "failed") { + toolState = { + status: "error", + input, + error: output || `${title} failed`, + metadata: {}, + time: { start: started, end: timestamp }, + }; + } else if (status === "in_progress") { + toolState = { + status: "running", + input, + title, + metadata: output ? { output } : {}, + time: { start: started }, + }; + } else { + toolState = { status: "pending", input, raw: JSON.stringify(input) }; + } + if (!part) { + part = { + id: ascendingOpenCodeId("prt"), + sessionID: meta.id, + messageID: job.assistant.info.id, + type: "tool", + callID: callId, + tool: title, + state: toolState, + }; + job.assistant.parts.push(part); + } else { + part.tool = title; + part.state = toolState; + } + state.emit({ + type: "message.part.updated", + properties: { sessionID: meta.id, part, time: timestamp }, + }); +} + +function applySessionEvent( + state: CompatState, + meta: SessionMeta, + job: PromptJob, + payload: unknown, +): void { + if (!payload || typeof payload !== "object") return; + const wrapper = payload as Record; + if ( + typeof wrapper.sessionId === "string" && + wrapper.sessionId !== meta.actorSessionId + ) + return; + const event = + wrapper.event && typeof wrapper.event === "object" + ? (wrapper.event as Record) + : undefined; + if (!event || event.method !== "session/update") return; + const params = + event.params && typeof event.params === "object" + ? (event.params as Record) + : undefined; + const update = + params?.update && typeof params.update === "object" + ? (params.update as Record) + : undefined; + if (!update) return; + switch (update.sessionUpdate) { + case "agent_message_chunk": + updateStreamingPart( + state, + meta, + job, + "text", + acpContentText(update.content), + ); + break; + case "agent_thought_chunk": + updateStreamingPart( + state, + meta, + job, + "reasoning", + acpContentText(update.content), + ); + break; + case "tool_call": + case "tool_call_update": + updateStreamingTool(state, meta, job, update); + break; + } +} + +function harnessHandoffPrompt( + state: CompatState, + meta: SessionMeta, + currentText: string, +): string { + const priorMessages = (state.messages.get(meta.id) ?? []).slice(0, -2); + const transcript = priorMessages + .map((message) => { + const role = message.info.role === "assistant" ? "Assistant" : "User"; + const content = message.parts + .map((part) => { + if (typeof part.text === "string") return part.text; + if ( + part.type !== "tool" || + !part.state || + typeof part.state !== "object" + ) + return ""; + const toolState = part.state as Record; + return typeof toolState.output === "string" + ? `[Tool output: ${toolState.output}]` + : typeof toolState.error === "string" + ? `[Tool error: ${toolState.error}]` + : ""; + }) + .filter(Boolean) + .join("\n"); + return content ? `${role}: ${content}` : ""; + }) + .filter(Boolean) + .join("\n\n"); + const boundedTranscript = + Buffer.byteLength(transcript) <= MAX_HARNESS_HANDOFF_BYTES + ? transcript + : Buffer.from(transcript) + .subarray(-MAX_HARNESS_HANDOFF_BYTES) + .toString("utf8"); + return [ + "You are taking over an existing Gigacode conversation from another AgentOS harness.", + "Use the bounded transcript below as context, then answer the current request. Do not restate the transcript.", + boundedTranscript + ? `\n\n${boundedTranscript}\n` + : "", + `\nCurrent request:\n${currentText}`, + ] + .filter(Boolean) + .join("\n"); +} + +async function deactivateOtherActorSessions( + state: CompatState, + runtime: RivetRuntime, + handle: GigacodeHandle, + meta: SessionMeta, +): Promise { + for (const other of state.sessions.values()) { + if ( + other.id === meta.id || + other.actorId !== meta.actorId || + !other.actorSessionId + ) { + continue; + } + const actorSessionId = other.actorSessionId; + await handle.closeSession(actorSessionId); + other.actorSessionId = undefined; + other.needsTranscriptHandoff = true; + other.updatedAt = now(); + await runtime.saveSession(other); + sessionLog(other.id).info({ + event: "agentos.session.deactivated", + actorSessionId, + activatedSessionId: meta.id, + }); + } +} + +async function runPromptInActor( + state: CompatState, + runtime: RivetRuntime, + meta: SessionMeta, + job: PromptJob, +) { + const client = await runtime.client(); + const { text, harness, requestedModel } = job; + const cancellationBarrier = state.cancellationBarriers.get(meta.id); + if (cancellationBarrier) { + try { + await cancellationBarrier; + } finally { + if (state.cancellationBarriers.get(meta.id) === cancellationBarrier) { + state.cancellationBarriers.delete(meta.id); + } + } + } + let promptText = text; + meta.updatedAt = now(); + const handle = client.vm.getForId(runtimeActorId(meta)); + const log = sessionLog(meta.id); + const promptStarted = performance.now(); + log.info({ + event: "prompt.received", + harness, + textBytes: Buffer.byteLength(text), + }); + await deactivateOtherActorSessions(state, runtime, handle, meta); + if (meta.actorSessionId && meta.harness && meta.harness !== harness) { + const previousHarness = meta.harness; + await handle.closeSession(meta.actorSessionId); + meta.actorSessionId = undefined; + meta.model = undefined; + meta.harness = harness; + promptText = harnessHandoffPrompt(state, meta, text); + delete meta.needsTranscriptHandoff; + await runtime.saveSession(meta); + log.info({ + event: "agentos.session.harness_switched", + from: previousHarness, + to: harness, + handoffBytes: Buffer.byteLength(promptText), + }); + } else { + meta.harness = harness; + } + if (meta.needsTranscriptHandoff) { + promptText = harnessHandoffPrompt(state, meta, text); + delete meta.needsTranscriptHandoff; + await runtime.saveSession(meta); + log.info({ + event: "agentos.session.transcript_handoff", + handoffBytes: Buffer.byteLength(promptText), + }); + } + if (!meta.actorSessionId) { + if (harness === "pi") { + const started = performance.now(); + await preparePiSession(handle); + log.info({ + event: "pi.configuration.prepared", + durationMs: performance.now() - started, + }); + } + const createStarted = performance.now(); + meta.actorSessionId = await handle.createSession(harness, { + cwd: WORKSPACE_MOUNT_PATH, + env: SESSION_ENV, + }); + log.info({ + event: "agentos.session.created", + durationMs: performance.now() - createStarted, + actorSessionId: meta.actorSessionId, + }); + await runtime.saveSession(meta); + } + if (requestedModel !== "default" && meta.model !== requestedModel) { + const response = await handle.setSessionModel( + meta.actorSessionId as string, + requestedModel, + ); + if (response?.error) { + throw new Error( + `failed to select ${harness} model ${requestedModel}: ${response.error.message ?? "unknown ACP error"}`, + ); + } + meta.model = requestedModel; + await runtime.saveSession(meta); + log.info({ + event: "agentos.session.model.selected", + model: requestedModel, + }); + } else if (!meta.model) { + meta.model = requestedModel; + } + if (job.cancelled) throw new TurnCancelledError(); + const userId = job.user.info.id as string; + const assistantId = job.assistant.info.id as string; + const connection = await runtime.connection(runtimeActorId(meta)); + const unsubscribeSessionEvents = connection.on( + "sessionEvent", + (payload: unknown) => { + try { + applySessionEvent(state, meta, job, payload); + } catch (error) { + log.error( + { event: "agentos.session_event.invalid", err: error }, + "Failed to translate an AgentOS session event", + ); + } + }, + ); + const unsubscribePermissions = connection.on( + "permissionRequest", + (payload: any) => { + if (payload?.sessionId !== meta.actorSessionId) return; + const request = payload.request; + const permission: PermissionRecord = { + id: request.permissionId, + sessionID: meta.id, + actorSessionId: meta.actorSessionId as string, + messageID: assistantId, + createdAt: now(), + description: request.description, + params: request.params, + }; + state.permissions.set(permission.id, permission); + state.emit({ + type: "permission.asked", + properties: { + id: permission.id, + sessionID: meta.id, + permission: permission.description ?? "AgentOS permission", + patterns: ["*"], + metadata: permission.params, + always: [], + }, + }); + }, + ); + let unsubscribePromptResult = () => {}; + + try { + const sendStarted = performance.now(); + const promptRequestId = id("prompt"); + const promptResult = new Promise((resolve, reject) => { + unsubscribePromptResult = connection.on( + "promptResult", + (payload: unknown) => { + if (!payload || typeof payload !== "object") return; + const value = payload as Record; + if (value.requestId !== promptRequestId) return; + if (typeof value.error === "string" && value.error) { + reject(new Error(value.error)); + return; + } + resolve(value.result); + }, + ); + }); + log.info({ + event: "agentos.prompt.started", + actorSessionId: meta.actorSessionId, + }); + await handle.sendPromptAsync( + meta.actorSessionId, + promptText, + promptRequestId, + ); + const result = await Promise.race([ + promptResult, + job.cancelSignal.then(() => { + throw new TurnCancelledError(); + }), + ]); + if (job.cancelled) throw new TurnCancelledError(); + if (result.response?.error) { + throw new Error( + `AgentOS prompt failed: ${result.response.error.message ?? "unknown ACP error"}`, + ); + } + log.info({ + event: "agentos.prompt.completed", + durationMs: performance.now() - sendStarted, + responseBytes: Buffer.byteLength(result.text), + }); + const completed = now(); + const info = job.assistant.info; + Object.assign(info, assistantInfo(meta, assistantId, userId, completed)); + let part = job.assistant.parts.find( + (candidate) => candidate.type === "text", + ); + const streamedText = typeof part?.text === "string" ? part.text : ""; + const delta = result.text.startsWith(streamedText) + ? result.text.slice(streamedText.length) + : undefined; + if (!part) { + part = { + id: ascendingOpenCodeId("prt"), + sessionID: meta.id, + messageID: assistantId, + type: "text", + text: result.text, + time: { start: completed, end: completed }, + }; + job.assistant.parts.push(part); + } else { + part.text = result.text; + part.time = { + ...((part.time as Record | undefined) ?? { + start: completed, + }), + end: completed, + }; + } + for (const reasoning of job.assistant.parts.filter( + (candidate) => candidate.type === "reasoning", + )) { + reasoning.time = { + ...((reasoning.time as Record | undefined) ?? { + start: completed, + }), + end: completed, + }; + } + state.emit({ + type: "message.updated", + properties: { info, sessionID: meta.id }, + }); + state.emit({ + type: "message.part.updated", + properties: { + sessionID: meta.id, + part, + ...(delta ? { delta } : {}), + time: completed, + }, + }); + log.info({ + event: "prompt.completed", + durationMs: performance.now() - promptStarted, + messageId: assistantId, + }); + return job.assistant; + } catch (error) { + log.error( + { + event: "prompt.failed", + durationMs: performance.now() - promptStarted, + err: error, + }, + "Gigacode prompt failed", + ); + throw error; + } finally { + unsubscribePromptResult(); + unsubscribePermissions(); + unsubscribeSessionEvents(); + } +} + +async function runPrompt( + state: CompatState, + runtime: RivetRuntime, + meta: SessionMeta, + job: PromptJob, +) { + return await runtime.withActorTurn(runtimeActorId(meta), () => + runPromptInActor(state, runtime, meta, job), + ); +} + +async function runShellCommand( + state: CompatState, + runtime: RivetRuntime, + meta: SessionMeta, + body: Record, +) { + if (state.promptQueues.has(meta.id) || state.activeShells.has(meta.id)) { + throw new Error("session is busy"); + } + if (typeof body.command !== "string" || !body.command.trim()) { + throw new Error("shell command must be a non-empty string"); + } + if (typeof body.agent !== "string" || !body.agent) { + throw new Error("shell agent must be a non-empty string"); + } + const harness = selectedHarness(body, meta.harness); + if (!meta.harness) meta.harness = harness; + meta.updatedAt = now(); + + const command = body.command; + const started = now(); + const userId = + typeof body.messageID === "string" + ? body.messageID + : ascendingOpenCodeId("msg"); + const userPart = { + id: ascendingOpenCodeId("prt"), + sessionID: meta.id, + messageID: userId, + type: "text", + text: "The following tool was executed by the user", + synthetic: true, + }; + const user: OpenCodeMessage = { + info: { + id: userId, + sessionID: meta.id, + role: "user", + time: { created: started }, + agent: body.agent, + model: { providerID: harness, modelID: "default" }, + }, + parts: [userPart], + }; + + const assistantId = ascendingOpenCodeId("msg"); + const info = assistantInfo(meta, assistantId, userId) as Record< + string, + unknown + >; + delete info.finish; + const toolPart: Record = { + id: ascendingOpenCodeId("prt"), + sessionID: meta.id, + messageID: assistantId, + type: "tool", + tool: "bash", + callID: id("call"), + state: { + status: "running", + time: { start: started }, + input: { command }, + }, + }; + const assistant: OpenCodeMessage = { info, parts: [toolPart] }; + const messages = state.messages.get(meta.id) ?? []; + if (messages.length + 2 > MAX_MESSAGES_PER_SESSION) { + throw new Error( + `session reached ${MAX_MESSAGES_PER_SESSION} messages; create a new session or raise GIGACODE_MAX_MESSAGES_PER_SESSION`, + ); + } + messages.push(user, assistant); + state.messages.set(meta.id, messages); + state.activeShells.add(meta.id); + state.emit({ + type: "message.updated", + properties: { info: user.info, sessionID: meta.id }, + }); + state.emit({ type: "message.part.updated", properties: { part: userPart } }); + state.emit({ + type: "message.updated", + properties: { info, sessionID: meta.id }, + }); + state.emit({ + type: "message.part.updated", + properties: { part: toolPart }, + }); + + const log = sessionLog(meta.id); + const executionStarted = performance.now(); + log.info({ + event: "agentos.shell.started", + commandBytes: Buffer.byteLength(command), + }); + const connection = await runtime.connection(runtimeActorId(meta)); + const processOutput = new Map< + number, + { stdout: Buffer[]; stderr: Buffer[] } + >(); + const unsubscribeProcessOutput = connection.on( + "processOutput", + (payload: unknown) => { + if (!payload || typeof payload !== "object") return; + const value = payload as Record; + if (typeof value.pid !== "number") return; + const chunks = processOutput.get(value.pid) ?? { stdout: [], stderr: [] }; + let data: Buffer; + try { + data = Buffer.from(value.data as Uint8Array); + } catch (error) { + log.error( + { event: "agentos.shell.output_invalid", err: error }, + "Failed to decode shell output", + ); + return; + } + if (value.stream === "stderr") chunks.stderr.push(data); + else chunks.stdout.push(data); + processOutput.set(value.pid, chunks); + }, + ); + try { + await Promise.all([runtime.saveSession(meta), saveMessages(state)]); + const spawned = await connection.spawn("sh", ["-lc", command], { + cwd: WORKSPACE_MOUNT_PATH, + env: SESSION_ENV, + }); + state.activeShellPids.set(meta.id, spawned.pid); + state.statuses.set(meta.id, "busy"); + state.emit({ + type: "session.status", + properties: { sessionID: meta.id, status: { type: "busy" } }, + }); + const exitCode = await connection.waitProcess(spawned.pid); + const chunks = processOutput.get(spawned.pid) ?? { + stdout: [], + stderr: [], + }; + const result = { + stdout: Buffer.concat(chunks.stdout).toString("utf8"), + stderr: Buffer.concat(chunks.stderr).toString("utf8"), + exitCode, + }; + const completed = now(); + const output = `${result.stdout}${result.stderr}`; + info.time = { created: started, completed }; + if (result.exitCode === 0) { + info.finish = "stop"; + toolPart.state = { + status: "completed", + time: { start: started, end: completed }, + input: { command }, + title: "", + metadata: { output, exitCode: result.exitCode }, + output, + }; + } else { + info.finish = "error"; + info.error = structuredUnknownError( + new Error(`shell command exited with status ${result.exitCode}`), + ); + toolPart.state = { + status: "error", + time: { start: started, end: completed }, + input: { command }, + metadata: { output, exitCode: result.exitCode }, + error: output || `Command exited with status ${result.exitCode}`, + }; + } + state.emit({ + type: "message.updated", + properties: { info, sessionID: meta.id }, + }); + state.emit({ + type: "message.part.updated", + properties: { part: toolPart }, + }); + log.info({ + event: "agentos.shell.completed", + durationMs: performance.now() - executionStarted, + exitCode: result.exitCode, + outputBytes: Buffer.byteLength(output), + }); + return assistant; + } catch (error) { + const completed = now(); + info.time = { created: started, completed }; + info.finish = "error"; + toolPart.state = { + status: "error", + time: { start: started, end: completed }, + input: { command }, + error: error instanceof Error ? error.message : String(error), + }; + state.emit({ + type: "message.updated", + properties: { info, sessionID: meta.id }, + }); + state.emit({ + type: "message.part.updated", + properties: { part: toolPart }, + }); + log.error( + { + event: "agentos.shell.failed", + durationMs: performance.now() - executionStarted, + err: error, + }, + "Gigacode shell command failed", + ); + throw error; + } finally { + state.activeShellPids.delete(meta.id); + state.activeShells.delete(meta.id); + if (!state.promptQueues.has(meta.id) && state.sessions.has(meta.id)) { + state.statuses.set(meta.id, "idle"); + state.emit({ + type: "session.status", + properties: { sessionID: meta.id, status: { type: "idle" } }, + }); + state.emit({ + type: "session.idle", + properties: { sessionID: meta.id }, + }); + } + await saveMessages(state).catch((error) => + console.error(`failed to persist shell messages for ${meta.id}`, error), + ); + unsubscribeProcessOutput(); + } +} + +function openUrl(url: string) { + const [command, args] = + process.platform === "darwin" + ? ["open", [url]] + : process.platform === "win32" + ? ["cmd", ["/c", "start", "", url]] + : ["xdg-open", [url]]; + const child = spawn(command, args, { detached: true, stdio: "ignore" }); + child.once("error", (error) => + console.error(`failed to open Gigacode debugger URL ${url}`, error), + ); + child.unref(); +} + +function debuggerUrl(actorId?: string) { + const url = new URL(INSPECTOR_URL); + url.searchParams.set("u", RIVET_ENDPOINT); + if (actorId) url.searchParams.set("actorId", actorId); + return url.toString(); +} + +async function serveCompat( + state: CompatState, + runtime: RivetRuntime, + modelCatalog: GlobalModelCatalog, +) { + const server = createServer(async (req, res) => { + try { + if (!req.url || !req.method) + return errorJson(res, 400, "invalid request"); + if (req.method === "OPTIONS") return json(res, true); + const url = new URL(req.url, API_ENDPOINT); + const path = url.pathname.replace(/^\/opencode(?=\/|$)/, "") || "/"; + + if ( + (path === "/global/health" || path === "/health") && + req.method === "GET" + ) { + return json(res, { + healthy: true, + version: VERSION, + rivetEndpoint: RIVET_ENDPOINT, + workspaceRoot: WORKSPACE_MOUNT_PATH, + rivetReady: runtime.isReady(), + rivetStartupStage: runtime.startupStage(), + modelCatalogStage: modelCatalog.startupStage(), + workspaceMountPath: WORKSPACE_MOUNT_PATH, + }); + } + if (path === "/event" || path === "/global/event") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + "access-control-allow-origin": "*", + }); + const global = path === "/global/event"; + const directory = directoryFor(req, url); + const subscription = { global, directory }; + state.eventClients.set(res, subscription); + const lastEventId = Number(req.headers["last-event-id"] ?? 0); + const connected = { + id: String(Math.max(0, state.nextId - 1)), + type: "server.connected", + properties: {}, + }; + res.write( + `data: ${JSON.stringify( + global ? { directory, payload: connected } : connected, + )}\n\n`, + ); + if (Number.isSafeInteger(lastEventId) && lastEventId > 0) { + state.replay(res, subscription, lastEventId); + } + const heartbeat = setInterval( + () => res.write(": heartbeat\n\n"), + 15_000, + ); + req.on("close", () => { + clearInterval(heartbeat); + state.eventClients.delete(res); + }); + return; + } + if (path === "/provider" || path === "/config/providers") + return json(res, await modelCatalog.payload()); + if (path === "/provider/auth") + return json( + res, + Object.fromEntries(Object.keys(harnesses).map((name) => [name, []])), + ); + if (path === "/agent") { + return json(res, [ + { + name: "build", + description: "AgentOS coding harness", + mode: "primary", + native: false, + permission: [], + options: {}, + }, + ]); + } + const commands = [ + { + name: "gigacode-debugger", + description: "Open this AgentOS actor in the Rivet inspector", + template: "", + hints: [], + source: "command", + }, + ]; + if (path === "/command") return json(res, commands); + if (path === "/api/command") { + const directory = directoryFor(req, url); + return json(res, { + location: locationValue(directory), + data: commands.map( + ({ hints: _hints, source: _source, ...command }) => command, + ), + }); + } + if (path === "/api/fs/find" && req.method === "GET") { + const directory = directoryFor(req, url); + const query = url.searchParams.get("query"); + if (query === null) return errorJson(res, 400, "query is required"); + return json(res, { + location: locationValue(directory), + data: await findFileSystemEntries( + directory, + query, + url.searchParams.get("type"), + url.searchParams.get("limit"), + ), + }); + } + if ( + (path === "/config" || path === "/global/config") && + req.method === "GET" + ) { + return json(res, { + mcp: {}, + agent: {}, + provider: {}, + command: {}, + model: "claude/default", + share: "disabled", + username: process.env.USER ?? "gigacode", + }); + } + if ( + (path === "/config" || path === "/global/config") && + req.method === "PATCH" + ) + return json(res, await readJson(req)); + if (path === "/path") { + const directory = directoryFor(req, url); + return json(res, { + home: homedir(), + state: STATE_DIR, + config: STATE_DIR, + worktree: directory, + directory, + }); + } + if (path === "/vcs") return json(res, { branch: "main" }); + if (path === "/vcs/diff" || path === "/vcs/status") return json(res, []); + if (path === "/vcs/diff/raw") return json(res, ""); + if (path === "/project" || path === "/project/current") { + const directory = directoryFor(req, url); + const project = { + id: "gigacode-local", + worktree: directory, + vcs: "git", + name: "gigacode", + time: { created: now(), updated: now() }, + }; + return json( + res, + path === "/project" && req.method === "GET" ? [project] : project, + ); + } + const projectDirectoriesMatch = path.match( + /^\/project\/([^/]+)\/directories$/, + ); + if (projectDirectoriesMatch && req.method === "GET") { + return json(res, [ + { directory: directoryFor(req, url), strategy: "local" }, + ]); + } + const projectCopyRefreshMatch = path.match( + /^\/experimental\/project\/([^/]+)\/copy\/refresh$/, + ); + if (projectCopyRefreshMatch && req.method === "POST") { + return noContent(res); + } + + if (path === "/session" && req.method === "GET") { + await syncSessions(state, runtime); + const directory = directoryFor(req, url); + return json( + res, + [...state.sessions.values()] + .filter((session) => session.directory === directory) + .sort((left, right) => right.updatedAt - left.updatedAt) + .map(sessionValue), + ); + } + if (path === "/session" && req.method === "POST") { + await syncSessions(state, runtime); + if (state.sessions.size >= MAX_SESSIONS) + throw new Error( + `session limit reached (${MAX_SESSIONS}); delete a session before creating another`, + ); + const body = await readJson(req); + const directory = directoryFor(req, url); + const actorStarted = performance.now(); + const { actorId } = await runtime.workspace(directory); + const actorDurationMs = performance.now() - actorStarted; + const timestamp = now(); + const meta: SessionMeta = { + id: id("ses"), + actorId, + directory, + title: + typeof body.title === "string" + ? body.title + : `New session - ${new Date(timestamp).toISOString()}`, + createdAt: timestamp, + updatedAt: timestamp, + }; + state.sessions.set(meta.id, meta); + state.statuses.set(meta.id, "idle"); + await runtime.saveSession(meta); + const log = sessionLog(meta.id); + log.info({ + event: "rivet.actor.resolved", + actorId, + durationMs: actorDurationMs, + }); + log.info({ event: "session.created" }); + state.emit({ + type: "session.created", + properties: { info: sessionValue(meta) }, + }); + return json(res, sessionValue(meta)); + } + if (path === "/session/status") { + await syncSessions(state, runtime); + const directory = directoryFor(req, url); + return json( + res, + Object.fromEntries( + [...state.statuses] + .filter( + ([key]) => state.sessions.get(key)?.directory === directory, + ) + .map(([key, value]) => [key, { type: value }]), + ), + ); + } + + const sessionMatch = path.match(/^\/session\/([^/]+)(.*)$/); + if (sessionMatch) { + const sessionId = decodeURIComponent(sessionMatch[1] as string); + const suffix = sessionMatch[2] as string; + await syncSessions(state, runtime); + const meta = state.sessions.get(sessionId); + if (!meta) return errorJson(res, 404, "session not found"); + if (suffix === "" && req.method === "GET") + return json(res, sessionValue(meta)); + if (suffix === "" && req.method === "PATCH") { + const body = await readJson(req); + if (typeof body.title === "string") meta.title = body.title; + meta.updatedAt = now(); + await runtime.saveSession(meta); + state.emit({ + type: "session.updated", + properties: { info: sessionValue(meta) }, + }); + return json(res, sessionValue(meta)); + } + if (suffix === "" && req.method === "DELETE") { + await abortPromptQueue(state, runtime, meta).catch((error) => + console.error( + `failed to abort deleted session ${sessionId}`, + error, + ), + ); + if (meta.actorSessionId) { + const client = await runtime.client(); + await client.vm + .getForId(runtimeActorId(meta)) + .closeSession(meta.actorSessionId) + .catch((error: unknown) => + console.error( + `failed to close AgentOS session ${meta.actorSessionId}`, + error, + ), + ); + } + state.emit({ + type: "session.deleted", + properties: { sessionID: sessionId, info: sessionValue(meta) }, + }); + state.sessions.delete(sessionId); + state.messages.delete(sessionId); + state.statuses.delete(sessionId); + state.cancellationBarriers.delete(sessionId); + for (const [permissionId, permission] of state.permissions) { + if (permission.sessionID === sessionId) + state.permissions.delete(permissionId); + } + await Promise.all([ + runtime.deleteSession(sessionId), + saveMessages(state), + ]); + sessionLog(sessionId).info({ event: "session.deleted" }); + closeSessionLog(sessionId); + return json(res, true); + } + if (suffix === "/message" && req.method === "GET") { + const messages = state.messages.get(sessionId) ?? []; + const requestedLimit = Number(url.searchParams.get("limit")); + const limit = + Number.isSafeInteger(requestedLimit) && requestedLimit > 0 + ? Math.min(requestedLimit, MAX_MESSAGES_PER_SESSION) + : messages.length; + return json(res, messages.slice(-limit)); + } + const messageMatch = suffix.match(/^\/message\/([^/]+)$/); + if (messageMatch && req.method === "GET") { + const messageId = decodeURIComponent(messageMatch[1] as string); + const message = (state.messages.get(sessionId) ?? []).find( + (item) => item.info.id === messageId, + ); + return message + ? json(res, message) + : errorJson(res, 404, "message not found"); + } + if (suffix === "/init" && req.method === "POST") { + const body = await readJson(req); + const submitted = enqueuePrompt(state, runtime, meta, { + ...body, + agent: "build", + parts: [ + { + type: "text", + text: "Analyze this project and create or update AGENTS.md with concise, project-specific instructions for coding agents.", + }, + ], + }); + await submitted.accepted; + void submitted.completion; + return json(res, true); + } + if (suffix === "/command" && req.method === "POST") { + const body = await readJson(req); + if (body.command !== "gigacode-debugger") { + return errorJson( + res, + 400, + `unsupported Gigacode command: ${String(body.command ?? "")}`, + ); + } + const completed = now(); + const messageId = ascendingOpenCodeId("msg"); + const parentId = + typeof body.messageID === "string" + ? body.messageID + : ascendingOpenCodeId("msg"); + const info = assistantInfo(meta, messageId, parentId, completed); + const inspector = debuggerUrl(sessionId); + const part = { + id: ascendingOpenCodeId("prt"), + sessionID: sessionId, + messageID: messageId, + type: "text", + text: `Rivet inspector: ${inspector}`, + time: { start: completed, end: completed }, + }; + const message = { info, parts: [part] }; + const messages = state.messages.get(sessionId) ?? []; + messages.push(message); + state.messages.set(sessionId, messages); + state.emit({ + type: "command.executed", + properties: { + name: body.command, + sessionID: sessionId, + arguments: + typeof body.arguments === "string" ? body.arguments : "", + messageID: messageId, + }, + }); + state.emit({ + type: "message.updated", + properties: { sessionID: sessionId, info }, + }); + state.emit({ + type: "message.part.updated", + properties: { sessionID: sessionId, part, time: completed }, + }); + await saveMessages(state); + if (process.env.GIGACODE_DISABLE_OPEN_URL !== "1") openUrl(inspector); + return json(res, message); + } + if ( + (suffix === "/message" || suffix === "/prompt_async") && + req.method === "POST" + ) { + const body = await readJson(req); + const submitted = enqueuePrompt(state, runtime, meta, body); + await submitted.accepted; + if (suffix === "/prompt_async") { + return noContent(res); + } + return json(res, await submitted.completion); + } + if (suffix === "/shell" && req.method === "POST") { + const body = await readJson(req); + const message = await runShellCommand(state, runtime, meta, body); + return json(res, message.info); + } + if (suffix === "/abort" && req.method === "POST") { + return json(res, await abortPromptQueue(state, runtime, meta)); + } + const legacyPermissionMatch = suffix.match(/^\/permissions\/([^/]+)$/); + if (legacyPermissionMatch && req.method === "POST") { + const permissionId = decodeURIComponent( + legacyPermissionMatch[1] as string, + ); + const permission = state.permissions.get(permissionId); + if (!permission || permission.sessionID !== sessionId) + return errorJson(res, 404, "permission not found"); + const body = await readJson(req); + const reply = + body.response === "always" + ? "always" + : body.response === "once" + ? "once" + : "reject"; + const client = await runtime.client(); + await client.vm + .getForId(runtimeActorId(meta)) + .respondPermission(permission.actorSessionId, permission.id, reply); + state.permissions.delete(permissionId); + state.emit({ + type: "permission.replied", + properties: { + sessionID: sessionId, + requestID: permissionId, + reply, + }, + }); + return json(res, true); + } + if (["/children", "/diff", "/todo"].includes(suffix)) + return json(res, []); + if (suffix === "/summarize" && req.method === "POST") + return errorJson( + res, + 501, + "AgentOS does not yet expose conversation compaction", + ); + } + + if (path === "/permission") + return json( + res, + [...state.permissions.values()].map((item) => ({ + id: item.id, + sessionID: item.sessionID, + permission: item.description ?? "AgentOS permission", + patterns: ["*"], + metadata: item.params, + always: [], + })), + ); + const permissionMatch = path.match(/^\/permission\/([^/]+)\/reply$/); + if (permissionMatch && req.method === "POST") { + const permissionId = decodeURIComponent(permissionMatch[1] as string); + const permission = state.permissions.get(permissionId); + if (!permission) return errorJson(res, 404, "permission not found"); + const body = await readJson(req); + const client = await runtime.client(); + const permissionSession = state.sessions.get(permission.sessionID); + if (!permissionSession) return errorJson(res, 404, "session not found"); + const reply = + body.reply === "always" + ? "always" + : body.reply === "once" + ? "once" + : "reject"; + await client.vm + .getForId(runtimeActorId(permissionSession)) + .respondPermission(permission.actorSessionId, permission.id, reply); + state.permissions.delete(permissionId); + state.emit({ + type: "permission.replied", + properties: { + sessionID: permission.sessionID, + requestID: permissionId, + reply, + }, + }); + return json(res, true); + } + + if (["/mcp", "/session/status"].includes(path)) return json(res, {}); + if ( + [ + "/lsp", + "/formatter", + "/skill", + "/experimental/resource", + "/question", + ].includes(path) + ) + return json(res, []); + if ( + path.startsWith("/tui/") || + path === "/global/dispose" || + path === "/instance/dispose" + ) + return json(res, true); + return errorJson( + res, + 404, + `unsupported OpenCode route: ${req.method} ${path}`, + ); + } catch (error) { + console.error("gigacode request failed", error); + return errorJson( + res, + 500, + error instanceof Error ? error.message : String(error), + ); + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(API_PORT, HOST, resolve); + }); + return server; +} + +async function writePid() { + await mkdir(STATE_DIR, { recursive: true }); + await writeFile(PID_FILE, `${process.pid}\n`, { mode: 0o600 }); +} + +async function readPid(): Promise { + try { + const value = Number((await readFile(PID_FILE, "utf8")).trim()); + return Number.isInteger(value) && value > 0 ? value : undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function processExists(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") return false; + if (code === "EPERM") return true; + throw error; + } +} + +async function gigacodeEnginePids(): Promise { + if (process.platform !== "linux") return []; + const entries = await readdir("/proc", { withFileTypes: true }).catch( + () => [], + ); + const storageMarker = `RIVETKIT_STORAGE_PATH=${RIVET_STORAGE_PATH}`; + const portMarker = `RIVET__GUARD__PORT=${RIVET_PORT}`; + const matches = await Promise.all( + entries.flatMap((entry) => { + if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) return []; + const pid = Number(entry.name); + if (pid === process.pid) return []; + return [ + Promise.all([ + readFile(`/proc/${pid}/comm`, "utf8"), + readFile(`/proc/${pid}/environ`, "utf8"), + ]) + .then(([comm, environment]) => { + const variables = environment.split("\0"); + return comm.trim() === "rivet-engine" && + variables.includes(storageMarker) && + variables.includes(portMarker) + ? pid + : undefined; + }) + .catch(() => undefined), + ]; + }), + ); + return matches.filter((pid): pid is number => pid !== undefined); +} + +async function stopRivetEngine(): Promise { + let pids = await gigacodeEnginePids(); + for (const pid of pids) { + try { + process.kill(pid, "SIGTERM"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + const deadline = Date.now() + 5_000; + while (pids.some(processExists) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + pids = pids.filter(processExists); + for (const pid of pids) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } +} + +type DaemonHealth = { + healthy: true; + version: string; + rivetEndpoint: string; + rivetReady?: boolean; + rivetStartupStage?: string; + modelCatalogStage?: string; + workspaceRoot?: string; +}; + +async function daemonHealth(): Promise { + try { + const response = await fetch(`${API_ENDPOINT}/global/health`, { + signal: AbortSignal.timeout(1_000), + }); + if (!response.ok) return undefined; + return (await response.json()) as DaemonHealth; + } catch (error) { + if (process.env.GIGACODE_DEBUG) + console.error("Gigacode health check failed", error); + return undefined; + } +} + +async function healthy() { + return Boolean(await daemonHealth()); +} + +async function printStartupLogUntil( + startOffset: number, + ready: Promise, +): Promise { + const log = await open(LOG_FILE, "r"); + let offset = startOffset; + let pending = ""; + let settled = false; + const settlement = ready.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + const printAvailable = async () => { + const { size } = await log.stat(); + while (offset < size) { + const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, size - offset)); + const { bytesRead } = await log.read( + buffer, + 0, + buffer.length, + offset, + ); + if (bytesRead === 0) break; + offset += bytesRead; + pending += buffer.subarray(0, bytesRead).toString("utf8"); + const lines = pending.split("\n"); + pending = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith("[gigacode]")) console.error(line); + } + } + }; + try { + while (!settled) { + await printAvailable(); + await Promise.race([ + settlement, + new Promise((resolve) => setTimeout(resolve, 100)), + ]); + } + await printAvailable(); + if (pending.startsWith("[gigacode]")) console.error(pending); + const response = await ready; + if (!response.ok) { + throw new Error( + `Gigacode provider bootstrap failed (${response.status}); see ${LOG_FILE}`, + ); + } + await response.arrayBuffer(); + } finally { + await log.close(); + } +} + +async function startDaemon(detached: boolean): Promise { + const running = await daemonHealth(); + if (running) return; + if (!detached) { + await runDaemon(); + return; + } + await mkdir(STATE_DIR, { recursive: true }); + console.error(`[gigacode] starting the local daemon; log: ${LOG_FILE}`); + const log = await open(LOG_FILE, "a", 0o600); + const startupLogOffset = (await log.stat()).size; + const script = process.argv[1]; + if (!script) + throw new Error( + "cannot autospawn Gigacode: entrypoint path is unavailable", + ); + const child = spawn( + process.execPath, + [...process.execArgv, script, "daemon"], + { + detached: true, + stdio: ["ignore", log.fd, log.fd], + env: process.env, + }, + ); + child.unref(); + await log.close(); + for (let attempt = 0; attempt < 600; attempt++) { + const health = await daemonHealth(); + if (health) { + console.error( + `[gigacode] OpenCode API is ready; ${health.rivetStartupStage ?? "Rivet startup is continuing in the background"}`, + ); + return startupLogOffset; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Gigacode daemon did not become healthy; see ${LOG_FILE}`); +} + +async function runDaemon() { + if (await healthy()) + throw new Error(`Gigacode daemon is already running at ${API_ENDPOINT}`); + await writePid(); + try { + const state = new CompatState(); + const runtime = new RivetRuntime(); + const modelCatalog = new GlobalModelCatalog(runtime); + const server = await serveCompat(state, runtime, modelCatalog); + void runtime.start().catch((error) => { + console.error("Gigacode Rivet runtime failed to initialize", error); + }); + modelCatalog.start(); + let shuttingDown = false; + const exitCleanly = () => { + if (shuttingDown) return; + shuttingDown = true; + void (async () => { + for (const meta of state.sessions.values()) { + if (!state.promptQueues.has(meta.id)) continue; + await abortPromptQueue(state, runtime, meta).catch((error) => + console.error(`failed to cancel active turn for ${meta.id}`, error), + ); + } + if (state.messagesLoaded) { + await saveMessages(state).catch((error) => + console.error("failed to persist Gigacode state", error), + ); + } + closeAllSessionLogs(); + server.close(); + server.closeAllConnections(); + await runtime + .shutdown() + .catch((error) => console.error("failed to stop RivetKit", error)); + process.exit(0); + })(); + }; + process.once("SIGINT", exitCleanly); + process.once("SIGTERM", exitCleanly); + startupLog(`OpenCode API is listening at ${API_ENDPOINT}`); + startupLog(`Rivet engine will listen at ${RIVET_ENDPOINT}`); + console.warn( + `[gigacode] each active host project is mounted read-write at ${WORKSPACE_MOUNT_PATH}; this is not a security boundary`, + ); + await new Promise((resolve) => server.once("close", resolve)); + } finally { + await rm(PID_FILE, { force: true }); + } +} + +async function stopDaemon() { + const pid = await readPid(); + if (!pid || !processExists(pid)) { + await rm(PID_FILE, { force: true }); + console.log("Gigacode daemon is not running"); + return; + } + process.kill(pid, "SIGTERM"); + for (let attempt = 0; attempt < 50 && processExists(pid); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + console.log(`Stopped Gigacode daemon (${pid})`); +} + +async function runClient(args: string[]) { + const startupLogOffset = await startDaemon(true); + if (startupLogOffset !== undefined) { + const providerReady = fetch(`${API_ENDPOINT}/opencode/provider`, { + signal: AbortSignal.timeout(STARTUP_TIMEOUT_MS), + }); + await printStartupLogUntil(startupLogOffset, providerReady); + } + const attachUrl = `${API_ENDPOINT}/opencode`; + const configured = process.env.GIGACODE_OPENCODE_BIN; + const command = configured ?? "opencode"; + const opencodeArgs = + args[0] === "run" + ? ["run", "--attach", attachUrl, ...args.slice(1)] + : ["attach", attachUrl, ...args]; + const child = spawn(command, opencodeArgs, { + stdio: "inherit", + }); + const status = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", resolve); + }).catch(async (error: NodeJS.ErrnoException) => { + if (!configured && error.code === "ENOENT") { + const fallback = spawn("npx", ["--yes", "opencode-ai", ...opencodeArgs], { + stdio: "inherit", + }); + return await new Promise((resolve, reject) => { + fallback.once("error", reject); + fallback.once("exit", resolve); + }); + } + throw error; + }); + if (status !== 0) throw new Error(`OpenCode exited with status ${status}`); +} + +async function runShell(args: string[]): Promise { + await startDaemon(true); + for (let attempt = 0; attempt < 300; attempt++) { + if ((await daemonHealth())?.rivetReady) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + if (!(await daemonHealth())?.rivetReady) + throw new Error("Gigacode Rivet runtime did not become ready"); + const [{ createClient: createRivetClient }, { attachShell }] = + await Promise.all([ + importModule("@rivet-dev/agentos/client"), + importModule("@rivet-dev/agentos/node"), + ]); + const client = createRivetClient({ endpoint: RIVET_ENDPOINT }); + const directory = canonicalDirectory(process.cwd()); + const handle = client.vm.getOrCreate(workspaceKey(directory), { + createWithInput: actorCreateInput(directory), + }); + await handle.resolve(); + const connection = handle.connect(); + const guestArgs = args[0] === "--" ? args.slice(1) : args; + const command = guestArgs[0] ?? "bash"; + const commandArgs = + guestArgs.length > 0 + ? guestArgs.slice(1) + : ["--input-backend", "minimal", "-i"]; + let shellError: unknown; + let exitCode: number | undefined; + + try { + exitCode = await attachShell(connection, { + command, + args: commandArgs, + cwd: WORKSPACE_MOUNT_PATH, + env: SESSION_ENV, + }); + } catch (error) { + shellError = error; + } + const cleanup = await Promise.allSettled([ + Promise.resolve().then(() => connection.dispose()), + ]); + const failures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (shellError !== undefined) { + for (const failure of failures) { + console.error("Gigacode shell cleanup failed", failure); + } + throw shellError; + } + if (failures.length > 0) throw failures[0]; + if (exitCode === undefined) { + throw new Error("Gigacode shell exited without an exit code"); + } + return exitCode; +} + +function usage() { + console.log( + `Gigacode ${VERSION}\n\nUsage:\n gigacode [OpenCode args...]\n gigacode shell [command [args...]]\n gigacode daemon [start|status|stop]\n gigacode debugger [actorID]\n`, + ); +} + +export async function main(argv = process.argv.slice(2)) { + const [command, subcommand] = argv; + if (command === "--help" || command === "-h" || command === "help") + return usage(); + if (command === "--version" || command === "-V") return console.log(VERSION); + if (command === "daemon") { + if (subcommand === "start") return startDaemon(true); + if (subcommand === "status") { + let pid = await readPid(); + if (pid && !processExists(pid)) { + await rm(PID_FILE, { force: true }); + pid = undefined; + } + const health = await daemonHealth(); + console.log( + JSON.stringify( + { + running: Boolean(health), + pid, + apiEndpoint: API_ENDPOINT, + rivetEndpoint: RIVET_ENDPOINT, + rivetReady: health?.rivetReady ?? false, + rivetStartupStage: health?.rivetStartupStage, + modelCatalogStage: health?.modelCatalogStage, + startupLog: LOG_FILE, + }, + null, + 2, + ), + ); + return; + } + if (subcommand === "stop") return stopDaemon(); + if (subcommand) throw new Error(`unknown daemon command: ${subcommand}`); + return runDaemon(); + } + if (command === "debugger") { + await startDaemon(true); + const url = debuggerUrl(subcommand); + openUrl(url); + console.log(url); + return; + } + if (command === "shell") { + process.exitCode = await runShell(argv.slice(1)); + return; + } + return runClient(argv); +} + +if (import.meta.url === pathToFileURL(process.argv[1] as string).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/experiments/gigacode/install-global.sh b/experiments/gigacode/install-global.sh new file mode 100755 index 0000000000..6a34f987db --- /dev/null +++ b/experiments/gigacode/install-global.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +experiment_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(CDPATH= cd -- "$experiment_dir/../.." && pwd) +install_dir=${GIGACODE_INSTALL_BIN_DIR:-"$HOME/.local/bin"} +install_root=${GIGACODE_INSTALL_ROOT:-"$HOME/.local/share/gigacode"} +destination="$install_dir/gigacode" +alias_destination="$install_dir/gc" + +if ! command -v pnpm >/dev/null 2>&1; then + echo "pnpm is required to install Gigacode globally." >&2 + exit 1 +fi + +mkdir -p "$install_dir" "$(dirname -- "$install_root")" +staging=$(mktemp -d "$(dirname -- "$install_root")/.gigacode-deploy.XXXXXX") +temporary=$(mktemp "$install_dir/.gigacode.XXXXXX") +generated_actions="$repo_root/packages/agentos/src/generated/actor-actions.generated.ts" +generated_actions_backup="" +restore_generated_actions() { + if [[ -n "$generated_actions_backup" && -f "$generated_actions_backup" ]]; then + cp "$generated_actions_backup" "$generated_actions" + rm -f "$generated_actions_backup" + generated_actions_backup="" + fi +} +cleanup() { + restore_generated_actions + rm -f "$temporary" + rm -rf "$staging" +} +trap cleanup EXIT + +# Repair a pnpm store entry whose downloaded OpenCode executable was removed +# after its lifecycle script had already been marked complete. +opencode_package="$experiment_dir/node_modules/opencode-ai" +if [[ -f "$opencode_package/postinstall.mjs" && ! -f "$opencode_package/bin/opencode.exe" ]]; then + (cd "$opencode_package" && node postinstall.mjs) +fi + +# Native AgentOS builds invoke packages/build-tools from Cargo build scripts, +# which is outside Gigacode's filtered pnpm dependency graph. +pnpm --dir "$repo_root" install + +case "$(uname -s)" in + Linux) plugin_name=libagentos_actor_plugin.so ;; + Darwin) plugin_name=libagentos_actor_plugin.dylib ;; + *) + echo "The Gigacode source installer supports Linux and macOS." >&2 + exit 1 + ;; +esac +plugin_source=${AGENTOS_PLUGIN_BIN:-"$repo_root/target/debug/$plugin_name"} +sidecar_source=${AGENTOS_SIDECAR_BIN:-"$repo_root/target/debug/agentos-sidecar"} +installed_plugin="$install_root/native/$plugin_name" +installed_sidecar="$install_root/native/agentos-sidecar" +if [[ ! -f "$plugin_source" && -f "$installed_plugin" ]]; then + plugin_source="$installed_plugin" +fi +if [[ ! -x "$sidecar_source" && -x "$installed_sidecar" ]]; then + sidecar_source="$installed_sidecar" +fi +if [[ ! -f "$plugin_source" || ! -x "$sidecar_source" ]]; then + generated_actions_backup=$(mktemp) + cp "$generated_actions" "$generated_actions_backup" + cargo build \ + --manifest-path "$repo_root/Cargo.toml" \ + -p agentos-actor-plugin \ + -p agentos-sidecar + restore_generated_actions + plugin_source="$repo_root/target/debug/$plugin_name" + sidecar_source="$repo_root/target/debug/agentos-sidecar" +fi + +pnpm --dir "$repo_root" \ + --filter @rivet-dev/agentos-experiment-gigacode \ + deploy --legacy --prod "$staging" + +mkdir -p "$staging/native" +cp "$plugin_source" "$staging/native/$plugin_name" +cp "$sidecar_source" "$staging/native/agentos-sidecar" +chmod 0755 "$staging/native/agentos-sidecar" +mkdir -p "$staging/bin" +cp "$staging/node_modules/opencode-ai/bin/opencode.exe" "$staging/bin/opencode" +chmod 0755 "$staging/bin/opencode" +mkdir -p "$staging/software" +for package in \ + coreutils sed grep gawk findutils diffutils tar gzip \ + claude-code codex-cli opencode pi; do + case "$package" in + claude-code) local_package="$repo_root/registry/agent/claude/dist/package.aospkg" ;; + pi) local_package="$repo_root/registry/agent/pi/dist/package.aospkg" ;; + coreutils|sed|grep|gawk|findutils|diffutils|tar|gzip) + local_package="$repo_root/registry/software/$package/dist/package.aospkg" + ;; + *) local_package="" ;; + esac + package_source="" + if [[ -n "$local_package" && -f "$local_package" ]]; then + package_source="$local_package" + else + package_source=$(find "$staging/node_modules/.pnpm" -path "*/node_modules/@agentos-software/$package/dist/package.aospkg" -print -quit) + fi + installed_package="$install_root/software/$package.aospkg" + if [[ -z "$package_source" && -f "$installed_package" ]]; then + package_source="$installed_package" + fi + if [[ -z "$package_source" ]]; then + echo "Gigacode deployment and current installation are missing @agentos-software/$package package.aospkg" >&2 + exit 1 + fi + cp "$package_source" "$staging/software/$package.aospkg" +done + +if [[ ! -x "$staging/node_modules/.bin/tsx" || ! -f "$staging/node_modules/tsx/dist/loader.mjs" || ! -x "$staging/node_modules/.bin/opencode" || ! -f "$staging/gigacode.ts" ]]; then + echo "Gigacode deployment did not contain its runtime entrypoint." >&2 + exit 1 +fi +# The shared development machine may prune or rewrite generated node_modules +# trees. Rename the deployed root out of its watched location before taking a +# fast uncompressed snapshot, transform it back to node_modules in the archive, +# then compress the stable archive. +mv "$staging/node_modules" "$staging/runtime-tree" +tar -cf "$staging/runtime.tar" \ + -C "$staging" \ + --transform='s,^runtime-tree,node_modules,' \ + runtime-tree +gzip -1 "$staging/runtime.tar" +mv "$staging/runtime-tree" "$staging/node_modules" + +backup="$install_root.previous.$$" +rm -rf "$backup" +if [[ -x "$destination" ]]; then + "$destination" daemon stop >/dev/null 2>&1 || true +fi +if [[ -e "$install_root" ]]; then + mv "$install_root" "$backup" +fi +mv "$staging" "$install_root" +rm -rf "$backup" + +printf '#!/usr/bin/env bash +set -euo pipefail +install_root=%q +runtime_bin="$install_root/node_modules/.bin" +opencode_bin="${GIGACODE_OPENCODE_BIN:-$install_root/bin/opencode}" +api_port="${GIGACODE_PORT:-2468}" +api_endpoint="http://127.0.0.1:$api_port" +first="${1:-}" +case "$first" in + daemon|debugger|shell|help|--help|-h|--version|-V) client_mode=0 ;; + *) client_mode=1 ;; +esac +if [[ "$client_mode" == 1 && -x "$opencode_bin" ]] && \ + curl --silent --fail --max-time 0.25 "$api_endpoint/global/health" >/dev/null 2>&1; then + if [[ "$first" == run ]]; then + shift + exec "$opencode_bin" run --attach "$api_endpoint/opencode" "$@" + fi + exec "$opencode_bin" attach "$api_endpoint/opencode" "$@" +fi +if [[ ! -x "$runtime_bin/tsx" || ! -f "$install_root/node_modules/tsx/dist/loader.mjs" || ! -x "$runtime_bin/opencode" ]]; then + recovery=$(mktemp -d "$install_root/.runtime.XXXXXX") + cleanup() { rm -rf "$recovery"; } + trap cleanup EXIT + tar -xzf "$install_root/runtime.tar.gz" -C "$recovery" + rm -rf "$install_root/node_modules" + mv "$recovery/node_modules" "$install_root/node_modules" + rmdir "$recovery" + trap - EXIT +fi +export AGENTOS_PLUGIN_BIN="${AGENTOS_PLUGIN_BIN:-$install_root/native/%s}" +export AGENTOS_SIDECAR_BIN="${AGENTOS_SIDECAR_BIN:-$install_root/native/agentos-sidecar}" +export GIGACODE_SOFTWARE_DIR="${GIGACODE_SOFTWARE_DIR:-$install_root/software}" +export PATH="$runtime_bin:$PATH" +exec "$runtime_bin/tsx" "$install_root/gigacode.ts" "$@" +' "$install_root" "$plugin_name" >"$temporary" +chmod 0755 "$temporary" +mv -f "$temporary" "$destination" +trap - EXIT +ln -sfn "$(basename "$destination")" "$alias_destination" + +echo "Installed Gigacode at $destination" +echo "Installed gc alias at $alias_destination" +echo "Installed runtime at $install_root" +if [[ ":$PATH:" != *":$install_dir:"* ]]; then + echo "Warning: $install_dir is not on PATH" >&2 + exit 1 +fi diff --git a/experiments/gigacode/package.json b/experiments/gigacode/package.json new file mode 100644 index 0000000000..abc20feb05 --- /dev/null +++ b/experiments/gigacode/package.json @@ -0,0 +1,34 @@ +{ + "name": "@rivet-dev/agentos-experiment-gigacode", + "version": "0.0.1", + "private": true, + "type": "module", + "bin": { + "gigacode": "./gigacode.ts", + "gc": "./gigacode.ts" + }, + "scripts": { + "check-types": "tsc --noEmit", + "gigacode": "tsx gigacode.ts", + "install-global": "bash ./install-global.sh", + "test:e2e": "vitest run gigacode.e2e.test.ts --reporter=verbose --pool=threads --maxWorkers=1 --minWorkers=1" + }, + "dependencies": { + "@agentos-software/claude-code": "workspace:*", + "@agentos-software/codex": "workspace:*", + "@agentos-software/opencode": "workspace:*", + "@agentos-software/pi": "workspace:*", + "@rivet-dev/agentos": "workspace:*", + "opencode-ai": "1.17.18", + "pino": "9.14.0", + "rivetkit": "catalog:rivetkit", + "tsx": "^4.19.0" + }, + "devDependencies": { + "@copilotkit/llmock": "^1.6.0", + "@opencode-ai/sdk": "^1.1.21", + "@types/node": "^22.19.15", + "typescript": "^5.9.2", + "vitest": "^2.1.8" + } +} diff --git a/experiments/gigacode/tmux-terminal.ts b/experiments/gigacode/tmux-terminal.ts new file mode 100644 index 0000000000..a6ea371d25 --- /dev/null +++ b/experiments/gigacode/tmux-terminal.ts @@ -0,0 +1,182 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export type TerminalKey = + | "Enter" + | "Escape" + | "Up" + | "Down" + | "Left" + | "Right" + | "Tab" + | "Backspace" + | "Ctrl-C"; + +export type TerminalSnapshot = { + label: string; + text: string; +}; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +/** A small Playwright-style driver for full-screen terminal applications. */ +export class TmuxTerminal { + readonly name: string; + readonly snapshots: TerminalSnapshot[] = []; + + private constructor(name: string) { + this.name = name; + } + + static async launch(options: { + command: string[]; + cwd: string; + env?: NodeJS.ProcessEnv; + name?: string; + width?: number; + height?: number; + }): Promise { + if (options.command.length === 0) + throw new Error("command cannot be empty"); + const name = + options.name ?? + `terminal-e2e-${process.pid}-${Math.random().toString(36).slice(2, 10)}`; + const environment = Object.entries(options.env ?? {}) + .filter((entry): entry is [string, string] => entry[1] !== undefined) + .map(([key, value]) => `${key}=${shellQuote(value)}`) + .join(" "); + const command = options.command.map(shellQuote).join(" "); + const shellCommand = `cd ${shellQuote(options.cwd)} && ${environment} exec ${command}`; + await execFileAsync("tmux", [ + "new-session", + "-d", + "-s", + name, + "-x", + String(options.width ?? 120), + "-y", + String(options.height ?? 40), + shellCommand, + ]); + return new TmuxTerminal(name); + } + + async type(text: string): Promise { + await execFileAsync("tmux", ["send-keys", "-t", this.name, "-l", text]); + } + + async press(key: TerminalKey): Promise { + const tmuxKey = + key === "Ctrl-C" ? "C-c" : key === "Backspace" ? "BSpace" : key; + await execFileAsync("tmux", ["send-keys", "-t", this.name, tmuxKey]); + } + + async text(): Promise { + const { stdout } = await execFileAsync("tmux", [ + "capture-pane", + "-p", + "-J", + "-S", + "-", + "-t", + this.name, + ]); + return stdout.replace(/[ \t]+$/gm, "").replace(/\n+$/, ""); + } + + async snapshot(label: string): Promise { + const snapshot = { label, text: await this.text() }; + this.snapshots.push(snapshot); + return snapshot; + } + + async waitForText( + expected: string | RegExp, + options: { timeoutMs?: number; intervalMs?: number } = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? 30_000; + const deadline = Date.now() + timeoutMs; + let latest = ""; + while (Date.now() < deadline) { + latest = await this.text(); + if ( + typeof expected === "string" + ? latest.includes(expected) + : expected.test(latest) + ) { + return latest; + } + await new Promise((resolve) => + setTimeout(resolve, options.intervalMs ?? 100), + ); + } + throw new Error( + `terminal did not show ${String(expected)} within ${timeoutMs}ms:\n${latest}`, + ); + } + + async waitForTextAbsent( + unexpected: string | RegExp, + options: { + timeoutMs?: number; + intervalMs?: number; + stableMs?: number; + } = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? 30_000; + const deadline = Date.now() + timeoutMs; + const stableMs = options.stableMs ?? 0; + let absentSince: number | undefined; + let latest = ""; + while (Date.now() < deadline) { + latest = await this.text(); + const present = + typeof unexpected === "string" + ? latest.includes(unexpected) + : unexpected.test(latest); + if (present) { + absentSince = undefined; + } else { + absentSince ??= Date.now(); + if (Date.now() - absentSince >= stableMs) return latest; + } + await new Promise((resolve) => + setTimeout(resolve, options.intervalMs ?? 100), + ); + } + throw new Error( + `terminal still showed ${String(unexpected)} after ${timeoutMs}ms:\n${latest}`, + ); + } + + async waitForTextOccurrences( + expected: string, + count: number, + options: { timeoutMs?: number; intervalMs?: number } = {}, + ): Promise { + if (count < 1) throw new Error("count must be at least 1"); + const timeoutMs = options.timeoutMs ?? 30_000; + const deadline = Date.now() + timeoutMs; + let latest = ""; + while (Date.now() < deadline) { + latest = await this.text(); + if (latest.split(expected).length - 1 >= count) return latest; + await new Promise((resolve) => + setTimeout(resolve, options.intervalMs ?? 100), + ); + } + throw new Error( + `terminal did not show ${JSON.stringify(expected)} ${count} times within ${timeoutMs}ms:\n${latest}`, + ); + } + + async close(): Promise { + await execFileAsync("tmux", ["kill-session", "-t", this.name]).catch( + () => undefined, + ); + } +} diff --git a/experiments/gigacode/tsconfig.json b/experiments/gigacode/tsconfig.json new file mode 100644 index 0000000000..8525604799 --- /dev/null +++ b/experiments/gigacode/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/packages/agentos/package.json b/packages/agentos/package.json index 954b84b8ce..fd00e3c2e5 100644 --- a/packages/agentos/package.json +++ b/packages/agentos/package.json @@ -36,6 +36,12 @@ "default": "./dist/client.js" } }, + "./node": { + "import": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + } + }, "./react": { "import": { "types": "./dist/react.d.ts", @@ -48,7 +54,7 @@ }, "scripts": { "build": "pnpm build:actor && pnpm build:tabs", - "build:actor": "tsup src/index.ts src/client.ts src/react.ts", + "build:actor": "tsup src/index.ts src/client.ts src/node.ts src/react.ts", "build:tabs": "vite build --config vite.tabs.config.ts", "check-types": "tsc --noEmit", "test": "vitest run" diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index f741bba5d7..f9ee8024a4 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -27,6 +27,7 @@ import { import { type AgentOsActorConfig, type AgentOsActorConfigInput, + type AgentOsActorCreateInput, agentOsActorConfigSchema, nativeAgentOsOptionsSchema, } from "./config.js"; @@ -279,12 +280,15 @@ function buildNativeFactoryBuilder( * That is what gives `createClient()` a fully-typed handle * (e.g. `handle.exec()` returns `ExecResult`, not `unknown`). */ -export type AgentOsActorDefinition = ActorDefinition< +export type AgentOsActorDefinition< + TConnParams, + TCreateInput = AgentOsActorCreateInput, +> = ActorDefinition< AgentOsActorState, TConnParams, undefined, AgentOsActorVars, - undefined, + TCreateInput, DatabaseProvider, Record, Record, @@ -385,9 +389,12 @@ const AGENTOS_INSPECTOR_CONFIG = { ], }; -export function createAgentOS( +export function createAgentOS< + TConnParams = undefined, + TCreateInput = AgentOsActorCreateInput, +>( config: AgentOsActorConfigInput, -): AgentOsActorDefinition { +): AgentOsActorDefinition { const parsed = agentOsActorConfigSchema.parse( config, ) as AgentOsActorConfig; @@ -411,9 +418,10 @@ export function createAgentOS( // rivetkit tabs) so the dashboard renders the agent-os UI. Without this // the shipped tab assets are never surfaced. inspector: AGENTOS_INSPECTOR_CONFIG, - } as Parameters< - typeof actor - >[0]) as unknown as AgentOsActorDefinition; + } as Parameters[0]) as unknown as AgentOsActorDefinition< + TConnParams, + TCreateInput + >; definition.nativeFactoryBuilder = buildNativeFactoryBuilder( parsed, actorOptions, diff --git a/packages/agentos/src/config.ts b/packages/agentos/src/config.ts index 06fb85a872..704ee55d5d 100644 --- a/packages/agentos/src/config.ts +++ b/packages/agentos/src/config.ts @@ -87,6 +87,26 @@ export type NativeAgentOsOptions = Pick< sidecar?: { kind: "shared"; pool?: string }; }; +/** + * VM options that may be supplied per actor through RivetKit's durable actor + * creation input. Software and sidecar selection remain definition-wide. + */ +export type AgentOsActorCreateOptions = Pick< + NativeAgentOsOptions, + | "loopbackExemptPorts" + | "allowedNodeBuiltins" + | "rootFilesystem" + | "additionalInstructions" + | "permissions" + | "limits" +> & { + mounts?: NativeMountConfig[]; +}; + +export interface AgentOsActorCreateInput { + options?: AgentOsActorCreateOptions; +} + type AgentOsActorContext = ActorContext< AgentOsActorState, TConnParams, @@ -122,14 +142,14 @@ interface AgentOsActorConfigCallbacks { export type AgentOsActorConfig = Omit< z.infer, "options" | "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" -> & - { options?: NativeAgentOsOptions } & - AgentOsActorConfigCallbacks; +> & { + options?: NativeAgentOsOptions; +} & AgentOsActorConfigCallbacks; // Input config (what users pass in before Zod transforms). export type AgentOsActorConfigInput = Omit< z.input, "options" | "onBeforeConnect" | "onSessionEvent" | "onPermissionRequest" -> & - { options?: NativeAgentOsOptions } & - AgentOsActorConfigCallbacks; +> & { + options?: NativeAgentOsOptions; +} & AgentOsActorConfigCallbacks; diff --git a/packages/agentos/src/generated/actor-actions.generated.ts b/packages/agentos/src/generated/actor-actions.generated.ts index 9671fd7470..f648f31842 100644 --- a/packages/agentos/src/generated/actor-actions.generated.ts +++ b/packages/agentos/src/generated/actor-actions.generated.ts @@ -1,11 +1,14 @@ // @generated by agentos-actor-plugin. Do not edit. -// This file is committed so package builds do not need to compile the native -// plugin just to regenerate TypeScript action types. +// This file is generated locally and is intentionally not committed to Git. +// Regenerated by the agentos-actor-plugin build script; build the crate +// (e.g. `cargo build -p agentos-actor-plugin`) to refresh it. import type { ExecResult, + JsonRpcResponse, PermissionReply, ProcessInfo, ProcessTreeNode, + SessionConfigOption, SpawnedProcessInfo, VirtualStat, } from "@rivet-dev/agentos-core"; @@ -152,7 +155,11 @@ export type AgentOsActions = { listCronJobs: (c: Ctx) => Promise; cancelCronJob: (c: Ctx, id: string) => Promise; createSession: (c: Ctx, agentType: string, options?: CreateSessionOptions) => Promise; + probeAgentConfig: (c: Ctx, agentType: string, options?: CreateSessionOptions) => Promise; sendPrompt: (c: Ctx, sessionId: string, text: string) => Promise; + sendPromptAsync: ( c: Ctx, sessionId: string, text: string, requestId: string, ) => Promise; + setSessionModel: (c: Ctx, sessionId: string, model: string) => Promise; + cancelSession: (c: Ctx, sessionId: string) => Promise; closeSession: (c: Ctx, sessionId: string) => Promise; listPersistedSessions: (c: Ctx) => Promise; getSessionEvents: (c: Ctx, sessionId: string) => Promise; diff --git a/packages/agentos/src/index.ts b/packages/agentos/src/index.ts index d745dd1706..e1b6a4fd97 100644 --- a/packages/agentos/src/index.ts +++ b/packages/agentos/src/index.ts @@ -15,6 +15,7 @@ import { setup as rivetkitSetup } from "rivetkit"; import { type AgentOsActorDefinition, createAgentOS } from "./actor.js"; import type { AgentOsActorConfigInput, + AgentOsActorCreateInput, NativeAgentOsOptions, } from "./config.js"; @@ -50,6 +51,8 @@ export { export { type AgentOsActorConfig, type AgentOsActorConfigInput, + type AgentOsActorCreateInput, + type AgentOsActorCreateOptions, agentOsActorConfigSchema, type NativeAgentOsOptions, nativeAgentOsOptionsSchema, @@ -108,9 +111,12 @@ export type AgentOSConfigInput = AgentOsOptions & { ) => void | Promise; }; -export function agentOS( +export function agentOS< + TConnParams = undefined, + TCreateInput = AgentOsActorCreateInput, +>( config: AgentOSConfigInput = {}, -): AgentOsActorDefinition { +): AgentOsActorDefinition { const { preview, actorOptions, @@ -120,7 +126,7 @@ export function agentOS( ...options } = config; - return createAgentOS({ + return createAgentOS({ options, actorOptions, preview, diff --git a/packages/agentos/src/node.ts b/packages/agentos/src/node.ts new file mode 100644 index 0000000000..f3a8fd6ec0 --- /dev/null +++ b/packages/agentos/src/node.ts @@ -0,0 +1,409 @@ +import { Buffer } from "node:buffer"; + +const DEFAULT_MAX_PENDING_OUTPUT_BYTES = 8 * 1024 * 1024; +const OUTPUT_LIMIT_WARNING_RATIO = 0.8; +const TRAILING_OUTPUT_GRACE_MS = 250; + +export interface NodeShellOpenOptions { + command?: string; + args?: string[]; + env?: Record; + cwd?: string; + cols?: number; + rows?: number; +} + +export interface NodeShellConnection { + openShell(options?: NodeShellOpenOptions): Promise<{ shellId: string }>; + writeShell(shellId: string, data: string | Uint8Array): Promise; + resizeShell(shellId: string, cols: number, rows: number): Promise; + closeShell(shellId: string): Promise; + waitShell(shellId: string): Promise; +} + +export interface NodeShellInput { + isTTY?: boolean; + isRaw?: boolean; + setRawMode?(enabled: boolean): unknown; + on(event: "data", listener: (data: string | Uint8Array) => void): unknown; + on(event: "end", listener: () => void): unknown; + on(event: "error", listener: (error: Error) => void): unknown; + removeListener( + event: "data", + listener: (data: string | Uint8Array) => void, + ): unknown; + removeListener(event: "end", listener: () => void): unknown; + removeListener(event: "error", listener: (error: Error) => void): unknown; + pause(): unknown; + resume(): unknown; +} + +export interface NodeShellOutput { + isTTY?: boolean; + columns?: number; + rows?: number; + write(data: string | Uint8Array): boolean; + on(event: "resize", listener: () => void): unknown; + removeListener(event: "resize", listener: () => void): unknown; +} + +export type NodeShellSignal = "SIGINT" | "SIGHUP" | "SIGTERM"; + +export interface AttachShellOptions extends NodeShellOpenOptions { + stdin?: NodeShellInput; + stdout?: NodeShellOutput; + stderr?: NodeShellOutput; + rawMode?: boolean; + resize?: boolean; + signals?: readonly NodeShellSignal[]; + maxPendingOutputBytes?: number; + onWarning?: (message: string) => void; +} + +interface ShellDataPayload { + shellId: string; + data: unknown; +} + +interface ShellExitPayload { + shellId: string; + exitCode: number; +} + +interface ShellEventSource { + on(event: string, listener: (payload: never) => void): unknown; + off?(event: string, listener: (payload: never) => void): unknown; + removeListener?(event: string, listener: (payload: never) => void): unknown; +} + +type EarlyOutput = { + stream: "stdout" | "stderr"; + payload: ShellDataPayload; + bytes: Uint8Array; +}; + +type AttachOutcome = + | { kind: "exit"; exitCode: number } + | { kind: "failure"; error: Error } + | { kind: "signal"; signal: NodeShellSignal; exitCode: number }; + +export class NodeShellOutputLimitError extends Error { + readonly code = "AGENTOS_NODE_SHELL_OUTPUT_LIMIT"; + + constructor( + readonly limitBytes: number, + readonly pendingBytes: number, + ) { + super( + `AgentOS Node shell pending output exceeded ${limitBytes} bytes ` + + `(${pendingBytes} bytes pending); raise AttachShellOptions.maxPendingOutputBytes to allow more`, + ); + this.name = "NodeShellOutputLimitError"; + } +} + +export class NodeShellCleanupError extends Error { + readonly code = "AGENTOS_NODE_SHELL_CLEANUP_FAILED"; + + constructor(readonly errors: readonly Error[]) { + super("AgentOS Node shell and cleanup both failed"); + this.name = "NodeShellCleanupError"; + } +} + +/** + * Open an AgentOS PTY shell and attach it to the current Node process terminal. + * The connection must expose AgentOS shell actions and its broadcast `on()` API. + */ +export async function attachShell( + connection: NodeShellConnection, + options: AttachShellOptions = {}, +): Promise { + const stdin = options.stdin ?? process.stdin; + const stdout = options.stdout ?? process.stdout; + const stderr = options.stderr ?? process.stderr; + const events = connection as unknown as ShellEventSource; + if (typeof events.on !== "function") { + throw new TypeError("attachShell requires an AgentOS connection with on()"); + } + + const maxPendingOutputBytes = + options.maxPendingOutputBytes ?? DEFAULT_MAX_PENDING_OUTPUT_BYTES; + if ( + !Number.isSafeInteger(maxPendingOutputBytes) || + maxPendingOutputBytes <= 0 + ) { + throw new RangeError( + "AttachShellOptions.maxPendingOutputBytes must be a positive safe integer", + ); + } + + const warn = + options.onWarning ?? ((message: string) => stderr.write(`${message}\n`)); + let shellId: string | undefined; + let shellClosed = false; + let failure: Error | undefined; + let resolveFailure: ((outcome: AttachOutcome) => void) | undefined; + const failurePromise = new Promise((resolve) => { + resolveFailure = resolve; + }); + const fail = (error: Error) => { + if (failure) return; + failure = error; + resolveFailure?.({ kind: "failure", error }); + }; + const earlyOutput: EarlyOutput[] = []; + let earlyOutputBytes = 0; + let earlyOutputWarned = false; + let resolveShellExit: (() => void) | undefined; + const shellExitPromise = new Promise((resolve) => { + resolveShellExit = resolve; + }); + + const onShellData = (payload: ShellDataPayload) => { + routeOutput("stdout", payload); + }; + const onShellStderr = (payload: ShellDataPayload) => { + routeOutput("stderr", payload); + }; + const onShellExit = (payload: ShellExitPayload) => { + if (shellId && payload.shellId === shellId) resolveShellExit?.(); + }; + const unsubscribers = [ + subscribe(events, "shellData", onShellData), + subscribe(events, "shellStderr", onShellStderr), + subscribe(events, "shellExit", onShellExit), + ]; + + function routeOutput( + stream: "stdout" | "stderr", + payload: ShellDataPayload, + ): void { + try { + const bytes = toBytes(payload.data); + if (bytes.byteLength === 0) return; + if (!shellId) { + const nextBytes = earlyOutputBytes + bytes.byteLength; + if (nextBytes > maxPendingOutputBytes) { + fail(new NodeShellOutputLimitError(maxPendingOutputBytes, nextBytes)); + return; + } + earlyOutputBytes = nextBytes; + earlyOutput.push({ stream, payload, bytes }); + if ( + !earlyOutputWarned && + nextBytes >= + Math.floor(maxPendingOutputBytes * OUTPUT_LIMIT_WARNING_RATIO) + ) { + earlyOutputWarned = true; + warn( + `AgentOS Node shell pending output is near its ${maxPendingOutputBytes}-byte limit ` + + `(${nextBytes} bytes pending)`, + ); + } + return; + } + if (payload.shellId !== shellId) return; + (stream === "stdout" ? stdout : stderr).write(bytes); + } catch (error) { + fail(asError(error)); + } + } + + let inputAttached = false; + let rawModeChanged = false; + let previousRawMode = false; + let inputWrite = Promise.resolve(); + let inputEnded = false; + let resizeAttached = false; + let signalOutcomeResolve: ((outcome: AttachOutcome) => void) | undefined; + const signalPromise = new Promise((resolve) => { + signalOutcomeResolve = resolve; + }); + const signalHandlers = new Map void>(); + + const onInputData = (data: string | Uint8Array) => { + stdin.pause(); + inputWrite = inputWrite + .then(async () => { + if (shellId) await connection.writeShell(shellId, data); + }) + .then(() => { + if (!inputEnded) stdin.resume(); + }) + .catch((error) => fail(asError(error))); + }; + const onInputEnd = () => { + inputEnded = true; + stdin.pause(); + inputWrite = inputWrite + .then(async () => { + if (shellId) await connection.writeShell(shellId, "\u0004"); + }) + .catch((error) => fail(asError(error))); + }; + const onInputError = (error: Error) => fail(error); + const onResize = () => { + if (!shellId) return; + const cols = stdout.columns; + const rows = stdout.rows; + if (!positiveInteger(cols) || !positiveInteger(rows)) return; + void connection.resizeShell(shellId, cols, rows).catch((error) => { + fail(asError(error)); + }); + }; + + try { + const opened = await connection.openShell({ + command: options.command, + args: options.args, + env: options.env, + cwd: options.cwd, + cols: options.cols ?? stdout.columns, + rows: options.rows ?? stdout.rows, + }); + shellId = opened.shellId; + for (const item of earlyOutput) { + if (item.payload.shellId !== shellId) continue; + (item.stream === "stdout" ? stdout : stderr).write(item.bytes); + } + earlyOutput.length = 0; + earlyOutputBytes = 0; + if (failure) throw failure; + + previousRawMode = Boolean(stdin.isRaw); + if ( + (options.rawMode ?? true) && + stdin.isTTY && + typeof stdin.setRawMode === "function" && + !previousRawMode + ) { + stdin.setRawMode(true); + rawModeChanged = true; + } + stdin.on("data", onInputData); + stdin.on("end", onInputEnd); + stdin.on("error", onInputError); + stdin.resume(); + inputAttached = true; + + if ((options.resize ?? true) && stdout.isTTY) { + stdout.on("resize", onResize); + resizeAttached = true; + onResize(); + } + + for (const signal of options.signals ?? ["SIGINT", "SIGHUP", "SIGTERM"]) { + const handler = () => { + signalOutcomeResolve?.({ + kind: "signal", + signal, + exitCode: signalExitCode(signal), + }); + }; + signalHandlers.set(signal, handler); + process.once(signal, handler); + } + + const outcome = await Promise.race([ + connection.waitShell(shellId).then((exitCode) => ({ + kind: "exit" as const, + exitCode, + })), + failurePromise, + signalPromise, + ]); + if (outcome.kind === "failure") { + await connection.closeShell(shellId); + shellClosed = true; + throw outcome.error; + } + if (outcome.kind === "signal") { + await connection.closeShell(shellId); + shellClosed = true; + return outcome.exitCode; + } + + await Promise.race([ + shellExitPromise, + new Promise((resolve) => + setTimeout(resolve, TRAILING_OUTPUT_GRACE_MS), + ), + ]); + await inputWrite; + if (failure) throw failure; + return outcome.exitCode; + } catch (error) { + if (shellId && !shellClosed) { + try { + await connection.closeShell(shellId); + shellClosed = true; + } catch (closeError) { + throw new NodeShellCleanupError([asError(error), asError(closeError)]); + } + } + throw error; + } finally { + for (const unsubscribe of unsubscribers) unsubscribe(); + if (inputAttached) { + stdin.removeListener("data", onInputData); + stdin.removeListener("end", onInputEnd); + stdin.removeListener("error", onInputError); + stdin.pause(); + } + if (resizeAttached) stdout.removeListener("resize", onResize); + if (rawModeChanged) stdin.setRawMode?.(previousRawMode); + for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler); + } + } +} + +function subscribe( + events: ShellEventSource, + event: string, + listener: (payload: TPayload) => void, +): () => void { + const untypedListener = listener as (payload: never) => void; + const result = events.on(event, untypedListener); + if (typeof result === "function") return result as () => void; + return () => { + if (events.off) events.off(event, untypedListener); + else events.removeListener?.(event, untypedListener); + }; +} + +function toBytes(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + if ( + Array.isArray(data) && + data.length === 2 && + data[0] === "$Uint8Array" && + typeof data[1] === "string" + ) { + return Buffer.from(data[1], "base64"); + } + if (typeof data === "string") return Buffer.from(data, "utf8"); + throw new TypeError( + `Unsupported AgentOS shell data payload: ${String(data)}`, + ); +} + +function positiveInteger(value: number | undefined): value is number { + return Number.isInteger(value) && (value ?? 0) > 0; +} + +function signalExitCode(signal: NodeShellSignal): number { + switch (signal) { + case "SIGHUP": + return 129; + case "SIGINT": + return 130; + case "SIGTERM": + return 143; + } +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/agentos/src/types.ts b/packages/agentos/src/types.ts index eb5c033e2a..240dcab9d7 100644 --- a/packages/agentos/src/types.ts +++ b/packages/agentos/src/types.ts @@ -32,6 +32,12 @@ export interface SessionEventPayload { event: JsonRpcNotification; } +export interface PromptResultEventPayload { + requestId: string; + result: PromptResult | null; + error: string | null; +} + export interface PermissionRequestPayload { sessionId: string; request: PermissionRequest; @@ -82,6 +88,7 @@ export interface CronEventPayload { export interface AgentOsEvents { sessionEvent: SessionEventPayload; + promptResult: PromptResultEventPayload; permissionRequest: PermissionRequestPayload; agentCrashed: AgentCrashedPayload; vmBooted: VmBootedPayload; diff --git a/packages/agentos/tests/node.test.ts b/packages/agentos/tests/node.test.ts new file mode 100644 index 0000000000..0210b3cb0b --- /dev/null +++ b/packages/agentos/tests/node.test.ts @@ -0,0 +1,158 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, test } from "vitest"; +import { + attachShell, + type NodeShellConnection, + NodeShellOutputLimitError, +} from "../src/node.js"; + +class TestInput extends EventEmitter { + isTTY = true; + isRaw = false; + readonly rawModes: boolean[] = []; + paused = true; + + setRawMode(enabled: boolean) { + this.isRaw = enabled; + this.rawModes.push(enabled); + } + + pause() { + this.paused = true; + return this; + } + + resume() { + this.paused = false; + return this; + } +} + +class TestOutput extends EventEmitter { + isTTY = true; + columns = 120; + rows = 40; + readonly chunks: Uint8Array[] = []; + + write(data: string | Uint8Array): boolean { + this.chunks.push( + typeof data === "string" ? Buffer.from(data, "utf8") : data, + ); + return true; + } + + text(): string { + return Buffer.concat(this.chunks).toString("utf8"); + } +} + +class TestConnection extends EventEmitter implements NodeShellConnection { + readonly shellId = "shell-test"; + readonly writes: Array = []; + readonly resizes: Array<[number, number]> = []; + closeCount = 0; + private resolveExit: ((exitCode: number) => void) | undefined; + private readonly exitPromise = new Promise((resolve) => { + this.resolveExit = resolve; + }); + + async openShell() { + this.emit("shellData", { + shellId: this.shellId, + data: Buffer.from("early output\n"), + }); + return { shellId: this.shellId }; + } + + async writeShell(_shellId: string, data: string | Uint8Array) { + this.writes.push(data); + } + + async resizeShell(_shellId: string, cols: number, rows: number) { + this.resizes.push([cols, rows]); + } + + async closeShell() { + this.closeCount++; + this.resolveExit?.(137); + } + + waitShell() { + return this.exitPromise; + } + + finish(exitCode: number) { + this.emit("shellExit", { shellId: this.shellId, exitCode }); + this.resolveExit?.(exitCode); + } +} + +describe("Node shell attachment", () => { + test("attaches PTY events to Node streams and restores terminal state", async () => { + const connection = new TestConnection(); + const stdin = new TestInput(); + const stdout = new TestOutput(); + const stderr = new TestOutput(); + const attached = attachShell(connection, { + stdin, + stdout, + stderr, + signals: [], + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(stdout.text()).toContain("early output"); + expect(connection.resizes).toContainEqual([120, 40]); + + stdin.emit("data", Buffer.from("echo hello\n")); + await new Promise((resolve) => setTimeout(resolve, 0)); + connection.emit("shellData", { + shellId: connection.shellId, + data: ["$Uint8Array", Buffer.from("hello\n").toString("base64")], + }); + connection.emit("shellStderr", { + shellId: connection.shellId, + data: "warning\n", + }); + stdout.columns = 90; + stdout.rows = 30; + stdout.emit("resize"); + await new Promise((resolve) => setTimeout(resolve, 0)); + connection.finish(7); + + await expect(attached).resolves.toBe(7); + expect(connection.writes).toEqual([Buffer.from("echo hello\n")]); + expect(connection.resizes).toContainEqual([90, 30]); + expect(stdout.text()).toContain("hello"); + expect(stderr.text()).toContain("warning"); + expect(stdin.rawModes).toEqual([true, false]); + expect(stdin.paused).toBe(true); + expect(connection.closeCount).toBe(0); + }); + + test("fails with a typed error when early output exceeds its bound", async () => { + const connection = new TestConnection(); + connection.openShell = async () => { + connection.emit("shellData", { + shellId: connection.shellId, + data: Buffer.from("too much output"), + }); + return { shellId: connection.shellId }; + }; + + await expect( + attachShell(connection, { + stdin: new TestInput(), + stdout: new TestOutput(), + stderr: new TestOutput(), + signals: [], + maxPendingOutputBytes: 4, + }), + ).rejects.toMatchObject({ + name: NodeShellOutputLimitError.name, + code: "AGENTOS_NODE_SHELL_OUTPUT_LIMIT", + limitBytes: 4, + }); + expect(connection.closeCount).toBe(1); + }); +}); diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index 7c8807fe14..774c6b8d00 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -20,9 +20,13 @@ import type { PermissionRequest, PermissionRequestHandler, SessionConfigOption, + SessionConfigSelectGroup, + SessionConfigSelectOption, SessionEventHandler, SessionInitData, + SessionMode, SessionModeState, + SessionUsage, } from "./agent-session-types.js"; import { type HostTool, type ToolKit, validateToolkits } from "./host-tools.js"; import { zodToJsonSchema } from "./host-tools-zod.js"; @@ -62,10 +66,13 @@ export type { PermissionRequest, PermissionRequestHandler, SessionConfigOption, + SessionConfigSelectGroup, + SessionConfigSelectOption, SessionEventHandler, SessionInitData, SessionMode, SessionModeState, + SessionUsage, } from "./agent-session-types.js"; export type { AcpTimeoutErrorData, @@ -81,6 +88,8 @@ export type { ConnectTerminalOptions } from "./runtime-compat.js"; const ACP_PROTOCOL_VERSION = 1; const ACP_EXTENSION_NAMESPACE = "dev.rivet.agent-os.acp"; const SHELL_DISPOSE_TIMEOUT_MS = 5_000; +const MAX_ACP_ADDITIONAL_DIRECTORIES = 128; +const MAX_ACP_GUEST_PATH_BYTES = 4_096; function defaultAcpClientCapabilities(): Record { return { @@ -89,9 +98,38 @@ function defaultAcpClientCapabilities(): Record { writeTextFile: true, }, terminal: true, + session: { + configOptions: { + boolean: {}, + }, + }, }; } +function normalizeAcpAdditionalDirectories( + directories: readonly string[] | undefined, +): string[] { + if (!directories) return []; + if (directories.length > MAX_ACP_ADDITIONAL_DIRECTORIES) { + throw new Error( + `ACP additionalDirectories exceeds ${MAX_ACP_ADDITIONAL_DIRECTORIES} entries`, + ); + } + return directories.map((directory, index) => { + if (!posixPath.isAbsolute(directory)) { + throw new Error( + `ACP additionalDirectories[${index}] must be an absolute guest path`, + ); + } + if (Buffer.byteLength(directory) > MAX_ACP_GUEST_PATH_BYTES) { + throw new Error( + `ACP additionalDirectories[${index}] exceeds ${MAX_ACP_GUEST_PATH_BYTES} bytes`, + ); + } + return posixPath.normalize(directory); + }); +} + async function waitForTrackedExitPromises( promises: Promise[], timeoutMs: number, @@ -156,7 +194,12 @@ export interface AgentRegistryEntry { adapterEntrypoint: string; } -import type { AgentType } from "./types.js"; +import { + OPT_AGENTOS_ROOT, + type PackageRef, + type SoftwarePackageRef, + tryReadAgentosPackageManifest, +} from "./agentos-package.js"; import { getBaseEnvironment } from "./base-filesystem.js"; import { CronManager } from "./cron/cron-manager.js"; import type { ScheduleDriver } from "./cron/schedule-driver.js"; @@ -168,6 +211,7 @@ import type { CronJobInfo, CronJobOptions, } from "./cron/types.js"; +import { resolveDefaultSoftware } from "./default-software.js"; import { type FilesystemEntry, snapshotVirtualFilesystem, @@ -185,14 +229,7 @@ import { type RootSnapshotExport, type SnapshotLayerHandle, } from "./layers.js"; -import { type SoftwareInput, type SoftwareRoot } from "./packages.js"; -import { - OPT_AGENTOS_ROOT, - type PackageRef, - type SoftwarePackageRef, - tryReadAgentosPackageManifest, -} from "./agentos-package.js"; -import { resolveDefaultSoftware } from "./default-software.js"; +import type { SoftwareInput, SoftwareRoot } from "./packages.js"; import type { PermissionTier } from "./runtime.js"; import { allowAll, createNodeHostNetworkAdapter } from "./runtime-compat.js"; import { @@ -228,6 +265,7 @@ import { type SidecarSessionState, serializeRootFilesystemForSidecar, } from "./sidecar/rpc-client.js"; +import type { AgentType } from "./types.js"; export interface AgentOsSharedSidecarOptions { pool?: string; @@ -296,6 +334,7 @@ interface AgentSessionEntry { closed: boolean; modes: SessionModeState | null; configOptions: SessionConfigOption[]; + usage: SessionUsage | null; capabilities: AgentCapabilities; agentInfo: AgentInfo | null; eventHandlers: Set; @@ -305,7 +344,7 @@ interface AgentSessionEntry { * this session, so a tool-heavy turn does not re-warn on every request. */ warnedNoPermissionHandler: boolean; - configOverrides: Map; + configOverrides: Map; pendingPermissionReplies: Map< string, { @@ -665,6 +704,8 @@ export interface CreateSessionOptions { env?: Record; /** MCP servers to make available to the agent during the session. */ mcpServers?: McpServerConfig[]; + /** Additional absolute guest workspace roots exposed to ACP-capable agents. */ + additionalDirectories?: string[]; /** Skip OS instructions injection entirely (default false). */ skipOsInstructions?: boolean; /** Additional instructions appended to the base OS instructions. */ @@ -688,6 +729,10 @@ export interface ResumeSessionOptions { transcriptPath?: string; /** Working directory for the resumed agent session (default `/workspace`). */ cwd?: string; + /** Additional absolute guest workspace roots for the resumed session. */ + additionalDirectories?: string[]; + /** MCP servers to reconnect while resuming the session. */ + mcpServers?: McpServerConfig[]; /** Environment variables to pass to the resumed agent process. */ env?: Record; } @@ -956,11 +1001,146 @@ function toSessionModes(value: unknown): SessionModeState | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } - return value as SessionModeState; + const object = value as Record; + if ( + typeof object.currentModeId !== "string" || + !Array.isArray(object.availableModes) + ) { + return null; + } + const availableModes = object.availableModes.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const mode = entry as Record; + if (typeof mode.id !== "string") return []; + return [ + { + ...mode, + id: mode.id, + name: + typeof mode.name === "string" + ? mode.name + : typeof mode.label === "string" + ? mode.label + : mode.id, + } as SessionMode, + ]; + }); + return { currentModeId: object.currentModeId, availableModes }; } function toSessionConfigOptions(value: unknown): SessionConfigOption[] { - return Array.isArray(value) ? (value as SessionConfigOption[]) : []; + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const option = entry as Record; + if (typeof option.id !== "string") return []; + const base = { + ...option, + id: option.id, + name: + typeof option.name === "string" + ? option.name + : typeof option.label === "string" + ? option.label + : option.id, + }; + if (option.type === "boolean" || typeof option.currentValue === "boolean") { + return [ + { + ...base, + type: "boolean" as const, + currentValue: + typeof option.currentValue === "boolean" + ? option.currentValue + : false, + } as SessionConfigOption, + ]; + } + const rawOptions = Array.isArray(option.options) + ? option.options + : Array.isArray(option.allowedValues) + ? option.allowedValues.map((allowed) => { + const item = + allowed && typeof allowed === "object" && !Array.isArray(allowed) + ? (allowed as Record) + : {}; + return { + value: item.id, + name: typeof item.label === "string" ? item.label : item.id, + }; + }) + : []; + const options: Array = + []; + for (const raw of rawOptions) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const item = raw as Record; + if (typeof item.group === "string" && Array.isArray(item.options)) { + const children = item.options.flatMap((child) => { + if (!child || typeof child !== "object" || Array.isArray(child)) { + return []; + } + const childItem = child as Record; + if (typeof childItem.value !== "string") return []; + return [ + { + ...childItem, + value: childItem.value, + name: + typeof childItem.name === "string" + ? childItem.name + : childItem.value, + } as SessionConfigSelectOption, + ]; + }); + options.push({ + ...item, + group: item.group, + name: typeof item.name === "string" ? item.name : item.group, + options: children, + } as SessionConfigSelectGroup); + continue; + } + if (typeof item.value !== "string") continue; + options.push({ + ...item, + value: item.value, + name: typeof item.name === "string" ? item.name : item.value, + } as SessionConfigSelectOption); + } + return [ + { + ...base, + type: "select" as const, + currentValue: + typeof option.currentValue === "string" ? option.currentValue : "", + options, + } as SessionConfigOption, + ]; + }); +} + +function withSessionConfigValue( + option: SessionConfigOption, + value: string | boolean, +): SessionConfigOption { + if (option.type === "boolean") { + return typeof value === "boolean" + ? { ...option, currentValue: value } + : option; + } + return typeof value === "string" + ? { ...option, currentValue: value } + : option; +} + +function toSessionUsage(value: unknown): SessionUsage | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const usage = value as Record; + if (typeof usage.used !== "number" || typeof usage.size !== "number") { + return null; + } + return usage as unknown as SessionUsage; } function toAgentCapabilities(value: unknown): AgentCapabilities { @@ -993,6 +1173,7 @@ function sessionEntryFromInit( closed: false, modes: initData.modes ?? null, configOptions: initData.configOptions ?? [], + usage: null, capabilities: initData.capabilities ?? {}, agentInfo: initData.agentInfo ?? null, eventHandlers: new Set(), @@ -3643,7 +3824,11 @@ export class AgentOs { sessionUpdate === "config_options_update") && Array.isArray(update.configOptions) ) { - session.configOptions = update.configOptions as SessionConfigOption[]; + session.configOptions = toSessionConfigOptions(update.configOptions); + } + + if (sessionUpdate === "usage_update") { + session.usage = toSessionUsage(update); } } @@ -3853,7 +4038,7 @@ export class AgentOs { : undefined); return override === undefined ? option - : { ...option, currentValue: override }; + : withSessionConfigValue(option, override); }); } @@ -4062,7 +4247,8 @@ export class AgentOs { if ( method === "session/set_config_option" && typeof params?.configId === "string" && - typeof params?.value === "string" + (typeof params?.value === "string" || + typeof params?.value === "boolean") ) { const nextValue = params.value; const updatedOption = session.configOptions.find( @@ -4074,7 +4260,7 @@ export class AgentOs { } session.configOptions = session.configOptions.map((option) => option.id === params.configId - ? { ...option, currentValue: nextValue } + ? withSessionConfigValue(option, nextValue) : option, ); } @@ -4085,13 +4271,13 @@ export class AgentOs { private async _setSessionConfigByCategory( sessionId: string, category: string, - value: string, + value: string | boolean, ): Promise { const session = this._requireSession(sessionId); const option = session.configOptions.find( (entry) => entry.category === category, ); - if (option?.readOnly) { + if (option?.readOnly === true) { return this._unsupportedConfigResponse(session.agentType, category); } const response = await this._sendSessionRequest( @@ -4245,6 +4431,9 @@ export class AgentOs { // skipOsInstructions plus the caller's env. const launchEnv = { ...options?.env }; const sessionCwd = options?.cwd ?? "/workspace"; + const additionalDirectories = normalizeAcpAdditionalDirectories( + options?.additionalDirectories, + ); const response = await this._sendAcpRequest({ tag: "AcpCreateSessionRequest", @@ -4254,6 +4443,7 @@ export class AgentOs { args: [], env: new Map(Object.entries(launchEnv)), cwd: sessionCwd, + additionalDirectories, mcpServers: JSON.stringify(options?.mcpServers ?? []), protocolVersion: ACP_PROTOCOL_VERSION, clientCapabilities: JSON.stringify(defaultAcpClientCapabilities()), @@ -4331,6 +4521,9 @@ export class AgentOs { // projected manifest, exactly as createSession does. const sessionCwd = options?.cwd ?? "/workspace"; const launchEnv = { ...options?.env }; + const additionalDirectories = normalizeAcpAdditionalDirectories( + options?.additionalDirectories, + ); const response = await this._sendAcpRequest({ tag: "AcpResumeSessionRequest", @@ -4339,6 +4532,8 @@ export class AgentOs { agentType: String(agentType), transcriptPath: options?.transcriptPath ?? null, cwd: sessionCwd, + additionalDirectories, + mcpServers: JSON.stringify(options?.mcpServers ?? []), env: new Map(Object.entries(launchEnv)), }, }); @@ -5232,6 +5427,25 @@ export class AgentOs { return this._setSessionConfigByCategory(sessionId, "thought_level", level); } + async setSessionConfigOption( + sessionId: string, + configId: string, + value: string | boolean, + ): Promise { + const session = this._requireSession(sessionId); + const option = session.configOptions.find((entry) => entry.id === configId); + if (option?.readOnly === true) { + return this._unsupportedConfigResponse( + session.agentType, + option.category ?? configId, + ); + } + return this._sendSessionRequest(sessionId, "session/set_config_option", { + configId, + value, + }); + } + getSessionConfigOptions(sessionId: string): SessionConfigOption[] { return [...this._requireSession(sessionId).configOptions]; } @@ -5241,6 +5455,10 @@ export class AgentOs { return Object.keys(caps).length > 0 ? caps : null; } + getSessionUsage(sessionId: string): SessionUsage | null { + return this._requireSession(sessionId).usage; + } + getSessionAgentInfo(sessionId: string): AgentInfo | null { return this._requireSession(sessionId).agentInfo; } diff --git a/packages/core/src/agent-session-types.ts b/packages/core/src/agent-session-types.ts index 1f6541fc4a..8658c197ac 100644 --- a/packages/core/src/agent-session-types.ts +++ b/packages/core/src/agent-session-types.ts @@ -14,9 +14,11 @@ export type PermissionRequestHandler = (request: PermissionRequest) => void; export interface SessionMode { id: string; - name?: string; + name: string; + /** @deprecated Legacy adapter alias retained while values are normalized. */ label?: string; description?: string; + _meta?: Record | null; [key: string]: unknown; } @@ -25,24 +27,89 @@ export interface SessionModeState { availableModes: SessionMode[]; } -export interface SessionConfigOption { +export interface SessionConfigSelectOption { + value: string; + name: string; + description?: string | null; + _meta?: Record | null; + [key: string]: unknown; +} + +export interface SessionConfigSelectGroup { + group: string; + name: string; + options: SessionConfigSelectOption[]; + _meta?: Record | null; + [key: string]: unknown; +} + +export interface SessionConfigOptionBase { id: string; - category?: string; + name: string; + category?: string | null; + description?: string | null; + _meta?: Record | null; + /** @deprecated Pre-ACP-1.18 adapter alias retained while values are normalized. */ label?: string; - description?: string; - currentValue?: string; + /** @deprecated Pre-ACP-1.18 adapter shape retained while values are normalized. */ allowedValues?: Array<{ id: string; label?: string }>; + /** AgentOS extension for adapters that report but cannot mutate an option. */ readOnly?: boolean; + [key: string]: unknown; } +export type SessionConfigOption = SessionConfigOptionBase & + ( + | { + type: "select"; + currentValue: string; + options: Array; + } + | { + type: "boolean"; + currentValue: boolean; + } + ); + export interface PromptCapabilities { audio?: boolean; embeddedContext?: boolean; image?: boolean; + _meta?: Record | null; + [key: string]: unknown; +} + +export interface AcpCapability { + _meta?: Record | null; + [key: string]: unknown; +} + +export interface SessionCapabilities { + list?: AcpCapability | null; + delete?: AcpCapability | null; + additionalDirectories?: AcpCapability | null; + resume?: AcpCapability | null; + close?: AcpCapability | null; + _meta?: Record | null; + [key: string]: unknown; +} + +export interface AgentAuthCapabilities { + logout?: AcpCapability | null; + _meta?: Record | null; [key: string]: unknown; } export interface AgentCapabilities { + loadSession?: boolean; + mcpCapabilities?: { + http?: boolean; + sse?: boolean; + _meta?: Record | null; + [key: string]: unknown; + }; + sessionCapabilities?: SessionCapabilities; + auth?: AgentAuthCapabilities; permissions?: boolean; plan_mode?: boolean; questions?: boolean; @@ -57,6 +124,7 @@ export interface AgentCapabilities { streaming_deltas?: boolean; mcp_tools?: boolean; promptCapabilities?: PromptCapabilities; + _meta?: Record | null; [key: string]: unknown; } @@ -64,6 +132,22 @@ export interface AgentInfo { name: string; title?: string; version?: string; + _meta?: Record | null; + [key: string]: unknown; +} + +export interface SessionCost { + amount: number; + currency: string; + _meta?: Record | null; + [key: string]: unknown; +} + +export interface SessionUsage { + used: number; + size: number; + cost?: SessionCost | null; + _meta?: Record | null; [key: string]: unknown; } diff --git a/packages/core/src/sidecar/agentos-protocol.ts b/packages/core/src/sidecar/agentos-protocol.ts index 5a29049cc8..fe8d560aab 100644 --- a/packages/core/src/sidecar/agentos-protocol.ts +++ b/packages/core/src/sidecar/agentos-protocol.ts @@ -113,6 +113,7 @@ export type AcpCreateSessionRequest = { readonly agentType: string readonly runtime: AcpRuntimeKind readonly cwd: string + readonly additionalDirectories: readonly string[] readonly args: readonly string[] readonly env: ReadonlyMap readonly protocolVersion: i32 @@ -127,6 +128,7 @@ export function readAcpCreateSessionRequest(bc: bare.ByteCursor): AcpCreateSessi agentType: bare.readString(bc), runtime: readAcpRuntimeKind(bc), cwd: bare.readString(bc), + additionalDirectories: read0(bc), args: read0(bc), env: read1(bc), protocolVersion: bare.readI32(bc), @@ -141,6 +143,7 @@ export function writeAcpCreateSessionRequest(bc: bare.ByteCursor, x: AcpCreateSe bare.writeString(bc, x.agentType) writeAcpRuntimeKind(bc, x.runtime) bare.writeString(bc, x.cwd) + write0(bc, x.additionalDirectories) write0(bc, x.args) write1(bc, x.env) bare.writeI32(bc, x.protocolVersion) @@ -293,6 +296,8 @@ export type AcpResumeSessionRequest = { readonly agentType: string readonly transcriptPath: string | null readonly cwd: string + readonly additionalDirectories: readonly string[] + readonly mcpServers: JsonUtf8 readonly env: ReadonlyMap } @@ -302,6 +307,8 @@ export function readAcpResumeSessionRequest(bc: bare.ByteCursor): AcpResumeSessi agentType: bare.readString(bc), transcriptPath: read2(bc), cwd: bare.readString(bc), + additionalDirectories: read0(bc), + mcpServers: readJsonUtf8(bc), env: read1(bc), } } @@ -311,6 +318,8 @@ export function writeAcpResumeSessionRequest(bc: bare.ByteCursor, x: AcpResumeSe bare.writeString(bc, x.agentType) write2(bc, x.transcriptPath) bare.writeString(bc, x.cwd) + write0(bc, x.additionalDirectories) + writeJsonUtf8(bc, x.mcpServers) write1(bc, x.env) } diff --git a/packages/core/tests/agentos-protocol.test.ts b/packages/core/tests/agentos-protocol.test.ts index 88df917832..7f35855258 100644 --- a/packages/core/tests/agentos-protocol.test.ts +++ b/packages/core/tests/agentos-protocol.test.ts @@ -14,6 +14,7 @@ describe("agent-os ACP protocol", () => { agentType: "codex", runtime: AcpRuntimeKind.JavaScript, cwd: "/home/agentos", + additionalDirectories: ["/tmp/reference"], args: ["--model", "gpt-5"], env: new Map([["AGENTOS_KEEP_STDIN_OPEN", "1"]]), protocolVersion: 1, diff --git a/packages/core/tests/synthetic-session-updates.test.ts b/packages/core/tests/synthetic-session-updates.test.ts index 8db25631b1..fa69b134d6 100644 --- a/packages/core/tests/synthetic-session-updates.test.ts +++ b/packages/core/tests/synthetic-session-updates.test.ts @@ -11,14 +11,25 @@ const sessionState = { { id: "model", category: "model", - label: "Model", + name: "Model", + type: "select", currentValue: "gpt-5-codex", + options: [{ value: "gpt-5-codex", name: "GPT-5 Codex", _meta: { source: "mock" } }], }, { id: "thought_level", category: "thought_level", - label: "Thought Level", + name: "Thought Level", + type: "select", currentValue: "medium", + options: [{ value: "medium", name: "Medium" }, { value: "high", name: "High" }], + }, + { + id: "auto_compact", + category: "model_config", + name: "Auto compact", + type: "boolean", + currentValue: false, }, ], }; @@ -43,6 +54,10 @@ function writeError(id, message, data) { }) + "\\n"); } +function writeNotification(method, params) { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\\n"); +} + process.stdin.resume(); process.stdin.on("data", (chunk) => { const text = chunk instanceof Uint8Array ? new TextDecoder().decode(chunk) : String(chunk); @@ -69,26 +84,31 @@ process.stdin.on("data", (chunk) => { agentCapabilities: { plan_mode: true, tool_calls: false, + sessionCapabilities: { additionalDirectories: {} }, promptCapabilities: {}, }, modes: { currentModeId: sessionState.modeId, availableModes: [ - { id: "default", label: "Default" }, - { id: "plan", label: "Plan" }, + { id: "default", name: "Default" }, + { id: "plan", name: "Plan" }, ], }, configOptions: sessionState.configOptions, }); break; case "session/new": + if (JSON.stringify(msg.params?.additionalDirectories) !== JSON.stringify(["/tmp/reference"])) { + writeError(msg.id, "additionalDirectories not forwarded"); + break; + } writeResponse(msg.id, { sessionId: "mock-session-1", modes: { currentModeId: sessionState.modeId, availableModes: [ - { id: "default", label: "Default" }, - { id: "plan", label: "Plan" }, + { id: "default", name: "Default" }, + { id: "plan", name: "Plan" }, ], }, configOptions: sessionState.configOptions, @@ -101,7 +121,7 @@ process.stdin.on("data", (chunk) => { case "session/set_config_option": { const configId = msg.params?.configId; const value = msg.params?.value; - if (typeof configId !== "string" || typeof value !== "string") { + if (typeof configId !== "string" || (typeof value !== "string" && typeof value !== "boolean")) { writeError(msg.id, "invalid config option params"); break; } @@ -111,6 +131,15 @@ process.stdin.on("data", (chunk) => { break; } option.currentValue = value; + writeNotification("session/update", { + sessionId: msg.params.sessionId, + update: { + sessionUpdate: "usage_update", + used: 123, + size: 4096, + cost: { amount: 0.25, currency: "USD", _meta: { source: "mock" } }, + }, + }); writeResponse(msg.id, { configOptions: sessionState.configOptions }); break; } @@ -142,7 +171,11 @@ describe("synthetic session/update compatibility", () => { let sessionId: string | undefined; try { - sessionId = (await vm.createSession("synthetic")).sessionId; + sessionId = ( + await vm.createSession("synthetic", { + additionalDirectories: ["/tmp/reference"], + }) + ).sessionId; const receivedEvents: string[] = []; const unsubscribe = vm.onSessionEvent(sessionId, (event) => { @@ -153,6 +186,7 @@ describe("synthetic session/update compatibility", () => { await vm.setSessionModel(sessionId, "gpt-5-codex"); await vm.setSessionThoughtLevel(sessionId, "high"); + await vm.setSessionConfigOption(sessionId, "auto_compact", true); await vm.setSessionMode(sessionId, "plan"); await new Promise((resolve) => queueMicrotask(resolve)); unsubscribe(); @@ -167,6 +201,19 @@ describe("synthetic session/update compatibility", () => { configOptions.find((option) => option.category === "thought_level") ?.currentValue, ).toBe("high"); + expect( + configOptions.find((option) => option.id === "auto_compact") + ?.currentValue, + ).toBe(true); + expect( + configOptions.find((option) => option.id === "model")?.options[0] + ?._meta, + ).toEqual({ source: "mock" }); + expect(vm.getSessionUsage(sessionId)).toMatchObject({ + used: 123, + size: 4096, + cost: { amount: 0.25, currency: "USD" }, + }); expect( receivedEvents.some((event) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f159cc531e..a86aa63ce6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2253,6 +2253,52 @@ importers: specifier: ^5.7.2 version: 5.9.3 + experiments/gigacode: + dependencies: + '@agentos-software/claude-code': + specifier: workspace:* + version: link:../../registry/agent/claude + '@agentos-software/codex': + specifier: workspace:* + version: link:../../registry/agent/codex + '@agentos-software/opencode': + specifier: workspace:* + version: link:../../registry/agent/opencode + '@agentos-software/pi': + specifier: workspace:* + version: link:../../registry/agent/pi + '@rivet-dev/agentos': + specifier: workspace:* + version: link:../../packages/agentos + opencode-ai: + specifier: 1.17.18 + version: 1.17.18 + pino: + specifier: 9.14.0 + version: 9.14.0 + rivetkit: + specifier: catalog:rivetkit + version: 0.0.0-stack-feat-rivetkit-forward-inspector-tabs-to-native-plugin-actors-qkwmksyy.d2859b0(@opentelemetry/api@1.9.0)(better-sqlite3@12.8.0)(ws@8.21.0(bufferutil@4.1.0)) + tsx: + specifier: ^4.19.0 + version: 4.21.0 + devDependencies: + '@copilotkit/llmock': + specifier: ^1.6.0 + version: 1.6.0 + '@opencode-ai/sdk': + specifier: ^1.1.21 + version: 1.17.18 + '@types/node': + specifier: ^22.19.15 + version: 22.19.15 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.19.15) + packages/agentos: dependencies: '@agentos-software/common': @@ -2927,8 +2973,8 @@ importers: registry/agent/claude: dependencies: '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^1.2.1 + version: 1.2.1(zod@4.3.6) '@anthropic-ai/claude-agent-sdk': specifier: 0.2.87 version: 0.2.87(@cfworker/json-schema@4.1.1)(zod@4.3.6) @@ -2986,8 +3032,8 @@ importers: registry/agent/pi: dependencies: '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^1.2.1 + version: 1.2.1(zod@4.3.6) '@mariozechner/pi-ai': specifier: 0.60.0 version: 0.60.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6))(bufferutil@4.1.0)(ws@8.21.0(bufferutil@4.1.0))(zod@4.3.6) @@ -3590,6 +3636,11 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@agentclientprotocol/sdk@1.2.1': + resolution: {integrity: sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@ai-sdk/amazon-bedrock@3.0.93': resolution: {integrity: sha512-57cP3Ume6DdQP05xPYl2g554EqPrQgKRW/eE3BGm1ktK1k71e35HGzNl1GZHIYKct82QrY/iQuheanSonI88Dg==} engines: {node: '>=18'} @@ -4948,6 +4999,9 @@ packages: resolution: {integrity: sha512-cZ5Ktd+rT0PO+o7KBH4vRFTgg+xMLf8F41WK39p8MkXEViZA/Qqe+4lzZT6102zgUxMORET1HtF9t5w8CB3tnQ==} engines: {node: '>=18.0.0'} + '@opencode-ai/sdk@1.17.18': + resolution: {integrity: sha512-c/C9PhY8PrbcxDY+JIYtOZsrmMD0KzoVvxq+RGUrZ6LQp57SuVBbT4lfwA2G8Se5RNC1N5JtYjiuaXeECnF2SQ==} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -7645,6 +7699,72 @@ packages: openapi3-ts@4.6.0: resolution: {integrity: sha512-a4sfn6L2sIShhtzJqmjGrARvxAW/3F2BJDdyRVvNF9VhAsZSh5hSyI3a9TNvmzBxXmq66nY5LNT5bQcBxYAZZg==} + opencode-ai@1.17.18: + resolution: {integrity: sha512-gpCE5X3dwvYam2ba9r3mw+FTPBTDlmKkguiOoYF1nv9MVA3sDIXje3tPQ+EMNxYkWD43FN3+eVC1GW6tRO9Aiw==} + cpu: [arm64, x64] + os: [darwin, linux, win32] + hasBin: true + + opencode-darwin-arm64@1.17.18: + resolution: {integrity: sha512-wcnsPXxNnIggdva8rQ2KAWi17GdyDL4joAT+M/+UqkRFXhTBgfScWiWyCAGpZitDlpxd0K5W79NPjal54A8aiw==} + cpu: [arm64] + os: [darwin] + + opencode-darwin-x64-baseline@1.17.18: + resolution: {integrity: sha512-kkUWjHQBtYtbtoAxV7N8srXpAdWu7ajynnrUQ/raHhJc2HnsR3Ud6/Ms60YSpNWOuCZ/WgtISpXkg12E7opvcA==} + cpu: [x64] + os: [darwin] + + opencode-darwin-x64@1.17.18: + resolution: {integrity: sha512-WwTwf6pCMkJ4ed3l6mLn2+eVz+Yli+5ijine0Hx7r+LVUYv9Odo46lHO4ZLd2m/dbnnDcX5xHNklasBNfCkBEQ==} + cpu: [x64] + os: [darwin] + + opencode-linux-arm64-musl@1.17.18: + resolution: {integrity: sha512-KW+77OknysG0gkD8tRzIcFKuv2mJR3jDtEQcCOhXmkAEWv7rEIqaDehWDa35F7AKjNJnJ4f6P7dDLMDJeQztHg==} + cpu: [arm64] + os: [linux] + + opencode-linux-arm64@1.17.18: + resolution: {integrity: sha512-VrTs+uQndp+B442Bfxf1a7BX9599gARjQCJ+MVCkcxl4Dr4gbBw7EfjKgGvhCqKUN5Hv2I7hUG/EZ1Ix3T6xrw==} + cpu: [arm64] + os: [linux] + + opencode-linux-x64-baseline-musl@1.17.18: + resolution: {integrity: sha512-e4/FLdohz2euPXsziktv7mKrYLO01R4n7bwMvhAcPDaYc9zCagHUoALZVYm0BXtVB6YikaIfnHsbAIAgNlC37w==} + cpu: [x64] + os: [linux] + + opencode-linux-x64-baseline@1.17.18: + resolution: {integrity: sha512-ZHgobRKdHocQJSAzrz/ekKBjN1cScB+u07Zgr5Hzks2zm+ZyuN5zvx/NOKM7ImPEy3TQ0+swXvtoYKCOE4Tpbw==} + cpu: [x64] + os: [linux] + + opencode-linux-x64-musl@1.17.18: + resolution: {integrity: sha512-3pWmNMZ08vsOVnQcOydqnE66NTZjqMZllcX6M9w+nCbQ2Edi6xWL1NMiW9MFvjyJMuOxTqzif6RvpIcK3CZHxw==} + cpu: [x64] + os: [linux] + + opencode-linux-x64@1.17.18: + resolution: {integrity: sha512-8BmT22yp7pCXXu/HvAMaJsNNd6xhmlUrGs5YZSfU0neZfkSZg+Dkf9IGsuOugOtL0x2erDg2/6rRBpcJAGmTrA==} + cpu: [x64] + os: [linux] + + opencode-windows-arm64@1.17.18: + resolution: {integrity: sha512-5vtCGvt3k1RbhiB31Sg4Q0H1Qy+npFMD1y4N3fhx8X+Dyrzfly7SPQNQPtSt8nDluRhcCB0oFl9suDew2q2YOg==} + cpu: [arm64] + os: [win32] + + opencode-windows-x64-baseline@1.17.18: + resolution: {integrity: sha512-pt9ga2UcoTUo6+/rFvZZ+KOxHXkVMHPqBi3tVjI/B96icAGtpXNPDznK+HuahBFKKMr9y8pwu17yd94Vn+gMMg==} + cpu: [x64] + os: [win32] + + opencode-windows-x64@1.17.18: + resolution: {integrity: sha512-6i17BI5tmQvopBm6lwerPu2I7p2BYneX7XDlHYeVCy9+INL0T8sD8Ww+Lobqs76E8kepwP1NVqEFzs5DkLYVJw==} + cpu: [x64] + os: [win32] + os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} @@ -8849,6 +8969,10 @@ snapshots: dependencies: zod: 4.3.6 + '@agentclientprotocol/sdk@1.2.1(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@ai-sdk/amazon-bedrock@3.0.93(zod@4.3.6)': dependencies: '@ai-sdk/anthropic': 2.0.74(zod@4.3.6) @@ -10711,6 +10835,10 @@ snapshots: wordwrap: 1.0.0 wrap-ansi: 7.0.0 + '@opencode-ai/sdk@1.17.18': + dependencies: + cross-spawn: 7.0.6 + '@opentelemetry/api@1.9.0': {} '@oven/bun-darwin-aarch64@1.3.11': @@ -13644,6 +13772,57 @@ snapshots: dependencies: yaml: 2.9.0 + opencode-ai@1.17.18: + optionalDependencies: + opencode-darwin-arm64: 1.17.18 + opencode-darwin-x64: 1.17.18 + opencode-darwin-x64-baseline: 1.17.18 + opencode-linux-arm64: 1.17.18 + opencode-linux-arm64-musl: 1.17.18 + opencode-linux-x64: 1.17.18 + opencode-linux-x64-baseline: 1.17.18 + opencode-linux-x64-baseline-musl: 1.17.18 + opencode-linux-x64-musl: 1.17.18 + opencode-windows-arm64: 1.17.18 + opencode-windows-x64: 1.17.18 + opencode-windows-x64-baseline: 1.17.18 + + opencode-darwin-arm64@1.17.18: + optional: true + + opencode-darwin-x64-baseline@1.17.18: + optional: true + + opencode-darwin-x64@1.17.18: + optional: true + + opencode-linux-arm64-musl@1.17.18: + optional: true + + opencode-linux-arm64@1.17.18: + optional: true + + opencode-linux-x64-baseline-musl@1.17.18: + optional: true + + opencode-linux-x64-baseline@1.17.18: + optional: true + + opencode-linux-x64-musl@1.17.18: + optional: true + + opencode-linux-x64@1.17.18: + optional: true + + opencode-windows-arm64@1.17.18: + optional: true + + opencode-windows-x64-baseline@1.17.18: + optional: true + + opencode-windows-x64@1.17.18: + optional: true + os-browserify@0.3.0: {} p-finally@1.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index abbcff2e92..40a18b4975 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,7 @@ packages: - registry/software/* - registry/tool/* - examples/* + - experiments/* # Nested example packages (e.g. examples/quickstart/hello-world) so every # embeddable example is a workspace member that `turbo check-types` covers. - examples/*/* @@ -49,6 +50,7 @@ onlyBuiltDependencies: - cbor-extract - esbuild - lefthook + - opencode-ai - sharp # rivetkit runtime + react bindings. Same-version lockstep with the sidecar; diff --git a/registry/agent/claude/agentos-package.json b/registry/agent/claude/agentos-package.json index 5d09a62725..4fd96f6c89 100644 --- a/registry/agent/claude/agentos-package.json +++ b/registry/agent/claude/agentos-package.json @@ -4,7 +4,6 @@ "acpEntrypoint": "claude-sdk-acp", "env": { "CLAUDE_AGENT_SDK_CLIENT_APP": "@rivet-dev/agentos", - "CLAUDE_CODE_SIMPLE": "1", "CLAUDE_CODE_FORCE_AGENT_OS_RIPGREP": "1", "CLAUDE_CODE_DEFER_GROWTHBOOK_INIT": "1", "CLAUDE_CODE_DISABLE_CWD_PERSIST": "1", diff --git a/registry/agent/claude/package.json b/registry/agent/claude/package.json index 3b056fe9be..d5241586bb 100644 --- a/registry/agent/claude/package.json +++ b/registry/agent/claude/package.json @@ -28,7 +28,7 @@ "test": "pnpm build && node --test --test-force-exit tests/*.test.mjs" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^1.2.1", "@anthropic-ai/claude-agent-sdk": "0.2.87", "zod": "^4.1.11" }, diff --git a/registry/agent/claude/src/adapter.ts b/registry/agent/claude/src/adapter.ts index 99e87d2b85..e33a8be197 100644 --- a/registry/agent/claude/src/adapter.ts +++ b/registry/agent/claude/src/adapter.ts @@ -6,6 +6,8 @@ import { type AuthenticateRequest, type AuthenticateResponse, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type InitializeRequest, type InitializeResponse, type NewSessionRequest, @@ -346,8 +348,10 @@ export class ClaudeQuerySession { // before a tool_use shifts that index. Cleared on each turn terminus. private toolUseBlockIndex = new Map(); private reader: Promise; + private createQuery: () => Query; private closed = false; private cancelled = false; + private cancellationBarrier: Promise = Promise.resolve(); constructor( private readonly conn: AgentSideConnection, @@ -361,7 +365,7 @@ export class ClaudeQuerySession { traceAdapter( `session_ctor id=${sessionId} cwd=${cwd} mode=${mode} cli=${pathToClaudeCodeExecutable}`, ); - this.query = queryFactory({ + this.createQuery = () => queryFactory({ prompt: this.promptQueue, options: { canUseTool: this.createPermissionHandler(), @@ -378,7 +382,7 @@ export class ClaudeQuerySession { process.env.CLAUDE_CODE_DISABLE_CWD_PERSIST ?? "1", CLAUDE_CODE_SIMPLE_SHELL_EXEC: process.env.CLAUDE_CODE_SIMPLE_SHELL_EXEC ?? "1", - CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE ?? "1", + CLAUDE_CODE_SIMPLE: process.env.CLAUDE_CODE_SIMPLE ?? "0", CLAUDE_CODE_NODE_SHELL_WRAPPER: process.env.CLAUDE_CODE_NODE_SHELL_WRAPPER ?? "1", CLAUDE_CODE_SKIP_INITIAL_MESSAGES: @@ -393,9 +397,8 @@ export class ClaudeQuerySession { process.env.CLAUDE_CODE_TRACE_STARTUP ?? "0", SHELL: process.env.SHELL ?? "/bin/sh", }, - extraArgs: { - bare: null, - }, + extraArgs: + process.env.CLAUDE_CODE_BARE === "1" ? { bare: null } : {}, includePartialMessages: true, pathToClaudeCodeExecutable, permissionMode: normalizeClaudePermissionMode(mode), @@ -563,8 +566,9 @@ export class ClaudeQuerySession { tools: { type: "preset", preset: "claude_code" }, }, }); + this.query = this.createQuery(); this.readyPromise = this.initialize(params); - this.reader = this.consume(); + this.reader = this.consume(this.query); } get currentMode(): PermissionMode { @@ -576,6 +580,7 @@ export class ClaudeQuerySession { } async prompt(params: PromptRequest): Promise { + await this.cancellationBarrier; if (this.closed) { throw new Error("Session is closed"); } @@ -622,11 +627,42 @@ export class ClaudeQuerySession { }); } - async cancel(): Promise { + cancel(): Promise { + const cancellation = this.cancelCurrentTurn(); + this.cancellationBarrier = cancellation; + return cancellation; + } + + private async cancelCurrentTurn(): Promise { if (this.closed) return; - this.cancelled = true; await this.readyPromise.catch(() => {}); - await this.query.interrupt(); + const turn = this.pendingTurn; + if (!turn) return; + this.cancelled = true; + this.pendingTurn = null; + this.activeToolCalls.clear(); + this.toolUseBlockIndex.clear(); + this.promptQueue.end(); + const previousQuery = this.query; + // Install the replacement before interrupting the old query. interrupt() + // can end its async iterator immediately; consume() must already be able + // to identify that iterator as replaced or it closes the entire session. + this.promptQueue = new AsyncQueue(); + this.mcpServersApplied = false; + this.query = this.createQuery(); + this.reader = this.consume(this.query); + try { + // interrupt() is the only safe per-turn lifecycle call here. close() + // races the SDK iterator's own child cleanup after earlier queued turns + // and can issue a second kill for an already-reaped child. + await previousQuery.interrupt(); + } catch (error) { + process.stderr.write( + `[claude-acp] failed to interrupt cancelled query: ${formatError(error)}\n`, + ); + } + this.cancelled = false; + turn.resolve({ stopReason: "cancelled" }); } async setMode(mode: PermissionMode): Promise { @@ -672,10 +708,11 @@ export class ClaudeQuerySession { traceAdapter(`apply_mcp_done session=${this.sessionId}`); } - private async consume(): Promise { + private async consume(query: Query): Promise { traceAdapter(`consume_start session=${this.sessionId}`); try { - for await (const message of this.query) { + for await (const message of query) { + if (query !== this.query) continue; traceAdapter( `consume_message session=${this.sessionId} type=${String(message.type ?? "")}`, ); @@ -683,6 +720,12 @@ export class ClaudeQuerySession { } traceAdapter(`consume_done session=${this.sessionId}`); } catch (error) { + if (query !== this.query) { + traceAdapter( + `consume_replaced_error session=${this.sessionId} error=${formatError(error)}`, + ); + return; + } traceAdapter( `consume_error session=${this.sessionId} error=${formatError(error)}`, ); @@ -695,6 +738,7 @@ export class ClaudeQuerySession { } } finally { traceAdapter(`consume_finally session=${this.sessionId}`); + if (query !== this.query) return; // The reader loop has exited — the SDK query stream is done (cleanly or // via error), so this session can never produce another result. Mark it // closed so a subsequent prompt() fails fast via the guard in prompt() @@ -752,7 +796,7 @@ export class ClaudeQuerySession { } private async handleStreamEvent(message: Record): Promise { - if (!this.pendingTurn) return; + if (!this.pendingTurn || this.cancelled) return; const event = (message.event ?? {}) as Record; const type = String(event.type ?? ""); @@ -829,7 +873,7 @@ export class ClaudeQuerySession { private async handleAssistantMessage( message: Record, ): Promise { - if (!this.pendingTurn) return; + if (!this.pendingTurn || this.cancelled) return; const content = Array.isArray((message.message as Record)?.content) ? (((message.message as Record).content ?? @@ -867,7 +911,7 @@ export class ClaudeQuerySession { private async handleToolProgress( message: Record, ): Promise { - if (!this.pendingTurn) return; + if (!this.pendingTurn || this.cancelled) return; const toolUseId = String(message.tool_use_id ?? ""); const toolName = String(message.tool_name ?? "tool"); @@ -887,7 +931,7 @@ export class ClaudeQuerySession { private async handleSystemMessage( message: Record, ): Promise { - if (!this.pendingTurn) return; + if (!this.pendingTurn || this.cancelled) return; if (message.subtype === "local_command_output") { await this.emitText(String(message.content ?? "")); @@ -901,7 +945,7 @@ export class ClaudeQuerySession { const subtype = String(message.subtype ?? "success"); const resultText = typeof message.result === "string" ? message.result : undefined; - if (resultText && !turn.sawAssistantText) { + if (resultText && !turn.sawAssistantText && !this.cancelled) { await this.emitText(resultText); } @@ -1025,7 +1069,8 @@ export class ClaudeQuerySession { } } -class ClaudeSdkAgent implements Agent { +// Exported for focused protocol/lifecycle tests; not a package public API. +export class ClaudeSdkAgent implements Agent { private sessions = new Map(); constructor(private readonly conn: AgentSideConnection) { @@ -1050,6 +1095,7 @@ class ClaudeSdkAgent implements Agent { version: "0.1.0", }, agentCapabilities: { + sessionCapabilities: { close: {} }, promptCapabilities: { audio: false, embeddedContext: false, @@ -1095,6 +1141,16 @@ class ClaudeSdkAgent implements Agent { await session.cancel(); } + async closeSession( + params: CloseSessionRequest, + ): Promise { + const session = this.requireSession(params.sessionId); + await session.cancel(); + session.close(); + this.sessions.delete(params.sessionId); + return {}; + } + async setSessionMode( params: SetSessionModeRequest, ): Promise { diff --git a/registry/agent/claude/tests/adapter.test.mjs b/registry/agent/claude/tests/adapter.test.mjs index 80855ac989..9c82495be8 100644 --- a/registry/agent/claude/tests/adapter.test.mjs +++ b/registry/agent/claude/tests/adapter.test.mjs @@ -14,10 +14,30 @@ after(() => { // drive the translation/permission/teardown logic with a fake Claude SDK query // and a mock ACP connection — no real SDK, no VM. const packageDir = resolvePath(import.meta.dirname, ".."); -const { ClaudeQuerySession } = await import( +const { ClaudeQuerySession, ClaudeSdkAgent } = await import( resolvePath(packageDir, "dist", "adapter.js") ); +test("claude advertises and implements stable session/close", async () => { + const agent = new ClaudeSdkAgent(makeConn()); + const capabilities = await agent.initialize({}); + assert.deepEqual(capabilities.agentCapabilities.sessionCapabilities.close, {}); + let cancelled = false; + let closed = false; + agent.sessions.set("s1", { + cancel: async () => { + cancelled = true; + }, + close: () => { + closed = true; + }, + }); + assert.deepEqual(await agent.closeSession({ sessionId: "s1" }), {}); + assert.ok(cancelled); + assert.ok(closed); + assert.equal(agent.sessions.has("s1"), false); +}); + function makeConn(overrides = {}) { let closeConn; const closed = new Promise((r) => { @@ -46,8 +66,9 @@ function makeQuery({ endImmediately = false } = {}) { if (!endImmediately) cleanups.push(stop); return { setMcpServers: async () => {}, - interrupt: async () => {}, + interrupt: async () => stop(), setPermissionMode: async () => {}, + close: () => stop(), async *[Symbol.asyncIterator]() { if (endImmediately) return; await stopped; // ends consume() when drained in `after` @@ -58,8 +79,10 @@ function makeQuery({ endImmediately = false } = {}) { function makeSession({ endImmediately = false, conn } = {}) { const c = conn ?? makeConn(); let capturedOptions; + let queryCount = 0; const queryFactory = (arg) => { capturedOptions = arg.options; + queryCount += 1; return makeQuery({ endImmediately }); }; const sess = new ClaudeQuerySession( @@ -71,7 +94,12 @@ function makeSession({ endImmediately = false, conn } = {}) { "/usr/bin/claude", queryFactory, ); - return { sess, conn: c, getOptions: () => capturedOptions }; + return { + sess, + conn: c, + getOptions: () => capturedOptions, + getQueryCount: () => queryCount, + }; } // ── Fix #5: input_json_delta maps to the correct tool by content-block index ── @@ -201,3 +229,32 @@ test("claude #4: once the query stream ends, prompt() fails fast instead of hang "a prompt on a dead session must reject promptly, not hang", ); }); + +test("claude cancellation rotates the SDK query and accepts a new turn", async () => { + const { sess, conn, getQueryCount } = makeSession(); + let response; + sess.pendingTurn = { + sawAssistantText: false, + sawToolCall: false, + resolve(value) { + response = value; + }, + reject(error) { + throw error; + }, + }; + await sess.cancel(); + assert.deepEqual(response, { stopReason: "cancelled" }); + assert.equal(getQueryCount(), 2, "cancel must replace the busy SDK query"); + assert.equal( + conn.updates.some((update) => + JSON.stringify(update).includes("late text from the cancelled turn"), + ), + false, + "cancelled terminal output must not be projected as an assistant reply", + ); + const next = sess.prompt({ prompt: [{ type: "text", text: "next" }] }); + await new Promise((resolve) => setImmediate(resolve)); + await sess.handleResult({ type: "result", subtype: "success", result: "next" }); + assert.deepEqual(await next, { stopReason: "end_turn" }); +}); diff --git a/registry/agent/pi/package.json b/registry/agent/pi/package.json index 40d41edbb5..077dbb1f72 100644 --- a/registry/agent/pi/package.json +++ b/registry/agent/pi/package.json @@ -29,7 +29,7 @@ "test": "pnpm build && node --test --test-force-exit tests/*.test.mjs" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^1.2.1", "@mariozechner/pi-coding-agent": "0.60.0", "@mariozechner/pi-ai": "0.60.0" }, diff --git a/registry/agent/pi/src/adapter.ts b/registry/agent/pi/src/adapter.ts index 1eea462bdb..3278480996 100644 --- a/registry/agent/pi/src/adapter.ts +++ b/registry/agent/pi/src/adapter.ts @@ -22,14 +22,19 @@ import type { AuthenticateRequest, AuthenticateResponse, CancelNotification, + CloseSessionRequest, + CloseSessionResponse, InitializeRequest, InitializeResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SessionConfigOption, SetSessionModeRequest, SetSessionModeResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, SessionNotification, } from "@agentclientprotocol/sdk"; import type { @@ -132,6 +137,7 @@ export function resolveSessionManager( type ModelLike = { id: string; provider: string; + name?: string; baseUrl?: string; reasoning?: boolean; }; @@ -206,7 +212,7 @@ type SettingsManagerInstanceLike = { type ModelRegistryInstanceLike = { find(provider: string, modelId: string): ModelLike | undefined; - getAvailable(): Promise; + getAll(): ModelLike[]; getApiKey(model: ModelLike): Promise; getApiKeyForProvider(provider: string): Promise; isUsingOAuth(model: ModelLike): boolean; @@ -254,6 +260,12 @@ type PiSessionLike = { setThinkingLevel(level: string): void; }; +type ModelSelectablePiSession = PiSessionLike & { + readonly model?: ModelLike; + readonly modelRegistry: ModelRegistryInstanceLike; + setModel(model: ModelLike): Promise; +}; + type PiSdkRuntime = { Agent: PiAgentCoreLike; AuthStorage: { @@ -270,7 +282,7 @@ type PiSdkRuntime = { DEFAULT_THINKING_LEVEL: string; ModelRegistry: new (authStorage: unknown, modelsPath?: string) => { find(provider: string, modelId: string): ModelLike | undefined; - getAvailable(): Promise; + getAll(): ModelLike[]; getApiKey(model: ModelLike): Promise; getApiKeyForProvider(provider: string): Promise; isUsingOAuth(model: ModelLike): boolean; @@ -842,6 +854,7 @@ export class PiSdkAgent implements Agent { version: "0.1.0", }, agentCapabilities: { + sessionCapabilities: { close: {} }, promptCapabilities: { image: true, audio: false, @@ -941,6 +954,7 @@ export class PiSdkAgent implements Agent { return { sessionId: this.sessionId, modes, + configOptions: await this.modelConfigOptions(), }; } @@ -1008,6 +1022,18 @@ export class PiSdkAgent implements Agent { await this.session?.abort(); } + async closeSession( + params: CloseSessionRequest, + ): Promise { + if (!this.session || params.sessionId !== this.sessionId) { + throw new Error(`Unknown Pi session: ${params.sessionId}`); + } + await this.cancel({ sessionId: params.sessionId }); + this.dispose(); + this.sessionId = ""; + return {}; + } + async setSessionMode( params: SetSessionModeRequest, ): Promise { @@ -1023,6 +1049,65 @@ export class PiSdkAgent implements Agent { }); } + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + if (params.configId !== "model" || typeof params.value !== "string") { + throw new Error(`Unsupported Pi config option: ${params.configId}`); + } + const session = this.modelSession(); + const separator = params.value.indexOf("/"); + if (separator <= 0 || separator === params.value.length - 1) { + throw new Error(`Invalid Pi model id: ${params.value}`); + } + const provider = params.value.slice(0, separator); + const modelId = params.value.slice(separator + 1); + const model = session.modelRegistry.find(provider, modelId); + if (!model) throw new Error(`Unknown Pi model: ${params.value}`); + await session.setModel(model); + const configOptions = await this.modelConfigOptions(); + await this.emit({ + sessionUpdate: "config_option_update", + configOptions, + }); + return { configOptions }; + } + + private modelSession(): ModelSelectablePiSession { + if (!this.session) throw new Error("No session created"); + return this.session as ModelSelectablePiSession; + } + + private async modelConfigOptions(): Promise { + const session = this.modelSession(); + const models = session.modelRegistry.getAll(); + if (models.length === 0) return []; + const grouped = new Map(); + for (const model of models) { + const values = grouped.get(model.provider) ?? []; + values.push(model); + grouped.set(model.provider, values); + } + const current = session.model ?? models[0]; + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: `${current.provider}/${current.id}`, + options: [...grouped].map(([provider, values]) => ({ + group: provider, + name: provider, + options: values.map((model) => ({ + value: `${model.provider}/${model.id}`, + name: model.name ?? model.id, + })), + })), + }, + ]; + } + async authenticate( _params: AuthenticateRequest, ): Promise { diff --git a/registry/agent/pi/tests/adapter.test.mjs b/registry/agent/pi/tests/adapter.test.mjs index e52c4d9185..66fbb8286e 100644 --- a/registry/agent/pi/tests/adapter.test.mjs +++ b/registry/agent/pi/tests/adapter.test.mjs @@ -26,6 +26,18 @@ function fakeSession(overrides = {}) { }; } +test("pi advertises and implements stable session/close", async () => { + let aborted = false; + const agent = new PiSdkAgent(makeConn()); + const capabilities = await agent.initialize({}); + assert.deepEqual(capabilities.agentCapabilities.sessionCapabilities.close, {}); + agent.sessionId = "s1"; + agent.session = fakeSession({ abort: async () => (aborted = true) }); + assert.deepEqual(await agent.closeSession({ sessionId: "s1" }), {}); + assert.ok(aborted); + assert.equal(agent.session, null); +}); + // ── Fix #3: editSnapshots is cleared each turn (no unbounded leak) ── test("pi #3: prompt() clears leaked edit snapshots from a prior aborted turn", async () => { const agent = new PiSdkAgent(makeConn()); diff --git a/website/public/docs/docs/filesystem.md b/website/public/docs/docs/filesystem.md index de857aa2d1..235bd23db2 100644 --- a/website/public/docs/docs/filesystem.md +++ b/website/public/docs/docs/filesystem.md @@ -24,6 +24,18 @@ Use `mountFs()` for a callback-backed JS filesystem driver. The driver must live The native `agentOS()` actor cannot accept `{ driver }` mounts in config because JS callback objects are not serializable across the native plugin boundary. Use `plugin` mounts there. +### Per-actor options + +Native `agentOS()` actors also accept durable Rivet actor creation input. Pass +`createWithInput: { options: ... }` to `getOrCreate`, or `input: { options: ... }` +to `create`, to vary mounts, additional instructions, permissions, loopback +exemptions, allowed Node built-ins, root filesystem, or limits for that actor. +The public input type is `AgentOsActorCreateInput`. + +Creation input follows normal actor semantics: it is applied only when the actor +is first created, persists with the actor, and is applied again when that actor +wakes. Software packages and sidecar pool selection remain definition-wide. + ## File operations These operations are primarily what the agent uses inside the VM, and are also available from the client to seed inputs and read results. For large or read-only inputs (a repo, a dataset), a read-only [host mount](#mounts) is faster than copying files in. Programs that need stdin or live output use exec instead (see [Core](/docs/core)). @@ -67,4 +79,4 @@ With no `mounts` configured, every VM boots an Alpine-based root filesystem with - `/bin`, `/sbin`, `/usr`: installed commands (common POSIX utilities by default, plus any [software](/docs/software) you add). - `/etc`, `/lib`, `/opt`, `/root`, `/run`, `/srv`, `/tmp`, `/var`, `/mnt`: standard system paths. -It is backed by the VM's own filesystem and persisted across sleep/wake. Nothing comes from or touches the host disk. \ No newline at end of file +It is backed by the VM's own filesystem and persisted across sleep/wake. Nothing comes from or touches the host disk. diff --git a/website/public/docs/docs/processes.md b/website/public/docs/docs/processes.md index 08108ad6af..745d855bfd 100644 --- a/website/public/docs/docs/processes.md +++ b/website/public/docs/docs/processes.md @@ -20,4 +20,11 @@ Send input to a running process. ## Interactive shells -Open an interactive shell with PTY support. Shell data is streamed via `shellData` events. \ No newline at end of file +Open an interactive shell with PTY support. Shell data is streamed via `shellData` events. + +### Attach a shell to a Node terminal + +The Node-specific `@rivet-dev/agentos/node` entrypoint attaches an actor's PTY +shell to the current process terminal. It forwards stdin and output, enables raw +mode, propagates terminal resizes, restores terminal state, and returns the guest +exit code. diff --git a/website/src/content/docs/docs/processes.mdx b/website/src/content/docs/docs/processes.mdx index 7c6bacf057..1927dbf68b 100644 --- a/website/src/content/docs/docs/processes.mdx +++ b/website/src/content/docs/docs/processes.mdx @@ -49,3 +49,14 @@ Open an interactive shell with PTY support. Shell data is streamed via `shellDat +### Attach a shell to a Node terminal + +The Node-specific `@rivet-dev/agentos/node` entrypoint attaches an actor's PTY +shell to the current process terminal. It forwards stdin and output, enables raw +mode, propagates terminal resizes, restores terminal state, and returns the guest +exit code. + + + + +