This commit is contained in:
NathanFlurry 2026-02-11 14:47:41 +00:00
parent 70287ec471
commit e72eb9f611
No known key found for this signature in database
GPG key ID: 6A5F43A4F3241BCA
264 changed files with 18559 additions and 51021 deletions

View file

@ -0,0 +1,205 @@
# Feature 1: Questions Subsystem
**Implementation approach:** ACP extension (`_sandboxagent/session/request_question`)
## Summary
v1 had a full question subsystem: agent requests a question from the user, client replies with an answer or rejection, and the system tracks question status. v2 has partial stub implementation in mock only.
## Current v2 State
- `_sandboxagent/session/request_question` is declared as a constant in `acp_runtime/mod.rs:33`
- Advertised in capability injection (`extensions.sessionRequestQuestion: true`)
- **Mock agent** (`acp_runtime/mock.rs:174-203`) emits questions when prompt contains "question"
- **No real agent handler** in the runtime for routing question requests/responses between real agent processes and clients
- Mock response handling exists (`mock.rs:377-415`) but the runtime lacks the general forwarding path
## v1 Types (exact, from `universal-agent-schema/src/lib.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct QuestionEventData {
pub question_id: String,
pub prompt: String,
pub options: Vec<String>,
pub response: Option<String>,
pub status: QuestionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum QuestionStatus {
Requested,
Answered,
Rejected,
}
```
## v1 HTTP Types (exact, from `router.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct QuestionReplyRequest {
pub answers: Vec<Vec<String>>,
}
#[derive(Debug, Clone)]
pub(crate) struct PendingQuestionInfo {
pub session_id: String,
pub question_id: String,
pub prompt: String,
pub options: Vec<String>,
}
#[derive(Debug, Clone)]
struct PendingQuestion {
prompt: String,
options: Vec<String>,
}
```
## v1 HTTP Endpoints (from `router.rs`)
```
POST /v1/sessions/{session_id}/questions/{question_id}/reply -> 204 No Content
POST /v1/sessions/{session_id}/questions/{question_id}/reject -> 204 No Content
```
### `reply_question` handler
```rust
async fn reply_question(
State(state): State<Arc<AppState>>,
Path((session_id, question_id)): Path<(String, String)>,
Json(request): Json<QuestionReplyRequest>,
) -> Result<StatusCode, ApiError> {
state.session_manager
.reply_question(&session_id, &question_id, request.answers)
.await?;
Ok(StatusCode::NO_CONTENT)
}
```
### `reject_question` handler
```rust
async fn reject_question(
State(state): State<Arc<AppState>>,
Path((session_id, question_id)): Path<(String, String)>,
) -> Result<StatusCode, ApiError> {
state.session_manager
.reject_question(&session_id, &question_id)
.await?;
Ok(StatusCode::NO_CONTENT)
}
```
## v1 SessionManager Methods (exact)
### `reply_question`
Key flow:
1. Look up session, take pending question by `question_id`
2. Extract first answer: `answers.first().and_then(|inner| inner.first()).cloned()`
3. Per-agent forwarding:
- **OpenCode**: `opencode_question_reply(&agent_session_id, question_id, answers)`
- **Claude**: If linked to a permission (AskUserQuestion/ExitPlanMode), send `claude_control_response_line` with `"allow"` and `updatedInput`; otherwise send `claude_tool_result_line`
- Others: TODO
4. Emit `QuestionResolved` event with `status: Answered`
### `reject_question`
Key flow:
1. Look up session, take pending question
2. Per-agent forwarding:
- **OpenCode**: `opencode_question_reject(&agent_session_id, question_id)`
- **Claude**: If linked to permission, send `claude_control_response_line` with `"deny"`; otherwise send `claude_tool_result_line` with `is_error: true`
- Others: TODO
3. Emit `QuestionResolved` event with `status: Rejected`
## v1 Event Flow
1. Agent emits `question.requested` event with `QuestionEventData { status: Requested, question_id, prompt, options }`
2. Client renders question UI
3. Client calls `POST /v1/sessions/{id}/questions/{qid}/reply` with `{ answers: [["selected"]] }` or `POST .../reject`
4. System emits `question.resolved` event with `QuestionEventData { status: Answered, response: Some("...") }` or `{ status: Rejected }`
## v1 Agent Capability
```rust
AgentId::Claude => AgentCapabilities { questions: true, ... },
AgentId::Codex => AgentCapabilities { questions: false, ... },
AgentId::Opencode => AgentCapabilities { questions: false, ... },
AgentId::Amp => AgentCapabilities { questions: false, ... },
AgentId::Mock => AgentCapabilities { questions: true, ... },
```
## Implementation Plan
### ACP Extension Design
The question flow maps to ACP's bidirectional request/response:
1. **Agent -> Runtime:** Agent process sends `_sandboxagent/session/request_question` as a JSON-RPC request
2. **Runtime -> Client:** Runtime forwards as a client-directed request in the SSE stream
3. **Client -> Runtime:** Client POSTs a JSON-RPC response (answered/rejected)
4. **Runtime -> Agent:** Runtime forwards the response back to the agent process stdin
### Payload Shape
Agent request:
```json
{
"jsonrpc": "2.0",
"id": "q-1",
"method": "_sandboxagent/session/request_question",
"params": {
"sessionId": "...",
"questionId": "uuid",
"prompt": "Which option?",
"options": [["option-a", "Option A"], ["option-b", "Option B"]]
}
}
```
Client response (answered):
```json
{
"jsonrpc": "2.0",
"id": "q-1",
"result": {
"status": "answered",
"answers": [["option-a"]]
}
}
```
Client response (rejected):
```json
{
"jsonrpc": "2.0",
"id": "q-1",
"result": {
"status": "rejected"
}
}
```
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add real request/response forwarding for `_sandboxagent/session/request_question` (currently only mock) |
| `server/packages/sandbox-agent/src/acp_runtime/mock.rs` | Already has mock implementation; verify alignment with final payload shape |
| `sdks/typescript/src/client.ts` | Add `onQuestion()` callback and `replyQuestion()` / `rejectQuestion()` methods |
| `frontend/packages/inspector/` | Add question rendering in inspector UI |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | N/A (ACP extension, not HTTP endpoint) |
| `docs/sdks/typescript.mdx` | Document `onQuestion` / `replyQuestion` / `rejectQuestion` SDK methods |
| `docs/inspector.mdx` | Document question rendering in inspector |
| `research/acp/spec.md` | Update extension methods list |

View file

@ -0,0 +1,387 @@
# Feature 4: Filesystem API
**Implementation approach:** Custom HTTP endpoints (not ACP), per CLAUDE.md
## Summary
v1 had 8 filesystem endpoints. v2 has only ACP `fs/read_text_file` + `fs/write_text_file` (text-only, agent->client direction). The full filesystem API should be re-implemented as Sandbox Agent-specific HTTP contracts at `/v2/fs/*`.
## Current v2 State
- ACP stable: `fs/read_text_file`, `fs/write_text_file` (client methods invoked by agents, text-only)
- No HTTP filesystem endpoints exist in current `router.rs`
- `rfds-vs-extensions.md` confirms: "Already extension (`/v2/fs/*` custom HTTP surface)"
- CLAUDE.md: "Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP"
## v1 Reference (source commit)
Port behavior from commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836`.
## v1 Endpoints (from `router.rs`)
| Method | Path | Handler | Description |
|--------|------|---------|-------------|
| GET | `/v1/fs/entries` | `fs_entries` | List directory entries |
| GET | `/v1/fs/file` | `fs_read_file` | Read file raw bytes |
| PUT | `/v1/fs/file` | `fs_write_file` | Write file raw bytes |
| DELETE | `/v1/fs/entry` | `fs_delete_entry` | Delete file or directory |
| POST | `/v1/fs/mkdir` | `fs_mkdir` | Create directory |
| POST | `/v1/fs/move` | `fs_move` | Move/rename file or directory |
| GET | `/v1/fs/stat` | `fs_stat` | Get file/directory metadata |
| POST | `/v1/fs/upload-batch` | `fs_upload_batch` | Upload tar archive |
## v1 Types (exact, from `router.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsPathQuery {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsEntriesQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsSessionQuery {
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsDeleteQuery {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsUploadBatchQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "session_id")]
pub session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum FsEntryType {
File,
Directory,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsEntry {
pub name: String,
pub path: String,
pub entry_type: FsEntryType,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsStat {
pub path: String,
pub entry_type: FsEntryType,
pub size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsWriteResponse {
pub path: String,
pub bytes_written: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsMoveRequest {
pub from: String,
pub to: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overwrite: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsMoveResponse {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsActionResponse {
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct FsUploadBatchResponse {
pub paths: Vec<String>,
pub truncated: bool,
}
```
## v1 Handler Implementations (exact, from `router.rs`)
### `fs_entries` (GET /v1/fs/entries)
```rust
async fn fs_entries(
State(state): State<Arc<AppState>>,
Query(query): Query<FsEntriesQuery>,
) -> Result<Json<Vec<FsEntry>>, ApiError> {
let path = query.path.unwrap_or_else(|| ".".to_string());
let target = resolve_fs_path(&state, query.session_id.as_deref(), &path).await?;
let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?;
if !metadata.is_dir() {
return Err(SandboxError::InvalidRequest {
message: format!("path is not a directory: {}", target.display()),
}.into());
}
let mut entries = Vec::new();
for entry in fs::read_dir(&target).map_err(|err| map_fs_error(&target, err))? {
let entry = entry.map_err(|err| SandboxError::StreamError { message: err.to_string() })?;
let path = entry.path();
let metadata = entry.metadata().map_err(|err| SandboxError::StreamError { message: err.to_string() })?;
let entry_type = if metadata.is_dir() { FsEntryType::Directory } else { FsEntryType::File };
let modified = metadata.modified().ok().and_then(|time| {
chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339().into()
});
entries.push(FsEntry {
name: entry.file_name().to_string_lossy().to_string(),
path: path.to_string_lossy().to_string(),
entry_type, size: metadata.len(), modified,
});
}
Ok(Json(entries))
}
```
### `fs_read_file` (GET /v1/fs/file)
```rust
async fn fs_read_file(
State(state): State<Arc<AppState>>,
Query(query): Query<FsPathQuery>,
) -> Result<Response, ApiError> {
let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?;
let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?;
if !metadata.is_file() {
return Err(SandboxError::InvalidRequest {
message: format!("path is not a file: {}", target.display()),
}.into());
}
let bytes = fs::read(&target).map_err(|err| map_fs_error(&target, err))?;
Ok(([(header::CONTENT_TYPE, "application/octet-stream")], Bytes::from(bytes)).into_response())
}
```
### `fs_write_file` (PUT /v1/fs/file)
```rust
async fn fs_write_file(
State(state): State<Arc<AppState>>,
Query(query): Query<FsPathQuery>,
body: Bytes,
) -> Result<Json<FsWriteResponse>, ApiError> {
let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|err| map_fs_error(parent, err))?;
}
fs::write(&target, &body).map_err(|err| map_fs_error(&target, err))?;
Ok(Json(FsWriteResponse {
path: target.to_string_lossy().to_string(),
bytes_written: body.len() as u64,
}))
}
```
### `fs_delete_entry` (DELETE /v1/fs/entry)
```rust
async fn fs_delete_entry(
State(state): State<Arc<AppState>>,
Query(query): Query<FsDeleteQuery>,
) -> Result<Json<FsActionResponse>, ApiError> {
let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?;
let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?;
if metadata.is_dir() {
if query.recursive.unwrap_or(false) {
fs::remove_dir_all(&target).map_err(|err| map_fs_error(&target, err))?;
} else {
fs::remove_dir(&target).map_err(|err| map_fs_error(&target, err))?;
}
} else {
fs::remove_file(&target).map_err(|err| map_fs_error(&target, err))?;
}
Ok(Json(FsActionResponse { path: target.to_string_lossy().to_string() }))
}
```
### `fs_mkdir` (POST /v1/fs/mkdir)
```rust
async fn fs_mkdir(
State(state): State<Arc<AppState>>,
Query(query): Query<FsPathQuery>,
) -> Result<Json<FsActionResponse>, ApiError> {
let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?;
fs::create_dir_all(&target).map_err(|err| map_fs_error(&target, err))?;
Ok(Json(FsActionResponse { path: target.to_string_lossy().to_string() }))
}
```
### `fs_move` (POST /v1/fs/move)
```rust
async fn fs_move(
State(state): State<Arc<AppState>>,
Query(query): Query<FsSessionQuery>,
Json(request): Json<FsMoveRequest>,
) -> Result<Json<FsMoveResponse>, ApiError> {
let session_id = query.session_id.as_deref();
let from = resolve_fs_path(&state, session_id, &request.from).await?;
let to = resolve_fs_path(&state, session_id, &request.to).await?;
if to.exists() {
if request.overwrite.unwrap_or(false) {
let metadata = fs::metadata(&to).map_err(|err| map_fs_error(&to, err))?;
if metadata.is_dir() {
fs::remove_dir_all(&to).map_err(|err| map_fs_error(&to, err))?;
} else {
fs::remove_file(&to).map_err(|err| map_fs_error(&to, err))?;
}
} else {
return Err(SandboxError::InvalidRequest {
message: format!("destination already exists: {}", to.display()),
}.into());
}
}
if let Some(parent) = to.parent() {
fs::create_dir_all(parent).map_err(|err| map_fs_error(parent, err))?;
}
fs::rename(&from, &to).map_err(|err| map_fs_error(&from, err))?;
Ok(Json(FsMoveResponse {
from: from.to_string_lossy().to_string(),
to: to.to_string_lossy().to_string(),
}))
}
```
### `fs_stat` (GET /v1/fs/stat)
```rust
async fn fs_stat(
State(state): State<Arc<AppState>>,
Query(query): Query<FsPathQuery>,
) -> Result<Json<FsStat>, ApiError> {
let target = resolve_fs_path(&state, query.session_id.as_deref(), &query.path).await?;
let metadata = fs::metadata(&target).map_err(|err| map_fs_error(&target, err))?;
let entry_type = if metadata.is_dir() { FsEntryType::Directory } else { FsEntryType::File };
let modified = metadata.modified().ok().and_then(|time| {
chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339().into()
});
Ok(Json(FsStat {
path: target.to_string_lossy().to_string(),
entry_type, size: metadata.len(), modified,
}))
}
```
### `fs_upload_batch` (POST /v1/fs/upload-batch)
```rust
async fn fs_upload_batch(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Query(query): Query<FsUploadBatchQuery>,
body: Bytes,
) -> Result<Json<FsUploadBatchResponse>, ApiError> {
let content_type = headers.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok()).unwrap_or_default();
if !content_type.starts_with("application/x-tar") {
return Err(SandboxError::InvalidRequest {
message: "content-type must be application/x-tar".to_string(),
}.into());
}
let path = query.path.unwrap_or_else(|| ".".to_string());
let base = resolve_fs_path(&state, query.session_id.as_deref(), &path).await?;
fs::create_dir_all(&base).map_err(|err| map_fs_error(&base, err))?;
let mut archive = Archive::new(Cursor::new(body));
let mut extracted = Vec::new();
let mut truncated = false;
for entry in archive.entries().map_err(|err| SandboxError::StreamError { message: err.to_string() })? {
let mut entry = entry.map_err(|err| SandboxError::StreamError { message: err.to_string() })?;
let entry_path = entry.path().map_err(|err| SandboxError::StreamError { message: err.to_string() })?;
let clean_path = sanitize_relative_path(&entry_path)?;
if clean_path.as_os_str().is_empty() { continue; }
let dest = base.join(&clean_path);
if !dest.starts_with(&base) {
return Err(SandboxError::InvalidRequest {
message: format!("tar entry escapes destination: {}", entry_path.display()),
}.into());
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|err| map_fs_error(parent, err))?;
}
entry.unpack(&dest).map_err(|err| SandboxError::StreamError { message: err.to_string() })?;
if extracted.len() < 1024 {
extracted.push(dest.to_string_lossy().to_string());
} else { truncated = true; }
}
Ok(Json(FsUploadBatchResponse { paths: extracted, truncated }))
}
```
## Implementation Plan
### New v2 Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | `/v2/fs/entries` | List directory entries |
| GET | `/v2/fs/file` | Read file raw bytes |
| PUT | `/v2/fs/file` | Write file raw bytes |
| DELETE | `/v2/fs/entry` | Delete file or directory |
| POST | `/v2/fs/mkdir` | Create directory |
| POST | `/v2/fs/move` | Move/rename |
| GET | `/v2/fs/stat` | File metadata |
| POST | `/v2/fs/upload-batch` | Upload tar archive |
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/router.rs` | Add all 8 `/v2/fs/*` endpoints with handlers (port from v1 with v2 path prefix) |
| `server/packages/sandbox-agent/src/cli.rs` | Add CLI `fs` subcommands (list, read, write, delete, mkdir, move, stat) |
| `sdks/typescript/src/client.ts` | Add filesystem methods to SDK |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add filesystem endpoint tests |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | Add `/v2/fs/*` endpoint specs |
| `docs/cli.mdx` | Add `fs` subcommand documentation |
| `docs/sdks/typescript.mdx` | Document filesystem SDK methods |

View file

@ -0,0 +1,90 @@
# Feature 5: Health Endpoint
**Implementation approach:** Enhance existing `GET /v2/health`
## Summary
v1 had a typed `HealthResponse` with detailed status. v2 `GET /v2/health` exists but returns only `{ status: "ok", api_version: "v2" }`. Needs enrichment.
## Current v2 State
From `router.rs:332-346`:
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct HealthResponse {
pub status: String,
pub api_version: String,
}
async fn get_v2_health() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".to_string(),
api_version: "v2".to_string(),
})
}
```
## v1 Reference (source commit)
Port behavior from commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836`.
## v1 Health Response
v1 returned a richer health response:
```rust
#[derive(Debug, Serialize, JsonSchema, ToSchema)]
pub struct HealthResponse {
pub status: HealthStatus,
pub version: String,
pub uptime_ms: u64,
pub agents: Vec<AgentHealthInfo>,
}
#[derive(Debug, Serialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum HealthStatus {
Healthy,
Degraded,
Unhealthy,
}
#[derive(Debug, Serialize, JsonSchema, ToSchema)]
pub struct AgentHealthInfo {
pub agent: String,
pub installed: bool,
pub running: bool,
}
```
## Implementation Plan
### v1-Parity HealthResponse
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct HealthResponse {
pub status: HealthStatus,
pub version: String,
pub uptime_ms: u64,
pub agents: Vec<AgentHealthInfo>,
}
```
`GET /v2/health` should mirror v1 semantics and response shape (ported from commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836`), while keeping the v2 route path.
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/router.rs` | Port v1 health response types/logic onto `GET /v2/health` |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Update health endpoint test for full v1-parity payload |
| `sdks/typescript/src/client.ts` | Update `HealthResponse` type |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | Update `/v2/health` response schema |
| `docs/sdks/typescript.mdx` | Document enriched health response |

View file

@ -0,0 +1,144 @@
# Feature 6: Server Status
**Implementation approach:** Extension fields on `GET /v2/agents` and `GET /v2/health`
## Summary
v1 had `ServerStatus` (Running/Stopped/Error) and `ServerStatusInfo` (baseUrl, lastError, restartCount, uptimeMs) per agent. v2 has none of this. Add server/agent process status tracking.
## Current v2 State
`GET /v2/agents` returns `AgentInfo` with install state only:
```rust
pub struct AgentInfo {
pub id: String,
pub native_required: bool,
pub native_installed: bool,
pub native_version: Option<String>,
pub agent_process_installed: bool,
pub agent_process_source: Option<String>,
pub agent_process_version: Option<String>,
pub capabilities: AgentCapabilities,
}
```
No runtime status (running/stopped/error), no error tracking, no restart counts.
## v1 Types (exact, from `router.rs`)
```rust
/// Status of a shared server process for an agent
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ServerStatus {
/// Server is running and accepting requests
Running,
/// Server is not currently running
Stopped,
/// Server is running but unhealthy
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ServerStatusInfo {
pub status: ServerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uptime_ms: Option<u64>,
pub restart_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
```
## v1 Implementation (exact)
### `ManagedServer::status_info`
```rust
fn status_info(&self) -> ServerStatusInfo {
let uptime_ms = self.start_time
.map(|started| started.elapsed().as_millis() as u64);
ServerStatusInfo {
status: self.status.clone(),
base_url: self.base_url(),
uptime_ms,
restart_count: self.restart_count,
last_error: self.last_error.clone(),
}
}
```
### `AgentServerManager::status_snapshot`
```rust
async fn status_snapshot(&self) -> HashMap<AgentId, ServerStatusInfo> {
let servers = self.servers.lock().await;
servers.iter()
.map(|(agent, server)| (*agent, server.status_info()))
.collect()
}
```
### `AgentServerManager::update_server_error`
```rust
async fn update_server_error(&self, agent: AgentId, message: String) {
let mut servers = self.servers.lock().await;
if let Some(server) = servers.get_mut(&agent) {
server.status = ServerStatus::Error;
server.start_time = None;
server.last_error = Some(message);
}
}
```
## Implementation Plan
### ACP Runtime Tracking
The `AcpRuntime` needs to track per-agent backend process:
```rust
struct AgentProcessStatus {
status: String, // "running" | "stopped" | "error"
start_time: Option<Instant>,
restart_count: u64,
last_error: Option<String>,
}
```
Track:
- Process start → set status to "running", record `start_time`, increment `restart_count`
- Process exit (normal) → set status to "stopped", clear `start_time`
- Process exit (error) → set status to "error", record `last_error`, clear `start_time`
### Add to AgentInfo
```rust
pub struct AgentInfo {
// ... existing fields ...
pub server_status: Option<ServerStatusInfo>,
}
```
Only include `server_status` for agents that use shared processes (Codex, OpenCode).
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Track agent process lifecycle (start/stop/error/restart count) per `AgentId`; expose `status_snapshot()` method |
| `server/packages/sandbox-agent/src/router.rs` | Add `ServerStatus`, `ServerStatusInfo` types; add `server_status` to `AgentInfo`; query runtime for status in `get_v2_agents` |
| `sdks/typescript/src/client.ts` | Update `AgentInfo` type with `serverStatus` |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Test server status in agent listing |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | Update `/v2/agents` response with `server_status` |
| `docs/sdks/typescript.mdx` | Document `serverStatus` field |

View file

@ -0,0 +1,123 @@
# Feature 7: Session Termination
**Implementation approach:** ACP extension, referencing existing ACP RFD
## Summary
v1 had explicit session termination (`POST /v1/sessions/{id}/terminate`). v2 only has `session/cancel` (turn cancellation, not session kill) and `DELETE /v2/rpc` (connection close, not session termination). Need explicit session destroy/terminate semantics.
## Current v2 State
- `session/cancel` — cancels an in-flight prompt turn only
- `DELETE /v2/rpc` — closes the HTTP connection, does **not** terminate the session
- `_sandboxagent/session/detach` — detaches a session from a connection (multi-client visibility)
- No session termination/deletion exists
- `rfds-vs-extensions.md`: "Session Termination: Not covered by ACP. Only implement if product explicitly requires termination semantics beyond session/cancel"
- `extensibility-status.md`: Documents `_sandboxagent/session/terminate` as proposed but not implemented
## v1 Implementation
### HTTP Endpoint
```
POST /v1/sessions/{id}/terminate
```
### Handler (from `router.rs`)
The terminate handler:
1. Looked up the session by ID
2. Killed the agent subprocess (SIGTERM then SIGKILL after grace period)
3. Emitted a `session.ended` event with `reason: Terminated, terminated_by: Daemon`
4. Cleaned up session state
### v1 Types
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct SessionEndedData {
pub reason: SessionEndReason,
pub terminated_by: TerminatedBy,
pub message: Option<String>,
pub exit_code: Option<i32>,
pub stderr: Option<StderrOutput>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SessionEndReason {
Completed,
Error,
Terminated,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TerminatedBy {
Agent,
Daemon,
}
```
## ACP RFD Reference
Per `~/misc/acp-docs/`, session termination is listed as an RFD topic. The existing ACP spec does not define a `session/terminate` or `session/delete` method.
## Implementation Plan
### ACP Extension Method
```
_sandboxagent/session/terminate
```
Client -> Runtime request:
```json
{
"jsonrpc": "2.0",
"id": "t-1",
"method": "_sandboxagent/session/terminate",
"params": {
"sessionId": "session-uuid"
}
}
```
Response:
```json
{
"jsonrpc": "2.0",
"id": "t-1",
"result": {
"terminated": true,
"reason": "terminated",
"terminatedBy": "daemon"
}
}
```
### Behavior
1. Client sends `_sandboxagent/session/terminate` request
2. Runtime identifies the session and its owning agent process
3. For shared-process agents (Codex, OpenCode): send a cancel/terminate signal to the agent process for that specific session
4. For per-turn subprocess agents (Claude, Amp): kill the subprocess if running, mark session as terminated
5. Emit `_sandboxagent/session/ended` to all connected clients watching that session
6. Method is idempotent: repeated calls on an already-ended session return success without side effects
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add `_sandboxagent/session/terminate` handler; add session removal from registry; add process kill logic |
| `server/packages/sandbox-agent/src/acp_runtime/mock.rs` | Add mock terminate support |
| `sdks/typescript/src/client.ts` | Add `terminateSession(sessionId)` method |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add session termination test |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/sdks/typescript.mdx` | Document `terminateSession` method |
| `research/acp/spec.md` | Add `_sandboxagent/session/terminate` to extension methods list |
| `research/acp/rfds-vs-extensions.md` | Update session termination row |

View file

@ -0,0 +1,130 @@
# Feature 8: Model Variants
> **Status:** Deferred / out of scope for the current implementation pass.
**Implementation approach:** Enhance existing `_sandboxagent/session/list_models` extension
## Summary
v1 had `AgentModelInfo.variants`, `AgentModelInfo.defaultVariant`, and `CreateSessionRequest.variant`. v2 already has `_sandboxagent/session/list_models` but the variant fields need to be verified and the session-creation variant selection needs to work end-to-end.
## Current v2 State
From `acp_runtime/mod.rs`, `_sandboxagent/session/list_models` is implemented and returns:
- `availableModels[]` with `modelId`, `name`, `description`
- `currentModelId`
- Fields for `defaultVariant`, `variants[]` are documented in `rfds-vs-extensions.md`
From v1 `router.rs`, model/variant types existed:
```rust
pub struct AgentModelsResponse {
pub models: Vec<AgentModelInfo>,
pub default_model: Option<String>,
}
pub struct AgentModelInfo {
pub id: String,
pub name: Option<String>,
pub variants: Option<Vec<AgentModelVariant>>,
pub default_variant: Option<String>,
}
pub struct AgentModelVariant {
pub id: String,
pub name: Option<String>,
}
```
## v1 Usage
### Pre-session Model Discovery
```
GET /v1/agents/{agent}/models
```
Returned `AgentModelsResponse` with full model list including variants.
### Session Creation with Variant
```
POST /v1/sessions
```
Body included `variant: Option<String>` to select a specific model variant at session creation time.
### Per-Agent Model Logic (from `router.rs`)
```rust
fn amp_models_response() -> AgentModelsResponse {
AgentModelsResponse {
models: vec![AgentModelInfo {
id: "amp-default".to_string(),
name: Some("Amp Default".to_string()),
variants: None,
default_variant: None,
}],
default_model: Some("amp-default".to_string()),
}
}
fn mock_models_response() -> AgentModelsResponse {
AgentModelsResponse {
models: vec![AgentModelInfo {
id: "mock".to_string(),
name: Some("Mock".to_string()),
variants: None,
default_variant: None,
}],
default_model: Some("mock".to_string()),
}
}
```
Claude and Codex models were fetched dynamically from the agent process.
## Implementation Plan
### Verify/Enrich `_sandboxagent/session/list_models`
The existing extension method already returns model data. Verify that:
1. `variants` array is included in each model entry when available
2. `defaultVariant` is included when available
3. The response shape matches the documented RFD shape
### Add Variant to Session Creation
Session creation via `session/new` should accept a variant hint in `_meta`:
```json
{
"method": "session/new",
"params": {
"_meta": {
"sandboxagent.dev": {
"variant": "opus"
}
}
}
}
```
The runtime should forward this variant to the agent process (e.g., as a model parameter in the spawn command or via `session/set_model`).
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Verify `list_models` response includes `variants`/`defaultVariant`; extract and forward `variant` from `session/new` `_meta` |
| `server/packages/sandbox-agent/src/acp_runtime/mock.rs` | Add variant support to mock model listing |
| `sdks/typescript/src/client.ts` | Update `listModels` return type to include variants |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add model variants test |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/sdks/typescript.mdx` | Document variant support in model listing and session creation |
| `research/acp/spec.md` | Update `_sandboxagent/session/list_models` payload shape |

View file

@ -0,0 +1,98 @@
# Feature 10: `include_raw`
> **Status:** Deferred / out of scope for the current implementation pass.
**Implementation approach:** ACP extension
## Summary
v1 had an `include_raw` option that preserved the original agent JSON alongside normalized events. The `UniversalEvent.raw` field held the verbatim agent output. v2 has `_sandboxagent/agent/unparsed` for parse errors but no mechanism for clients to request raw agent payloads alongside normalized ACP events.
## Current v2 State
- `_sandboxagent/agent/unparsed` — sends notifications when the runtime fails to parse agent output (error recovery only)
- No option for clients to request raw agent JSON alongside normal ACP events
- ACP events are already the agent's native JSON-RPC output (for agents that speak ACP natively); the "raw" concept is less meaningful when the agent already speaks ACP
## v1 Types
```rust
pub struct UniversalEvent {
pub event_id: String,
pub sequence: u64,
pub time: String,
pub session_id: String,
pub native_session_id: Option<String>,
pub synthetic: bool,
pub source: EventSource,
pub event_type: UniversalEventType,
pub data: UniversalEventData,
pub raw: Option<Value>, // <-- Raw agent output when include_raw=true
}
```
### v1 Usage
```
GET /v1/sessions/{id}/events?include_raw=true
```
When `include_raw=true`, each `UniversalEvent` included the verbatim JSON the agent process emitted before normalization into the universal schema.
## Implementation Plan
### Extension Design
Since v2 agents speak ACP natively (JSON-RPC), the "raw" concept changes:
- For ACP-native agents: raw = the ACP JSON-RPC envelope itself (which clients already see)
- For non-native agents or runtime-synthesized events: raw = the original agent output before transformation
The extension provides a way for clients to opt into receiving the pre-transformation payload.
### Opt-in via `_meta`
Client requests raw mode at connection initialization:
```json
{
"method": "initialize",
"params": {
"_meta": {
"sandboxagent.dev": {
"includeRaw": true
}
}
}
}
```
When enabled, notifications forwarded from the agent process include an additional `_meta.sandboxagent.dev.raw` field containing the original payload:
```json
{
"jsonrpc": "2.0",
"method": "session/update",
"params": {
// ... normalized ACP event ...
"_meta": {
"sandboxagent.dev": {
"raw": { /* original agent JSON */ }
}
}
}
}
```
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Track per-client `includeRaw` preference; attach raw payload to forwarded notifications when enabled |
| `sdks/typescript/src/client.ts` | Add `includeRaw` option to connection config; expose raw data on event objects |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/sdks/typescript.mdx` | Document `includeRaw` option |
| `research/acp/spec.md` | Document raw extension behavior |

View file

@ -0,0 +1,222 @@
# Feature 12: Agent Listing (Typed Response)
**Implementation approach:** Enhance existing `GET /v2/agents`
## Summary
v1 `GET /v1/agents` returned a typed `AgentListResponse` with `installed`, `credentialsAvailable`, `path`, `capabilities`, `serverStatus`. v2 `GET /v2/agents` returns a basic `AgentInfo` with only install state. Needs enrichment.
This feature also carries pre-session models/modes as optional fields when the agent is installed (Feature #13), rather than using separate model/mode endpoints.
## Current v2 State
From `router.rs:265-275`:
```rust
pub struct AgentInfo {
pub id: String,
pub native_required: bool,
pub native_installed: bool,
pub native_version: Option<String>,
pub agent_process_installed: bool,
pub agent_process_source: Option<String>,
pub agent_process_version: Option<String>,
pub capabilities: AgentCapabilities,
}
pub struct AgentCapabilities {
pub unstable_methods: bool,
}
```
## v1 Types (exact, from `router.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentCapabilities {
pub plan_mode: bool,
pub permissions: bool,
pub questions: bool,
pub tool_calls: bool,
pub tool_results: bool,
pub text_messages: bool,
pub images: bool,
pub file_attachments: bool,
pub session_lifecycle: bool,
pub error_events: bool,
pub reasoning: bool,
pub status: bool,
pub command_execution: bool,
pub file_changes: bool,
pub mcp_tools: bool,
pub streaming_deltas: bool,
pub item_started: bool,
/// Whether this agent uses a shared long-running server process (vs per-turn subprocess)
pub shared_process: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentInfo {
pub id: String,
pub installed: bool,
/// Whether the agent's required provider credentials are available
pub credentials_available: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub capabilities: AgentCapabilities,
/// Status of the shared server process (only present for agents with shared_process=true)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server_status: Option<ServerStatusInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct AgentListResponse {
pub agents: Vec<AgentInfo>,
}
```
## v1 `list_agents` Handler (exact)
```rust
async fn list_agents(
State(state): State<Arc<AppState>>,
) -> Result<Json<AgentListResponse>, ApiError> {
let manager = state.agent_manager.clone();
let server_statuses = state.session_manager.server_manager.status_snapshot().await;
let agents = tokio::task::spawn_blocking(move || {
let credentials = extract_all_credentials(&CredentialExtractionOptions::new());
let has_anthropic = credentials.anthropic.is_some();
let has_openai = credentials.openai.is_some();
all_agents().into_iter().map(|agent_id| {
let installed = manager.is_installed(agent_id);
let version = manager.version(agent_id).ok().flatten();
let path = manager.resolve_binary(agent_id).ok();
let capabilities = agent_capabilities_for(agent_id);
let credentials_available = match agent_id {
AgentId::Claude | AgentId::Amp => has_anthropic,
AgentId::Codex => has_openai,
AgentId::Opencode => has_anthropic || has_openai,
AgentId::Mock => true,
};
let server_status = if capabilities.shared_process {
Some(server_statuses.get(&agent_id).cloned().unwrap_or(
ServerStatusInfo {
status: ServerStatus::Stopped,
base_url: None, uptime_ms: None,
restart_count: 0, last_error: None,
},
))
} else { None };
AgentInfo {
id: agent_id.as_str().to_string(),
installed, credentials_available, version,
path: path.map(|p| p.to_string_lossy().to_string()),
capabilities, server_status,
}
}).collect::<Vec<_>>()
}).await.map_err(|err| SandboxError::StreamError { message: err.to_string() })?;
Ok(Json(AgentListResponse { agents }))
}
```
## v1 Per-Agent Capability Mapping (exact)
```rust
fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities {
match agent {
AgentId::Claude => AgentCapabilities {
plan_mode: false, permissions: true, questions: true,
tool_calls: true, tool_results: true, text_messages: true,
images: false, file_attachments: false, session_lifecycle: false,
error_events: false, reasoning: false, status: false,
command_execution: false, file_changes: false, mcp_tools: true,
streaming_deltas: true, item_started: false, shared_process: false,
},
AgentId::Codex => AgentCapabilities {
plan_mode: true, permissions: true, questions: false,
tool_calls: true, tool_results: true, text_messages: true,
images: true, file_attachments: true, session_lifecycle: true,
error_events: true, reasoning: true, status: true,
command_execution: true, file_changes: true, mcp_tools: true,
streaming_deltas: true, item_started: true, shared_process: true,
},
AgentId::Opencode => AgentCapabilities {
plan_mode: false, permissions: false, questions: false,
tool_calls: true, tool_results: true, text_messages: true,
images: true, file_attachments: true, session_lifecycle: true,
error_events: true, reasoning: false, status: false,
command_execution: false, file_changes: false, mcp_tools: true,
streaming_deltas: true, item_started: true, shared_process: true,
},
AgentId::Amp => AgentCapabilities {
plan_mode: false, permissions: false, questions: false,
tool_calls: true, tool_results: true, text_messages: true,
images: false, file_attachments: false, session_lifecycle: false,
error_events: true, reasoning: false, status: false,
command_execution: false, file_changes: false, mcp_tools: true,
streaming_deltas: false, item_started: false, shared_process: false,
},
AgentId::Mock => AgentCapabilities {
plan_mode: true, permissions: true, questions: true,
tool_calls: true, tool_results: true, text_messages: true,
images: true, file_attachments: true, session_lifecycle: true,
error_events: true, reasoning: true, status: true,
command_execution: true, file_changes: true, mcp_tools: true,
streaming_deltas: true, item_started: true, shared_process: false,
},
}
}
```
## Implementation Plan
### Enriched AgentInfo
Merge v2 install fields with v1 richness:
```rust
pub struct AgentInfo {
pub id: String,
pub installed: bool, // convenience: is fully installed
pub credentials_available: bool, // from credential extraction
pub native_required: bool, // keep from v2
pub native_installed: bool, // keep from v2
pub native_version: Option<String>, // keep from v2
pub agent_process_installed: bool, // keep from v2
pub agent_process_source: Option<String>, // keep from v2
pub agent_process_version: Option<String>, // keep from v2
pub path: Option<String>, // from resolve_binary()
pub capabilities: AgentCapabilities, // full v1 capability set
pub server_status: Option<AgentServerStatus>, // from Feature #6
pub models: Option<Vec<AgentModelInfo>>, // optional, installed agents only
pub default_model: Option<String>, // optional, installed agents only
pub modes: Option<Vec<AgentModeInfo>>, // optional, installed agents only
}
```
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/router.rs` | Enrich `AgentInfo` and `AgentCapabilities` structs; add `agent_capabilities_for()` static mapping; add credential check; add convenience `installed` field; add optional `models`/`modes` for installed agents |
| `server/packages/agent-management/src/agents.rs` | Expose credential availability check and `resolve_binary()` if not already present |
| `sdks/typescript/src/client.ts` | Update `AgentInfo` and `AgentCapabilities` types |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Update agent listing test assertions |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | Update `/v2/agents` response schema with full `AgentCapabilities` |
| `docs/sdks/typescript.mdx` | Document enriched agent listing |

View file

@ -0,0 +1,67 @@
# Feature 13: Models/Modes Listing (Pre-Session)
**Implementation approach:** Enrich agent response payloads (no separate `/models` or `/modes` endpoints)
## Summary
v1 exposed pre-session model/mode discovery via separate endpoints. For v2, models and modes should be optional fields on the agent response payload (only when the agent is installed), with lazy population for dynamic agents.
## Current v2 State
- `_sandboxagent/session/list_models` works but requires an active ACP connection and session
- `GET /v2/agents` does not include pre-session model/mode metadata
- v1 had static per-agent mode definitions (`agent_modes_for()` in `router.rs`)
- v1 had dynamic model fetching (Claude/Codex/OpenCode), plus static model lists for Amp/Mock
## v1 Reference (source commit)
Use commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836` as the baseline for mode definitions and model-fetching behavior.
## Response Shape (embedded in agent response)
Agent payloads should include optional model/mode fields:
```rust
pub struct AgentInfo {
// existing fields...
pub models: Option<Vec<AgentModelInfo>>, // only present when installed
pub default_model: Option<String>, // only present when installed
pub modes: Option<Vec<AgentModeInfo>>, // only present when installed
}
pub struct AgentModelInfo {
pub id: String,
pub name: Option<String>,
}
pub struct AgentModeInfo {
pub id: String,
pub name: String,
pub description: String,
}
```
Model variants are explicitly out of scope for this implementation pass.
## Population Rules
1. If agent is not installed: omit `models`, `default_model`, and `modes`.
2. If installed and static agent (Amp/Mock): populate immediately from static data.
3. If installed and dynamic agent (Claude/Codex/OpenCode): lazily start/query backing process and populate response.
4. On dynamic-query failure: return the base agent payload and omit model fields, while preserving existing endpoint success semantics.
## Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/router.rs` | Enrich agent response type/handlers to optionally include models + modes |
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Expose model query support for control-plane enrichment without requiring an active session |
| `sdks/typescript/src/client.ts` | Extend `AgentInfo` type with optional `models`, `defaultModel`, `modes` |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add assertions for installed vs non-installed agent response shapes |
## Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | Update `/v2/agents` (and agent detail endpoint if present) schema with optional `models`/`modes` |
| `docs/sdks/typescript.mdx` | Document optional model/mode fields on agent response |

View file

@ -0,0 +1,132 @@
# Feature 14: Message Attachments
**Implementation approach:** ACP extension via `_meta` in `session/prompt`
## Summary
v1 `MessageRequest.attachments` allowed sending file attachments (path, mime, filename) with prompts. v2 ACP `embeddedContext` is only partial. Need to support file attachments in prompt messages.
## Current v2 State
- ACP `session/prompt` accepts `params.content` as the prompt text
- No attachment mechanism in the current ACP prompt flow
- `embeddedContext` in ACP is for inline context, not file references
- The runtime currently passes prompt content through to the agent process as-is
## v1 Reference (source commit)
Port behavior from commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836`.
## v1 Types
```rust
#[derive(Debug, Deserialize, JsonSchema, ToSchema)]
pub struct MessageRequest {
pub message: String,
pub attachments: Option<Vec<MessageAttachment>>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema, ToSchema)]
pub struct MessageAttachment {
pub path: String,
pub mime: Option<String>,
pub filename: Option<String>,
}
```
## v1 Attachment Processing (from `router.rs`)
```rust
fn format_message_with_attachments(message: &str, attachments: &[MessageAttachment]) -> String {
if attachments.is_empty() {
return message.to_string();
}
let mut combined = String::new();
combined.push_str(message);
combined.push_str("\n\nAttachments:\n");
for attachment in attachments {
combined.push_str("- ");
combined.push_str(&attachment.path);
combined.push('\n');
}
combined
}
fn opencode_file_part_input(attachment: &MessageAttachment) -> Value {
let path = attachment.path.as_str();
let url = if path.starts_with("file://") {
path.to_string()
} else {
format!("file://{path}")
};
let filename = attachment.filename.clone().or_else(|| {
let clean = path.strip_prefix("file://").unwrap_or(path);
StdPath::new(clean)
.file_name()
.map(|name| name.to_string_lossy().to_string())
});
let mut map = serde_json::Map::new();
map.insert("type".to_string(), json!("file"));
map.insert("mime".to_string(), json!(attachment.mime.clone()
.unwrap_or_else(|| "application/octet-stream".to_string())));
map.insert("url".to_string(), json!(url));
if let Some(filename) = filename {
map.insert("filename".to_string(), json!(filename));
}
Value::Object(map)
}
```
### Per-Agent Handling
- **Claude**: Attachments appended as text to the prompt message (basic)
- **OpenCode**: Attachments converted to `file://` URIs in the `input` array using `opencode_file_part_input()`
- **Codex**: Attachments converted to file references in the Codex request format
## Implementation Plan
### Extension via `_meta` in `session/prompt`
Attachments are passed in `_meta.sandboxagent.dev.attachments`:
```json
{
"method": "session/prompt",
"params": {
"content": "Review this file",
"_meta": {
"sandboxagent.dev": {
"attachments": [
{
"path": "/workspace/file.py",
"mime": "text/x-python",
"filename": "file.py"
}
]
}
}
}
}
```
### Runtime Processing
The runtime extracts attachments from `_meta` and transforms them per agent:
1. **ACP-native agents**: Forward attachments in `_meta` — the agent process handles them
2. **Non-ACP fallback**: Append attachment paths to prompt text (like v1 Claude behavior)
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Extract `attachments` from `session/prompt` `_meta`; transform per agent before forwarding |
| `server/packages/sandbox-agent/src/acp_runtime/mock.rs` | Add mock handling for attachments |
| `sdks/typescript/src/client.ts` | Add `attachments` option to prompt method |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add attachment prompt test |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/sdks/typescript.mdx` | Document attachment support in prompts |
| `research/acp/spec.md` | Document attachment extension behavior |

View file

@ -0,0 +1,107 @@
# Feature 15: Session Creation Richness
**Implementation approach:** Check existing extensions — most already implemented
## Summary
v1 `CreateSessionRequest` had `mcp` (full MCP server config with OAuth, env headers, bearer tokens), `skills` (sources with git refs), `agent_version`, `directory`. v2 needs to support these at session creation time.
## Current v2 State — MOSTLY IMPLEMENTED
Investigation shows that **most of these are already supported** via `_meta.sandboxagent.dev` passthrough in `session/new`:
| Field | v1 | v2 Status | v2 Mechanism |
|-------|-----|-----------|-------------|
| `directory` | `CreateSessionRequest.directory` | **Implemented** | `cwd` parameter extracted from payload |
| `agent_version` | `CreateSessionRequest.agent_version` | **Implemented** | `_meta.sandboxagent.dev.agentVersionRequested` (stored, forwarded) |
| `skills` | `CreateSessionRequest.skills` | **Implemented** | `_meta.sandboxagent.dev.skills` (stored, forwarded) |
| `mcp` | `CreateSessionRequest.mcp` | **Stored but not processed** | `_meta.sandboxagent.dev.mcp` passthrough — stored in `sandbox_meta` but no active MCP server config processing |
| `title` | (session metadata) | **Implemented** | `_meta.sandboxagent.dev.title` extracted to `MetaSession.title` |
| `requestedSessionId` | (session alias) | **Implemented** | `_meta.sandboxagent.dev.requestedSessionId` |
| `model` | `CreateSessionRequest.model` | **Implemented** | `_meta.sandboxagent.dev.model` via `session_model_hint()` |
| `variant` | `CreateSessionRequest.variant` | **Deferred** | Out of scope in current implementation pass |
### Confirmation from `rfds-vs-extensions.md`
- Skills: "Already extension via `_meta[\"sandboxagent.dev\"].skills` and optional `_sandboxagent/session/set_metadata`"
- Agent version: "Already extension via `_meta[\"sandboxagent.dev\"].agentVersionRequested`"
- Requested session ID: "Already extension via `_meta[\"sandboxagent.dev\"].requestedSessionId`"
## v1 Types (for reference)
```rust
#[derive(Debug, Deserialize, JsonSchema, ToSchema)]
pub struct CreateSessionRequest {
pub agent: String,
pub message: String,
pub directory: Option<String>,
pub variant: Option<String>,
pub agent_version: Option<String>,
pub mcp: Option<Vec<McpServerConfig>>,
pub skills: Option<Vec<SkillSource>>,
pub attachments: Option<Vec<MessageAttachment>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ToSchema)]
pub struct McpServerConfig {
pub name: String,
pub command: String,
pub args: Option<Vec<String>>,
pub env: Option<HashMap<String, String>>,
pub oauth: Option<McpOAuthConfig>,
pub headers: Option<HashMap<String, String>>,
pub bearer_token: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ToSchema)]
pub struct McpOAuthConfig {
pub client_id: String,
pub client_secret: Option<String>,
pub auth_url: String,
pub token_url: String,
pub scopes: Option<Vec<String>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ToSchema)]
pub struct SkillSource {
pub name: String,
pub source: SkillSourceType,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ToSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SkillSourceType {
Git { url: String, ref_spec: Option<String> },
Local { path: String },
}
```
## What Remains
### MCP Server Config Processing
The `mcp` field is stored in `sandbox_meta` but **not actively processed**. To fully support MCP server configuration at session creation:
1. Extract `_meta.sandboxagent.dev.mcp` array from `session/new` params
2. Forward MCP server configs to the agent process (agent-specific: Claude uses `--mcp-config`, Codex/OpenCode have different mechanisms)
3. This is complex and agent-specific — may be deferred
### Recommendation
Since most fields are already implemented via `_meta` passthrough:
- **No new work needed** for `directory`, `agent_version`, `skills`, `title`, `requestedSessionId`, `model`
- **MCP config processing** is the only gap — evaluate whether the agent processes already handle MCP config from `_meta` or if explicit processing is needed
- Mark this feature as **largely complete** with MCP as a follow-up
## Files to Modify (if MCP processing is needed)
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Extract and process `mcp` config from `_meta.sandboxagent.dev.mcp` during session creation |
| `server/packages/agent-management/src/agents.rs` | Accept MCP config in agent spawn parameters |
## Docs to Update
| Doc | Change |
|-----|--------|
| `docs/sdks/typescript.mdx` | Document all supported `_meta.sandboxagent.dev` fields for session creation |

View file

@ -0,0 +1,170 @@
# Feature 16: Session Info
**Implementation approach:** New HTTP endpoints (`GET /v2/sessions`, `GET /v2/sessions/{id}`)
## Summary
v1 `SessionInfo` tracked `event_count`, `created_at`, `updated_at`, and full `mcp` config. v2 has session data in the ACP runtime's `MetaSession` struct but no HTTP endpoints to query it. Add REST endpoints for session listing and detail.
## Current v2 State
### Internal Session Tracking
From `acp_runtime/mod.rs:130-138`:
```rust
struct MetaSession {
session_id: String,
agent: AgentId,
cwd: String,
title: Option<String>,
updated_at: Option<String>,
sandbox_meta: Map<String, Value>,
}
```
### ACP `session/list` Response
The ACP `session/list` already returns session data (lines 956-967):
```json
{
"sessionId": "...",
"cwd": "...",
"title": "...",
"updatedAt": "...",
"_meta": { "sandboxagent.dev": { "agent": "claude" } }
}
```
But this requires an active ACP connection.
## v1 Types (exact, from `router.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub session_id: String,
pub agent: String,
pub agent_mode: String,
pub permission_mode: String,
pub model: Option<String>,
pub native_session_id: Option<String>,
pub ended: bool,
pub event_count: u64,
pub created_at: i64,
pub updated_at: i64,
pub directory: Option<String>,
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<BTreeMap<String, McpServerConfig>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<SkillsConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)]
pub struct SessionListResponse {
pub sessions: Vec<SessionInfo>,
}
```
## v1 Handler and Builder (exact)
```rust
async fn list_sessions(
State(state): State<Arc<AppState>>,
) -> Result<Json<SessionListResponse>, ApiError> {
let sessions = state.session_manager.list_sessions().await;
Ok(Json(SessionListResponse { sessions }))
}
// SessionManager methods:
pub(crate) async fn list_sessions(&self) -> Vec<SessionInfo> {
let sessions = self.sessions.lock().await;
sessions.iter().rev()
.map(|state| Self::build_session_info(state))
.collect()
}
pub(crate) async fn get_session_info(&self, session_id: &str) -> Option<SessionInfo> {
let sessions = self.sessions.lock().await;
Self::session_ref(&sessions, session_id).map(Self::build_session_info)
}
fn build_session_info(state: &SessionState) -> SessionInfo {
SessionInfo {
session_id: state.session_id.clone(),
agent: state.agent.as_str().to_string(),
agent_mode: state.agent_mode.clone(),
permission_mode: state.permission_mode.clone(),
model: state.model.clone(),
native_session_id: state.native_session_id.clone(),
ended: state.ended,
event_count: state.events.len() as u64,
created_at: state.created_at,
updated_at: state.updated_at,
directory: state.directory.clone(),
title: state.title.clone(),
mcp: state.mcp.clone(),
skills: state.skills.clone(),
}
}
```
## Implementation Plan
### New HTTP Endpoints
```
GET /v2/sessions -> SessionListResponse
GET /v2/sessions/{id} -> SessionInfo
```
These are control-plane HTTP endpoints (not ACP), providing session visibility without requiring an active ACP connection.
### Response Types
The v2 `SessionInfo` should be a superset of v1 fields, adapted for ACP:
```rust
#[derive(Debug, Serialize, JsonSchema, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct SessionInfo {
pub session_id: String,
pub agent: String,
pub cwd: String,
pub title: Option<String>,
pub ended: bool,
pub created_at: Option<String>, // ISO 8601 (v1 used i64 timestamp)
pub updated_at: Option<String>, // ISO 8601
pub model: Option<String>,
pub metadata: Value, // full sandbox_meta
}
```
### Data Source
The `AcpRuntime` maintains a `sessions: RwLock<HashMap<String, MetaSession>>` registry. The new HTTP endpoints query this registry.
Need to add:
- `created_at` field to `MetaSession`
- `ended` status tracking
- Public methods on `AcpRuntime` to expose session list/detail for HTTP handlers
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/router.rs` | Add `GET /v2/sessions` and `GET /v2/sessions/{id}` handlers; add response types |
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add `created_at` to `MetaSession`; add `ended` tracking; expose `list_sessions()` and `get_session()` public methods |
| `sdks/typescript/src/client.ts` | Add `listSessions()` and `getSession(id)` methods |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add session listing and detail tests |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/openapi.json` | Add `/v2/sessions` and `/v2/sessions/{id}` endpoint specs |
| `docs/cli.mdx` | Add CLI `sessions list` and `sessions info` commands |
| `docs/sdks/typescript.mdx` | Document session listing SDK methods |

View file

@ -0,0 +1,191 @@
# Feature 17: Error Termination Metadata
**Implementation approach:** Enrich ACP notifications and session info
## Summary
v1 captured `exit_code`, structured `StderrOutput` (head/tail/truncated) when a session ended due to error. v2 loses this metadata. Need to capture and expose process termination details.
## Current v2 State
- Agent process lifecycle is managed in `acp_runtime/mod.rs`
- Process exit is detected but error metadata (exit code, stderr) is not captured or forwarded
- The `_sandboxagent/agent/unparsed` notification exists for parse errors, but not for process crashes
- No structured error termination data is emitted to clients
## v1 Reference (source commit)
Port behavior and payload shape from commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836`.
## v1 Types (exact, from `universal-agent-schema/src/lib.rs`)
```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct SessionEndedData {
pub reason: SessionEndReason,
pub terminated_by: TerminatedBy,
/// Error message when reason is Error
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
/// Process exit code when reason is Error
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Agent stderr output when reason is Error
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr: Option<StderrOutput>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
pub struct StderrOutput {
/// First N lines of stderr (if truncated) or full stderr (if not truncated)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
/// Last N lines of stderr (only present if truncated)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tail: Option<String>,
/// Whether the output was truncated
pub truncated: bool,
/// Total number of lines in stderr
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_lines: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum SessionEndReason {
Completed,
Error,
Terminated,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TerminatedBy {
Agent,
Daemon,
}
```
## v1 Implementation (exact)
### `mark_session_ended` (SessionManager)
```rust
async fn mark_session_ended(
&self,
session_id: &str,
exit_code: Option<i32>,
message: &str,
reason: SessionEndReason,
terminated_by: TerminatedBy,
stderr: Option<StderrOutput>,
) {
let mut sessions = self.sessions.lock().await;
if let Some(session) = Self::session_mut(&mut sessions, session_id) {
if session.ended { return; }
session.mark_ended(exit_code, message.to_string(), reason.clone(), terminated_by.clone());
let (error_message, error_exit_code, error_stderr) =
if reason == SessionEndReason::Error {
(Some(message.to_string()), exit_code, stderr)
} else {
(None, None, None)
};
let ended = EventConversion::new(
UniversalEventType::SessionEnded,
UniversalEventData::SessionEnded(SessionEndedData {
reason, terminated_by,
message: error_message,
exit_code: error_exit_code,
stderr: error_stderr,
}),
).synthetic().with_native_session(session.native_session_id.clone());
session.record_conversions(vec![ended]);
}
}
```
### Stderr capture on error exit
```rust
// Called from consume_spawn when agent process exits with error:
Ok(Ok(status)) => {
let message = format!("agent exited with status {:?}", status);
if !terminate_early {
self.record_error(&session_id, message.clone(),
Some("process_exit".to_string()), None).await;
}
let logs = self.read_agent_stderr(agent);
self.mark_session_ended(
&session_id, status.code(), &message,
SessionEndReason::Error, TerminatedBy::Agent, logs,
).await;
}
```
### Stderr reading
```rust
fn read_agent_stderr(&self, agent: AgentId) -> Option<StderrOutput> {
let logs = AgentServerLogs::new(self.server_manager.log_base_dir.clone(), agent.as_str());
logs.read_stderr()
}
```
## Implementation Plan
### Stderr Capture in ACP Runtime
When an agent process exits (especially abnormally):
1. **Capture stderr**: Buffer the agent process's stderr stream with head/tail logic (~50 lines each)
2. **Capture exit code**: Get the process exit status
3. **Store in session**: Record termination info in the session registry
4. **Emit notification**: Send error notification to all connected clients
### ACP Notification Shape
When an agent process terminates with an error:
```json
{
"jsonrpc": "2.0",
"method": "_sandboxagent/session/ended",
"params": {
"session_id": "session-uuid",
"data": {
"reason": "error",
"terminated_by": "agent",
"message": "agent exited with status ExitStatus(unix_wait_status(256))",
"exit_code": 1,
"stderr": {
"head": "Error: module not found\n at ...",
"tail": " at process.exit\nnode exited",
"truncated": true,
"total_lines": 250
}
}
}
}
```
### Session Info Integration
Termination metadata should be accessible via:
- `GET /v2/sessions/{id}` (Feature #16) — include `terminationInfo` in response when session has ended
- `session/list` ACP response — include termination status in session entries
### Files to Modify
| File | Change |
|------|--------|
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | Add stderr capture (head/tail buffer) on agent process; capture exit code; emit `_sandboxagent/session/ended`; store v1-shaped termination info in `MetaSession` |
| `server/packages/sandbox-agent/src/acp_runtime/mock.rs` | Add mock error termination scenario (e.g., when prompt contains "crash") |
| `sdks/typescript/src/client.ts` | Add `TerminationInfo` type; expose on session events and session info |
| `server/packages/sandbox-agent/tests/v2_api.rs` | Add error termination metadata test |
### Docs to Update
| Doc | Change |
|-----|--------|
| `docs/sdks/typescript.mdx` | Document `TerminationInfo` type and how to handle error termination |
| `research/acp/spec.md` | Document `_sandboxagent/session/ended` extension and payload |

View file

@ -0,0 +1,30 @@
# Missing Features Index
Features selected for implementation from the v1-to-v2 gap analysis.
## Completely UNIMPLEMENTED in v2
| # | Feature | Implementation notes |
|---|---------|---------------------|
| 1 | ~~Questions~~ | Deferred to agent process side ([#156](https://github.com/rivet-dev/sandbox-agent/issues/156)) |
| 2 | ~~Event history/polling~~ | Not selected |
| 3 | ~~Turn stream~~ | Not selected |
| 4 | **Filesystem API** -- all 8 endpoints (list, read, write, delete, mkdir, move, stat, upload-batch). ACP only has text-only `fs/read_text_file` + `fs/write_text_file` (agent->client direction). | |
| 5 | **Health endpoint** -- typed `HealthResponse` with status. | |
| 6 | **Server status** -- `ServerStatus` (Running/Stopped/Error), `ServerStatusInfo` (baseUrl, lastError, restartCount, uptimeMs). | |
| 7 | **Session termination** -- v1 had full `terminate`. v2 only has `session/cancel` (turn cancellation, not session kill). No explicit close/delete. | See existing ACP RFD |
| 8 | ~~Model variants~~ -- deferred for now. | Out of scope |
| 9 | ~~Agent capability flags~~ | Not selected |
| 10 | ~~`include_raw`~~ -- deferred for now. | Out of scope |
## Downgraded / Partial in v2
| # | Feature | Implementation notes |
|---|---------|---------------------|
| 11 | ~~Permission reply granularity~~ | Not selected |
| 12 | **Agent listing** -- v1 `GET /v1/agents` returned typed `AgentListResponse` with `installed`, `credentialsAvailable`, `path`, `capabilities`, `serverStatus`. v2 returns generic JSON. | |
| 13 | **Models/modes listing** -- expose as optional `models`/`modes` fields on agent response payloads (installed agents only), lazily populated. | No separate `/models` or `/modes` endpoints |
| 14 | **Message attachments** -- v1 `MessageRequest.attachments` (path, mime, filename). v2 ACP `embeddedContext` is only partial. | |
| 15 | **Session creation richness** -- v1 `CreateSessionRequest` had `mcp` (full MCP server config with OAuth, env headers, bearer tokens), `skills` (sources with git refs), `agent_version`, `directory`. Most have no ACP equivalent. | Check with our extensions, do not implement if already done |
| 16 | **Session info** -- v1 `SessionInfo` tracked `event_count`, `created_at`, `updated_at`, full `mcp` config. Mostly lost. | Add as sessions HTTP endpoint |
| 17 | **Error termination metadata** -- v1 captured `exit_code`, structured `StderrOutput` (head/tail/truncated). Gone. | |

View file

@ -0,0 +1,132 @@
# Missing Features Implementation Plan
Features selected from the v1-to-v2 gap analysis, ordered for implementation.
## Confirmed Decisions (Locked)
- Canonical extension naming is `_sandboxagent/...` and `_meta["sandboxagent.dev"]`; remove/ignore `_sandboxagent/*`.
- Control-plane discovery/status/session APIs are HTTP-only under `/v2/*` (no ACP control-plane equivalents).
- For Health, Filesystem, and Attachments, implementation should port behavior from v1 using commit `8ecd27bc24e62505d7aa4c50cbdd1c9dbb09f836`.
- Session termination via `_sandboxagent/session/terminate` is idempotent.
- `DELETE /v2/rpc` is transport detach only; it must not replace explicit termination semantics.
- Model variants (#8) are removed from current scope.
- `include_raw` (#10) is removed from current scope.
- Models/modes should be optional properties on agent response payloads (only when the agent is installed) and lazily populated.
- Error termination metadata should emit a dedicated session-ended extension event.
## Implementation Order
Features are ordered by dependency chain and implementation complexity. Features that other features depend on come first.
### Phase A: Foundation (control-plane enrichment)
These features enrich existing endpoints and have no dependencies on each other.
| Order | Feature | Spec | Approach | Effort |
|:-----:|----------------------------------------------|:----:|--------------------------------------------|:------:|
| A1 | [Health Endpoint](./05-health-endpoint.md) | #5 | Port v1 health behavior to `GET /v2/health` | Small |
| A2 | [Server Status](./06-server-status.md) | #6 | Add process tracking to ACP runtime | Medium |
| A3 | [Agent Listing](./12-agent-listing.md) | #12 | Enrich `GET /v2/agents` with v1-parity data | Medium |
**A2 blocks A3** — agent listing includes server status from Feature #6.
### Phase B: Session lifecycle
Session-level features that build on Phase A runtime tracking.
| Order | Feature | Spec | Approach | Effort |
|:-----:|--------------------------------------------------------------|:----:|------------------------------------------------------|:------:|
| B1 | [Session Info](./16-session-info.md) | #16 | New `GET /v2/sessions` and `GET /v2/sessions/{id}` | Medium |
| B2 | [Session Termination](./07-session-termination.md) | #7 | Idempotent `_sandboxagent/session/terminate` | Medium |
| B3 | [Error Termination Metadata](./17-error-termination-metadata.md) | #17 | Stderr capture + `_sandboxagent/session/ended` event | Medium |
**B2 depends on B1** — terminate updates session state visible via session info.
**B3 depends on B1** — termination metadata is stored in session info.
### Phase C: Agent interaction enrichment
Features that add richness to the prompt/response cycle.
| Order | Feature | Spec | Approach | Effort |
|:-----:|--------------------------------------------------|:----:|--------------------------------------------------|:------:|
| C1 | [Message Attachments](./14-message-attachments.md) | #14 | Port v1 attachment behavior via `session/prompt` | Medium |
No internal dependencies.
> **Note:** Questions (#1) deferred to agent process side — see [#156](https://github.com/rivet-dev/sandbox-agent/issues/156).
### Phase D: Discovery and configuration
Pre-session discovery and session configuration features.
| Order | Feature | Spec | Approach | Effort |
|:-----:|---------------------------------------------------------|:----:|---------------------------------------------------------|:------:|
| D1 | [Models/Modes Listing](./13-models-modes-listing.md) | #13 | Optional `models`/`modes` on agent response, lazy load | Medium |
| D2 | [Session Creation Richness](./15-session-creation-richness.md) | #15 | **Mostly done**; MCP config processing remains | Small |
**D2 is mostly complete** — verify existing `_meta` passthrough; only MCP server config processing may need work.
### Phase E: Platform services
Standalone platform-level API.
| Order | Feature | Spec | Approach | Effort |
|:-----:|---------------------------------------|:----:|----------------------------------|:------:|
| E1 | [Filesystem API](./04-filesystem-api.md) | #4 | Port v1 behavior to `/v2/fs/*` | Large |
No dependencies on other features. Can be implemented at any time but is the largest single feature.
## Dependency Graph
```
A1 (Health)
A2 (Server Status) ──> A3 (Agent Listing)
──> B1 (Session Info) ──> B2 (Session Termination)
──> B3 (Error Termination Metadata)
C1 (Attachments) [independent]
D1 (Models/Modes on agent response)
D2 (Session Creation) [mostly complete]
E1 (Filesystem) [independent]
```
## Summary Table
| # | Feature | Spec File | Status | Approach |
|:--:|---------------------------------|-------------------------------------------------------|---------------------------------|-------------------------------------------------|
| 1 | ~~Questions~~ | [01-questions.md](./01-questions.md) | Deferred ([#156](https://github.com/rivet-dev/sandbox-agent/issues/156)) | Agent process side |
| 4 | Filesystem API | [04-filesystem-api.md](./04-filesystem-api.md) | Not implemented | Port v1 behavior onto `/v2/fs/*` |
| 5 | Health Endpoint | [05-health-endpoint.md](./05-health-endpoint.md) | Partial (basic only) | Port v1 health behavior |
| 6 | Server Status | [06-server-status.md](./06-server-status.md) | Not implemented | Runtime tracking |
| 7 | Session Termination | [07-session-termination.md](./07-session-termination.md) | Not implemented | Idempotent ACP extension |
| 8 | ~~Model Variants~~ | [08-model-variants.md](./08-model-variants.md) | Deferred (removed from scope) | Do not implement |
| 10 | ~~include_raw~~ | [10-include-raw.md](./10-include-raw.md) | Deferred (removed from scope) | Do not implement |
| 12 | Agent Listing | [12-agent-listing.md](./12-agent-listing.md) | Partial (install state only) | Enhance existing |
| 13 | Models/Modes Listing | [13-models-modes-listing.md](./13-models-modes-listing.md) | Not implemented | Optional agent fields; lazy process start |
| 14 | Message Attachments | [14-message-attachments.md](./14-message-attachments.md) | Not implemented | Port v1 behavior via ACP `_meta` |
| 15 | Session Creation Richness | [15-session-creation-richness.md](./15-session-creation-richness.md) | **Mostly complete** | Verify existing; MCP config TBD |
| 16 | Session Info | [16-session-info.md](./16-session-info.md) | Not implemented | New HTTP endpoints |
| 17 | Error Termination Metadata | [17-error-termination-metadata.md](./17-error-termination-metadata.md) | Not implemented | Runtime stderr + `_sandboxagent/session/ended` |
## Cross-Cutting Concerns
### Files modified by multiple features
| File | Features |
|---------------------------------------------------|-------------------------------|
| `server/packages/sandbox-agent/src/router.rs` | #4, #5, #6, #12, #13, #16 |
| `server/packages/sandbox-agent/src/acp_runtime/mod.rs` | #6, #7, #13, #14, #16, #17 |
| `sdks/typescript/src/client.ts` | All in-scope features |
| `docs/openapi.json` | #4, #5, #6, #12, #13, #16 |
| `docs/sdks/typescript.mdx` | All in-scope features |
| `server/packages/sandbox-agent/tests/v2_api.rs` | All in-scope features |
### Docs update checklist
- [ ] `docs/openapi.json` — regenerate after all HTTP endpoint changes
- [ ] `docs/cli.mdx` — update for new CLI subcommands (#4, #16)
- [ ] `docs/sdks/typescript.mdx` — update for all new SDK methods
- [ ] `research/acp/spec.md` — update extension methods list
- [ ] `research/acp/rfds-vs-extensions.md` — update status of implemented features