diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 0000000..487c019 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,165 @@ +# Release Agent + +You are a release agent for the Gigacode project (sandbox-agent). Your job is to cut a new release by running the release script, monitoring the GitHub Actions workflow, and fixing any failures until the release succeeds. + +## Step 1: Gather Release Information + +Ask the user what type of release they want to cut: + +- **patch** - Bug fixes (e.g., 0.1.8 -> 0.1.9) +- **minor** - New features (e.g., 0.1.8 -> 0.2.0) +- **major** - Breaking changes (e.g., 0.1.8 -> 1.0.0) +- **rc** - Release candidate (e.g., 0.2.0-rc.1) + +For **rc** releases, also ask: +1. What base version the RC is for (e.g., 0.2.0). If the user doesn't specify, determine it by bumping the minor version from the current version. +2. What RC number (e.g., 1, 2, 3). If the user doesn't specify, check existing git tags to auto-determine the next RC number: + +```bash +git tag -l "v-rc.*" | sort -V +``` + +If no prior RC tags exist for that base version, use `rc.1`. Otherwise, increment the highest existing RC number. + +The final RC version string is `-rc.` (e.g., `0.2.0-rc.1`). + +## Step 2: Confirm Release Details + +Before proceeding, display the release details to the user and ask for explicit confirmation: + +- Current version (read from `Cargo.toml` workspace.package.version) +- New version +- Current branch +- Whether it will be tagged as "latest" (RC releases are never tagged as latest) + +Do NOT proceed without user confirmation. + +## Step 3: Run the Release Script (Setup Local) + +The release script handles version bumping, local checks, committing, pushing, and triggering the workflow. + +For **major**, **minor**, or **patch** releases: + +```bash +echo "yes" | ./scripts/release/main.ts -- --phase setup-local +``` + +For **rc** releases (using explicit version): + +```bash +echo "yes" | ./scripts/release/main.ts --version --phase setup-local +``` + +Where `` is `major`, `minor`, or `patch`, and `` is the full RC version string like `0.2.0-rc.1`. + +The `--phase setup-local` runs these steps in order: +1. Confirms release details (interactive prompt - piping "yes" handles this) +2. Updates version in all files (Cargo.toml, package.json files) +3. Runs local checks (cargo check, cargo fmt, pnpm typecheck) +4. Git commits with message `chore(release): update version to X.Y.Z` +5. Git pushes +6. Triggers the GitHub Actions workflow + +If local checks fail at step 3, fix the issues in the codebase, then re-run using `--only-steps` to avoid re-running already-completed steps: + +```bash +echo "yes" | ./scripts/release/main.ts --version --only-steps run-local-checks,git-commit,git-push,trigger-workflow +``` + +## Step 4: Monitor the GitHub Actions Workflow + +After the workflow is triggered, wait 5 seconds for it to register, then begin polling. + +### Find the workflow run + +```bash +gh run list --workflow=release.yaml --limit=1 --json databaseId,status,conclusion,createdAt,url +``` + +Verify the run was created recently (within the last 2 minutes) to confirm you are monitoring the correct run. Save the `databaseId` as the run ID. + +### Poll for completion + +Poll every 15 seconds using: + +```bash +gh run view --json status,conclusion +``` + +Report progress to the user periodically (every ~60 seconds or when status changes). The status values are: +- `queued` / `in_progress` / `waiting` - Still running, keep polling +- `completed` - Done, check `conclusion` + +When `status` is `completed`, check `conclusion`: +- `success` - Release succeeded! Proceed to Step 6. +- `failure` - Proceed to Step 5. +- `cancelled` - Inform the user and stop. + +## Step 5: Handle Workflow Failures + +If the workflow fails: + +### 5a. Get failure logs + +```bash +gh run view --log-failed +``` + +### 5b. Analyze the error + +Read the failure logs carefully. Common failure categories: +- **Build failures** (cargo build, TypeScript compilation) - Fix the code +- **Formatting issues** (cargo fmt) - Run `cargo fmt` and commit +- **Test failures** - Fix the failing tests +- **Publishing failures** (crates.io, npm) - These may be transient; check if retry will help +- **Docker build failures** - Check Dockerfile or build script issues +- **Infrastructure/transient failures** (network timeouts, rate limits) - Just re-trigger without code changes + +### 5c. Fix and re-push + +If a code fix is needed: +1. Make the fix in the codebase +2. Amend the release commit (since the release version commit is the most recent): + +```bash +git add -A +git commit --amend --no-edit +git push --force-with-lease +``` + +IMPORTANT: Use `--force-with-lease` (not `--force`) for safety. Amend the commit rather than creating a new one so the release stays as a single version-bump commit. + +3. Re-trigger the workflow: + +```bash +gh workflow run .github/workflows/release.yaml \ + -f version= \ + -f latest= \ + --ref +``` + +Where `` is the current branch (usually `main`). Set `latest` to `false` for RC releases, `true` for stable releases that are newer than the current latest tag. + +4. Return to Step 4 to monitor the new run. + +If no code fix is needed (transient failure), skip straight to re-triggering the workflow (step 3 above). + +### 5d. Retry limit + +If the workflow has failed **5 times**, stop and report all errors to the user. Ask whether they want to continue retrying or abort the release. Do not retry infinitely. + +## Step 6: Report Success + +When the workflow completes successfully: +1. Print the GitHub Actions run URL +2. Print the new version number +3. Suggest running post-release testing: "Run `/project:post-release-testing` to verify the release works correctly." + +## Important Notes + +- The product name is "Gigacode" (capital G, lowercase c). The CLI binary is `gigacode` (lowercase). +- Do not include co-authors in any commit messages. +- Use conventional commits style (e.g., `chore(release): update version to X.Y.Z`). +- Keep commit messages to a single line. +- The release script requires `tsx` to run (it's a TypeScript file with a shebang). +- Always work on the current branch. Releases are typically cut from `main`. diff --git a/.dockerignore b/.dockerignore index 96880e9..1a4fa41 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,7 @@ dist/ build/ # Dependencies -node_modules/ +**/node_modules/ # Cache .cache/ diff --git a/.github/workflows/skill-generator.yml b/.github/workflows/skill-generator.yml index 0f220f8..f9a81ec 100644 --- a/.github/workflows/skill-generator.yml +++ b/.github/workflows/skill-generator.yml @@ -20,17 +20,25 @@ jobs: - name: Sync to skills repo env: - SKILLS_REPO_TOKEN: ${{ secrets.RIVET_GITHUB_PAT }} + GH_TOKEN: ${{ secrets.RIVET_GITHUB_PAT }} run: | - if [ -z "$SKILLS_REPO_TOKEN" ]; then - echo "SKILLS_REPO_TOKEN is not set" >&2 + if [ -z "$GH_TOKEN" ]; then + echo "::error::RIVET_GITHUB_PAT secret is not set" + exit 1 + fi + + # Validate token before proceeding + if ! gh auth status 2>/dev/null; then + echo "::error::RIVET_GITHUB_PAT is invalid or expired. Rotate the token at https://github.com/settings/tokens" exit 1 fi git config --global user.name "github-actions[bot]" git config --global user.email "github-actions[bot]@users.noreply.github.com" - git clone "https://x-access-token:${SKILLS_REPO_TOKEN}@github.com/rivet-dev/skills.git" /tmp/rivet-skills + # Clone public repo, configure auth via gh credential helper + gh auth setup-git + git clone https://github.com/rivet-dev/skills.git /tmp/rivet-skills mkdir -p /tmp/rivet-skills/skills/sandbox-agent rm -rf /tmp/rivet-skills/skills/sandbox-agent/* diff --git a/.gitignore b/.gitignore index e983e76..07d74c2 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,13 @@ npm-debug.log* Cargo.lock **/*.rs.bk +# Agent runtime directories +.agents/ +.claude/ +.opencode/ + +# Example temp files +.tmp-upload/ + # CLI binaries (downloaded during npm publish) sdks/cli/platforms/*/bin/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..cc04a2b --- /dev/null +++ b/.mcp.json @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "everything": { + "args": [ + "@modelcontextprotocol/server-everything" + ], + "command": "npx" + } + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index fae0758..c0dcf17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,16 @@ Universal schema guidance: - On parse failures, emit an `agent.unparsed` event (source=daemon, synthetic=true) and treat it as a test failure. Preserve raw payloads when `include_raw=true`. - Track subagent support in `docs/conversion.md`. For now, normalize subagent activity into normal message/tool flow, but revisit explicit subagent modeling later. - Keep the FAQ in `README.md` and `frontend/packages/website/src/components/FAQ.tsx` in sync. When adding or modifying FAQ entries, update both files. +- Update `research/wip-agent-support.md` as agent support changes are implemented. + +### OpenAPI / utoipa requirements + +Every `#[utoipa::path(...)]` handler function must have a doc comment where: +- The **first line** becomes the OpenAPI `summary` (short human-readable title, e.g. `"List Agents"`). This is used as the sidebar label and page heading in the docs site. +- The **remaining lines** become the OpenAPI `description` (one-sentence explanation of what the endpoint does). +- Every `responses(...)` entry must have a `description` (no empty descriptions). + +When adding or modifying endpoints, regenerate `docs/openapi.json` and verify titles render correctly in the docs site. ### CLI ⇄ HTTP endpoint map (keep in sync) @@ -64,11 +74,45 @@ Universal schema guidance: - `sandbox-agent api sessions reply-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` - `sandbox-agent api sessions reject-question` ↔ `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` - `sandbox-agent api sessions reply-permission` ↔ `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` +- `sandbox-agent api fs entries` ↔ `GET /v1/fs/entries` +- `sandbox-agent api fs read` ↔ `GET /v1/fs/file` +- `sandbox-agent api fs write` ↔ `PUT /v1/fs/file` +- `sandbox-agent api fs delete` ↔ `DELETE /v1/fs/entry` +- `sandbox-agent api fs mkdir` ↔ `POST /v1/fs/mkdir` +- `sandbox-agent api fs move` ↔ `POST /v1/fs/move` +- `sandbox-agent api fs stat` ↔ `GET /v1/fs/stat` +- `sandbox-agent api fs upload-batch` ↔ `POST /v1/fs/upload-batch` -## OpenCode CLI (Experimental) +## OpenCode Compatibility Layer `sandbox-agent opencode` starts a sandbox-agent server and attaches an OpenCode session (uses `/opencode`). +### Session ownership + +Sessions are stored **only** in sandbox-agent's v1 `SessionManager` — they are never sent to or stored in the native OpenCode server. The OpenCode TUI reads sessions via `GET /session` which the compat layer serves from the v1 store. The native OpenCode process has no knowledge of sessions. + +### Proxy elimination strategy + +The `/opencode` compat layer (`opencode_compat.rs`) historically proxied many endpoints to the native OpenCode server via `proxy_native_opencode()`. The goal is to **eliminate proxying** by implementing each endpoint natively using the v1 `SessionManager` as the single source of truth. + +**Already de-proxied** (use v1 SessionManager directly): +- `GET /session` — `oc_session_list` reads from `SessionManager::list_sessions()` +- `GET /session/{id}` — `oc_session_get` reads from `SessionManager::get_session_info()` +- `GET /session/status` — `oc_session_status` derives busy/idle from v1 session `ended` flag +- `POST /tui/open-sessions` — returns `true` directly (TUI fetches sessions from `GET /session`) +- `POST /tui/select-session` — emits `tui.session.select` event via the OpenCode event broadcaster + +**Still proxied** (none of these reference session IDs or the session list — all are session-agnostic): +- `GET /command` — command list +- `GET /config`, `PATCH /config` — project config read/write +- `GET /global/config`, `PATCH /global/config` — global config read/write +- `GET /tui/control/next`, `POST /tui/control/response` — TUI control loop +- `POST /tui/append-prompt`, `/tui/submit-prompt`, `/tui/clear-prompt` — prompt management +- `POST /tui/open-help`, `/tui/open-themes`, `/tui/open-models` — TUI navigation +- `POST /tui/execute-command`, `/tui/show-toast`, `/tui/publish` — TUI actions + +When converting a proxied endpoint: add needed fields to `SessionState`/`SessionInfo` in `router.rs`, implement the logic natively in `opencode_compat.rs`, and use `session_info_to_opencode_value()` to format responses. + ## Post-Release Testing After cutting a release, verify the release works correctly. Run `/project:post-release-testing` to execute the testing agent. diff --git a/Cargo.toml b/Cargo.toml index 7644f8f..eafe654 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,21 +3,21 @@ resolver = "2" members = ["server/packages/*", "gigacode"] [workspace.package] -version = "0.1.7" +version = "0.1.12-rc.1" edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" repository = "https://github.com/rivet-dev/sandbox-agent" -description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Amp, and Pi." +description = "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Cursor, Amp, and Pi." [workspace.dependencies] # Internal crates -sandbox-agent = { version = "0.1.7", path = "server/packages/sandbox-agent" } -sandbox-agent-error = { version = "0.1.7", path = "server/packages/error" } -sandbox-agent-agent-management = { version = "0.1.7", path = "server/packages/agent-management" } -sandbox-agent-agent-credentials = { version = "0.1.7", path = "server/packages/agent-credentials" } -sandbox-agent-universal-agent-schema = { version = "0.1.7", path = "server/packages/universal-agent-schema" } -sandbox-agent-extracted-agent-schemas = { version = "0.1.7", path = "server/packages/extracted-agent-schemas" } +sandbox-agent = { version = "0.1.12-rc.1", path = "server/packages/sandbox-agent" } +sandbox-agent-error = { version = "0.1.12-rc.1", path = "server/packages/error" } +sandbox-agent-agent-management = { version = "0.1.12-rc.1", path = "server/packages/agent-management" } +sandbox-agent-agent-credentials = { version = "0.1.12-rc.1", path = "server/packages/agent-credentials" } +sandbox-agent-universal-agent-schema = { version = "0.1.12-rc.1", path = "server/packages/universal-agent-schema" } +sandbox-agent-extracted-agent-schemas = { version = "0.1.12-rc.1", path = "server/packages/extracted-agent-schemas" } # Serialization serde = { version = "1.0", features = ["derive"] } @@ -69,6 +69,7 @@ url = "2.5" regress = "0.10" include_dir = "0.7" base64 = "0.22" +toml_edit = "0.22" # Code generation (build deps) typify = "0.4" diff --git a/README.md b/README.md index 24ef108..609194c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Run Coding Agents in Sandboxes. Control Them Over HTTP.

- A server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Amp, or Pi — streaming events, handling permissions, managing sessions. + A server that runs inside your sandbox. Your app connects remotely to control Claude Code, Codex, OpenCode, Cursor, Amp, or Pi — streaming events, handling permissions, managing sessions.

@@ -24,13 +24,13 @@ Sandbox Agent solves three problems: 1. **Coding agents need sandboxes** — You can't let AI execute arbitrary code on your production servers. Coding agents need isolated environments, but existing SDKs assume local execution. Sandbox Agent is a server that runs inside the sandbox and exposes HTTP/SSE. -2. **Every coding agent is different** — Claude Code, Codex, OpenCode, Amp, and Pi each have proprietary APIs, event formats, and behaviors. Swapping agents means rewriting your integration. Sandbox Agent provides one HTTP API — write your code once, swap agents with a config change. +2. **Every coding agent is different** — Claude Code, Codex, OpenCode, Cursor, Amp, and Pi each have proprietary APIs, event formats, and behaviors. Swapping agents means rewriting your integration. Sandbox Agent provides one HTTP API — write your code once, swap agents with a config change. 3. **Sessions are ephemeral** — Agent transcripts live in the sandbox. When the process ends, you lose everything. Sandbox Agent streams events in a universal schema to your storage. Persist to Postgres, ClickHouse, or [Rivet](https://rivet.dev). Replay later, audit everything. ## Features -- **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, Amp, and Pi with full feature coverage +- **Universal Agent API**: Single interface to control Claude Code, Codex, OpenCode, Cursor, Amp, and Pi with full feature coverage - **Streaming Events**: Real-time SSE stream of everything the agent does — tool calls, permission requests, file edits, and more - **Universal Session Schema**: [Standardized schema](https://sandboxagent.dev/docs/session-transcript-schema) that normalizes all agent event formats for storage and replay - **Human-in-the-Loop**: Approve or deny tool executions and answer agent questions remotely over HTTP @@ -131,6 +131,8 @@ for await (const event of client.streamEvents("demo", { offset: 0 })) { } ``` +`permissionMode: "acceptEdits"` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents. + [SDK documentation](https://sandboxagent.dev/docs/sdks/typescript) — [Building a Chat UI](https://sandboxagent.dev/docs/building-chat-ui) — [Managing Sessions](https://sandboxagent.dev/docs/manage-sessions) ### HTTP Server @@ -232,7 +234,7 @@ No, they're complementary. AI SDK is for building chat interfaces and calling LL

Which coding agents are supported? -Claude Code, Codex, OpenCode, Amp, and Pi. The SDK normalizes their APIs so you can swap between them without changing your code. +Claude Code, Codex, OpenCode, Cursor, Amp, and Pi. The SDK normalizes their APIs so you can swap between them without changing your code.
diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx new file mode 100644 index 0000000..78390a3 --- /dev/null +++ b/docs/agent-sessions.mdx @@ -0,0 +1,278 @@ +--- +title: "Agent Sessions" +description: "Create sessions and send messages to agents." +sidebarTitle: "Sessions" +icon: "comments" +--- + +Sessions are the unit of interaction with an agent. You create one session per task, then send messages and stream events. + +## Session Options + +`POST /v1/sessions/{sessionId}` accepts the following fields: + +- `agent` (required): `claude`, `codex`, `opencode`, `amp`, or `mock` +- `agentMode`: agent mode string (for example, `build`, `plan`) +- `permissionMode`: permission mode string (`default`, `plan`, `bypass`, etc.) +- `model`: model override (agent-specific) +- `variant`: model variant (agent-specific) +- `agentVersion`: agent version override +- `mcp`: MCP server config map (see `MCP`) +- `skills`: skill path config (see `Skills`) + +## Create A Session + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.createSession("build-session", { + agent: "codex", + agentMode: "build", + permissionMode: "default", + model: "gpt-4.1", + variant: "reasoning", + agentVersion: "latest", +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "agent": "codex", + "agentMode": "build", + "permissionMode": "default", + "model": "gpt-4.1", + "variant": "reasoning", + "agentVersion": "latest" + }' +``` + + +## Send A Message + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.postMessage("build-session", { + message: "Summarize the repository structure.", +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/messages" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message":"Summarize the repository structure."}' +``` + + +## Stream A Turn + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +const response = await client.postMessageStream("build-session", { + message: "Explain the main entrypoints.", +}); + +const reader = response.body?.getReader(); +if (reader) { + const decoder = new TextDecoder(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + console.log(decoder.decode(value, { stream: true })); + } +} +``` + +```bash cURL +curl -N -X POST "http://127.0.0.1:2468/v1/sessions/build-session/messages/stream" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"message":"Explain the main entrypoints."}' +``` + + +## Fetch Events + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +const events = await client.getEvents("build-session", { + offset: 0, + limit: 50, + includeRaw: false, +}); + +console.log(events.events); +``` + +```bash cURL +curl -X GET "http://127.0.0.1:2468/v1/sessions/build-session/events?offset=0&limit=50" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + + +`GET /v1/sessions/{sessionId}/get-messages` is an alias for `events`. + +## Stream Events (SSE) + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +for await (const event of client.streamEvents("build-session", { offset: 0 })) { + console.log(event.type, event.data); +} +``` + +```bash cURL +curl -N -X GET "http://127.0.0.1:2468/v1/sessions/build-session/events/sse?offset=0" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + + +## List Sessions + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +const sessions = await client.listSessions(); +console.log(sessions.sessions); +``` + +```bash cURL +curl -X GET "http://127.0.0.1:2468/v1/sessions" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + + +## Reply To A Question + +When the agent asks a question, reply with an array of answers. Each inner array is one multi-select response. + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.replyQuestion("build-session", "question-1", { + answers: [["yes"]], +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/questions/question-1/reply" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"answers":[["yes"]]}' +``` + + +## Reject A Question + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.rejectQuestion("build-session", "question-1"); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/questions/question-1/reject" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + + +## Reply To A Permission Request + +Use `once`, `always`, or `reject`. + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.replyPermission("build-session", "permission-1", { + reply: "once", +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/permissions/permission-1/reply" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"reply":"once"}' +``` + + +## Terminate A Session + + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.terminateSession("build-session"); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/terminate" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` + diff --git a/docs/attachments.mdx b/docs/attachments.mdx new file mode 100644 index 0000000..2458c52 --- /dev/null +++ b/docs/attachments.mdx @@ -0,0 +1,87 @@ +--- +title: "Attachments" +description: "Upload files into the sandbox and attach them to prompts." +sidebarTitle: "Attachments" +icon: "paperclip" +--- + +Use the filesystem API to upload files, then reference them as attachments when sending prompts. + + + + + ```ts TypeScript + import { SandboxAgent } from "sandbox-agent"; + import fs from "node:fs"; + + const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, + }); + + const buffer = await fs.promises.readFile("./data.csv"); + + const upload = await client.writeFsFile( + { path: "./uploads/data.csv", sessionId: "my-session" }, + buffer, + ); + + console.log(upload.path); + ``` + + ```bash cURL + curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./uploads/data.csv&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + --data-binary @./data.csv + ``` + + + The response returns the absolute path that you should use for attachments. + + + + + ```ts TypeScript + import { SandboxAgent } from "sandbox-agent"; + + const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, + }); + + await client.postMessage("my-session", { + message: "Please analyze the attached CSV.", + attachments: [ + { + path: "/home/sandbox/uploads/data.csv", + mime: "text/csv", + filename: "data.csv", + }, + ], + }); + ``` + + ```bash cURL + curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "message": "Please analyze the attached CSV.", + "attachments": [ + { + "path": "/home/sandbox/uploads/data.csv", + "mime": "text/csv", + "filename": "data.csv" + } + ] + }' + ``` + + + + +## Notes + +- Use absolute paths from the upload response to avoid ambiguity. +- If `mime` is omitted, the server defaults to `application/octet-stream`. +- OpenCode receives file parts directly; other agents will see the attachment paths appended to the prompt. diff --git a/docs/building-chat-ui.mdx b/docs/building-chat-ui.mdx index 381cdb9..da706ff 100644 --- a/docs/building-chat-ui.mdx +++ b/docs/building-chat-ui.mdx @@ -29,7 +29,7 @@ const sessionId = `session-${crypto.randomUUID()}`; await client.createSession(sessionId, { agent: "claude", agentMode: "code", // Optional: agent-specific mode - permissionMode: "default", // Optional: "default" | "plan" | "bypass" + permissionMode: "default", // Optional: "default" | "plan" | "bypass" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default) model: "claude-sonnet-4", // Optional: model override }); ``` @@ -70,7 +70,7 @@ Use `offset` to track the last seen `sequence` number and resume from where you ### Bare minimum -Handle these three events to render a basic chat: +Handle item lifecycle plus turn lifecycle to render a basic chat: ```ts type ItemState = { @@ -79,9 +79,20 @@ type ItemState = { }; const items = new Map(); +let turnInProgress = false; function handleEvent(event: UniversalEvent) { switch (event.type) { + case "turn.started": { + turnInProgress = true; + break; + } + + case "turn.ended": { + turnInProgress = false; + break; + } + case "item.started": { const { item } = event.data as ItemEventData; items.set(item.item_id, { item, deltas: [] }); @@ -110,12 +121,14 @@ function handleEvent(event: UniversalEvent) { } ``` -When rendering, show a loading indicator while `item.status === "in_progress"`: +When rendering: +- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.). +- Use `item.status === "in_progress"` for per-item streaming state. ```ts function renderItem(state: ItemState) { const { item, deltas } = state; - const isLoading = item.status === "in_progress"; + const isItemLoading = item.status === "in_progress"; // For streaming text, combine item content with accumulated deltas const text = item.content @@ -126,7 +139,8 @@ function renderItem(state: ItemState) { return { content: streamedText, - isLoading, + isItemLoading, + isTurnLoading: turnInProgress, role: item.role, kind: item.kind, }; diff --git a/docs/cli.mdx b/docs/cli.mdx index dce67e8..2111b35 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -2,7 +2,6 @@ title: "CLI Reference" description: "Complete CLI reference for sandbox-agent." sidebarTitle: "CLI" -icon: "terminal" --- ## Server @@ -71,7 +70,6 @@ sandbox-agent opencode [OPTIONS] | `-H, --host ` | `127.0.0.1` | Host to bind to | | `-p, --port ` | `2468` | Port to bind to | | `--session-title ` | - | Title for the OpenCode session | -| `--opencode-bin <PATH>` | - | Override `opencode` binary path | ```bash sandbox-agent opencode --token "$TOKEN" @@ -79,7 +77,7 @@ sandbox-agent opencode --token "$TOKEN" The daemon logs to a per-host log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log`). -Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`). If it is not found on `PATH`, sandbox-agent installs it automatically. +Existing installs are reused and missing binaries are installed automatically. --- @@ -247,10 +245,12 @@ sandbox-agent api sessions create <SESSION_ID> [OPTIONS] |--------|-------------| | `-a, --agent <AGENT>` | Agent identifier (required) | | `-g, --agent-mode <MODE>` | Agent mode | -| `-p, --permission-mode <MODE>` | Permission mode (`default`, `plan`, `bypass`) | +| `-p, --permission-mode <MODE>` | Permission mode (`default`, `plan`, `bypass`, `acceptEdits`) | | `-m, --model <MODEL>` | Model override | | `-v, --variant <VARIANT>` | Model variant | | `-A, --agent-version <VERSION>` | Agent version | +| `--mcp-config <PATH>` | JSON file with MCP server config (see `mcp` docs) | +| `--skill <PATH>` | Skill directory or `SKILL.md` path (repeatable) | ```bash sandbox-agent api sessions create my-session \ @@ -259,6 +259,8 @@ sandbox-agent api sessions create my-session \ --permission-mode default ``` +`acceptEdits` passes through to Claude, auto-approves file changes for Codex, and is treated as `default` for other agents. + #### Send Message ```bash @@ -380,6 +382,132 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once --- +### Filesystem + +#### List Entries + +```bash +sandbox-agent api fs entries [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--path <PATH>` | Directory path (default: `.`) | +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs entries --path ./workspace +``` + +#### Read File + +`api fs read` writes raw bytes to stdout. + +```bash +sandbox-agent api fs read <PATH> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs read ./notes.txt > ./notes.txt +``` + +#### Write File + +```bash +sandbox-agent api fs write <PATH> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--content <TEXT>` | Write UTF-8 content | +| `--from-file <PATH>` | Read content from a local file | +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs write ./hello.txt --content "hello" +sandbox-agent api fs write ./image.bin --from-file ./image.bin +``` + +#### Delete Entry + +```bash +sandbox-agent api fs delete <PATH> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--recursive` | Delete directories recursively | +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs delete ./old.log +``` + +#### Create Directory + +```bash +sandbox-agent api fs mkdir <PATH> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs mkdir ./cache +``` + +#### Move/Rename + +```bash +sandbox-agent api fs move <FROM> <TO> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--overwrite` | Overwrite destination if it exists | +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs move ./a.txt ./b.txt --overwrite +``` + +#### Stat + +```bash +sandbox-agent api fs stat <PATH> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs stat ./notes.txt +``` + +#### Upload Batch (tar) + +```bash +sandbox-agent api fs upload-batch --tar <PATH> [OPTIONS] +``` + +| Option | Description | +|--------|-------------| +| `--tar <PATH>` | Tar archive to extract | +| `--path <PATH>` | Destination directory | +| `--session-id <SESSION_ID>` | Resolve relative paths from the session working directory | + +```bash +sandbox-agent api fs upload-batch --tar ./skills.tar --path ./skills +``` + +--- + ## CLI to HTTP Mapping | CLI Command | HTTP Endpoint | @@ -398,3 +526,11 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once | `api sessions reply-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reply` | | `api sessions reject-question` | `POST /v1/sessions/{sessionId}/questions/{questionId}/reject` | | `api sessions reply-permission` | `POST /v1/sessions/{sessionId}/permissions/{permissionId}/reply` | +| `api fs entries` | `GET /v1/fs/entries` | +| `api fs read` | `GET /v1/fs/file` | +| `api fs write` | `PUT /v1/fs/file` | +| `api fs delete` | `DELETE /v1/fs/entry` | +| `api fs mkdir` | `POST /v1/fs/mkdir` | +| `api fs move` | `POST /v1/fs/move` | +| `api fs stat` | `GET /v1/fs/stat` | +| `api fs upload-batch` | `POST /v1/fs/upload-batch` | diff --git a/docs/conversion.mdx b/docs/conversion.mdx index ac728c6..d7eb505 100644 --- a/docs/conversion.mdx +++ b/docs/conversion.mdx @@ -44,9 +44,11 @@ Events / Message Flow +------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+----------------------------+ | session.started | none | method=thread/started | type=session.created | none | none | | session.ended | SDKMessage.type=result | no explicit session end (turn/completed) | no explicit session end (session.deleted)| type=done | none (daemon synthetic) | +| turn.started | synthetic on message send | method=turn/started | type=session.status (busy) | synthetic on message send | none (daemon synthetic) | +| turn.ended | synthetic after result | method=turn/completed | type=session.idle | synthetic on done | none (daemon synthetic) | | message (user) | SDKMessage.type=user | item/completed (ThreadItem.type=userMessage)| message.updated (Message.role=user) | type=message | none (daemon synthetic) | | message (assistant) | SDKMessage.type=assistant | item/completed (ThreadItem.type=agentMessage)| message.updated (Message.role=assistant)| type=message | message_start/message_end | -| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (delta) | synthetic | message_update (text_delta/thinking_delta) | +| message.delta | stream_event (partial) or synthetic | method=item/agentMessage/delta | type=message.part.updated (text-part delta) | synthetic | message_update (text_delta/thinking_delta) | | tool call | type=tool_use | method=item/mcpToolCall/progress | message.part.updated (part.type=tool) | type=tool_call | tool_execution_start | | tool result | user.message.content.tool_result | item/completed (tool result ThreadItem variants) | message.part.updated (part.type=tool, state=completed) | type=tool_result | tool_execution_end | | permission.requested | control_request.can_use_tool | none | type=permission.asked | none | none | @@ -56,6 +58,10 @@ Events / Message Flow | error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error | hook_error (status item) | +------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+----------------------------+ +Permission status normalization: +- `permission.requested` uses `status=requested`. +- `permission.resolved` uses `status=accept`, `accept_for_session`, or `reject`. + Synthetics +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ @@ -63,6 +69,8 @@ Synthetics +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ | session.started | When agent emits no explicit start | session.started event | Mark source=daemon | | session.ended | When agent emits no explicit end | session.ended event | Mark source=daemon; reason may be inferred | +| turn.started | When agent emits no explicit turn start | turn.started event | Mark source=daemon | +| turn.ended | When agent emits no explicit turn end | turn.ended event | Mark source=daemon | | item_id (Claude) | Claude provides no item IDs | item_id | Maintain provider_item_id map when possible | | user message (Claude) | Claude emits only assistant output | item.completed | Mark source=daemon; preserve raw input in event metadata | | question events (Claude) | AskUserQuestion tool usage | question.requested/resolved | Derived from tool_use blocks (source=agent) | @@ -71,7 +79,7 @@ Synthetics | message.delta (Claude) | No native deltas emitted | item.delta | Synthetic delta with full message content; source=daemon | | message.delta (Amp) | No native deltas | item.delta | Synthetic delta with full message content; source=daemon | +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ -| message.delta (OpenCode) | part delta before message | item.delta | If part arrives first, create item.started stub then delta | +| message.delta (OpenCode) | text part delta before message | item.delta | If part arrives first, create item.started stub then delta | +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ Delta handling @@ -82,10 +90,11 @@ Delta handling - Pi emits message_update deltas and cumulative tool_execution_update partialResult values (we diff to produce deltas). Policy: -- Always emit item.delta across all providers. +- Emit item.delta for streamable text content across providers. - For providers without native deltas, emit a single synthetic delta containing the full content prior to item.completed. - For Claude when partial streaming is enabled, forward native deltas and skip the synthetic full-content delta. - For providers with native deltas, forward as-is; also emit item.completed when final content is known. +- For OpenCode reasoning part deltas, emit typed reasoning item updates (item.started/item.completed with content.type=reasoning) instead of item.delta. Message normalization notes diff --git a/docs/credentials.mdx b/docs/credentials.mdx new file mode 100644 index 0000000..ce1ce7b --- /dev/null +++ b/docs/credentials.mdx @@ -0,0 +1,144 @@ +--- +title: "Credentials" +description: "How sandbox-agent discovers and uses provider credentials." +icon: "key" +--- + +Sandbox-agent automatically discovers API credentials from environment variables and agent config files. Credentials are used to authenticate with AI providers (Anthropic, OpenAI) when spawning agents. + +## Credential sources + +Credentials are extracted in priority order. The first valid credential found for each provider is used. + +### Environment variables (highest priority) + +**API keys** (checked first): + +| Variable | Provider | +|----------|----------| +| `ANTHROPIC_API_KEY` | Anthropic | +| `CLAUDE_API_KEY` | Anthropic (fallback) | +| `OPENAI_API_KEY` | OpenAI | +| `CODEX_API_KEY` | OpenAI (fallback) | + +**OAuth tokens** (checked if no API key found): + +| Variable | Provider | +|----------|----------| +| `CLAUDE_CODE_OAUTH_TOKEN` | Anthropic (OAuth) | +| `ANTHROPIC_AUTH_TOKEN` | Anthropic (OAuth fallback) | + +OAuth tokens from environment variables are only used when `include_oauth` is enabled (the default). + +### Agent config files + +If no environment variable is set, sandbox-agent checks agent-specific config files: + +| Agent | Config path | Provider | +|-------|-------------|----------| +| Amp | `~/.amp/config.json` | Anthropic | +| Claude Code | `~/.claude.json`, `~/.claude/.credentials.json` | Anthropic | +| Codex | `~/.codex/auth.json` | OpenAI | +| OpenCode | `~/.local/share/opencode/auth.json` | Both | + +OAuth tokens are supported for Claude Code, Codex, and OpenCode. Expired tokens are automatically skipped. + +## Provider requirements by agent + +| Agent | Required provider | +|-------|-------------------| +| Claude Code | Anthropic | +| Amp | Anthropic | +| Codex | OpenAI | +| OpenCode | Anthropic or OpenAI | +| Mock | None | + +## Error handling behavior + +Sandbox-agent uses a **best-effort, fail-forward** approach to credentials: + +### Extraction failures are silent + +If a config file is missing, unreadable, or malformed, extraction continues to the next source. No errors are thrown. Missing credentials simply mean the provider is marked as unavailable. + +``` +~/.claude.json missing → try ~/.claude/.credentials.json +~/.claude/.credentials.json missing → try OpenCode config +All sources exhausted → anthropic = None (not an error) +``` + +### Agents spawn without credential validation + +When you send a message to a session, sandbox-agent does **not** pre-validate credentials. The agent process is spawned with whatever credentials were found (or none), and the agent's native error surfaces if authentication fails. + +This design: +- Lets you test agent error handling behavior +- Avoids duplicating provider-specific auth validation +- Ensures sandbox-agent faithfully proxies agent behavior + +For example, sending a message to Claude Code without Anthropic credentials will spawn the agent, which will then emit its own "ANTHROPIC_API_KEY not set" error through the event stream. + +## Checking credential status + +### API endpoint + +The `GET /v1/agents` endpoint includes a `credentialsAvailable` field for each agent: + +```json +{ + "agents": [ + { + "id": "claude", + "installed": true, + "credentialsAvailable": true, + ... + }, + { + "id": "codex", + "installed": true, + "credentialsAvailable": false, + ... + } + ] +} +``` + +### TypeScript SDK + +```typescript +const { agents } = await client.listAgents(); +for (const agent of agents) { + console.log(`${agent.id}: ${agent.credentialsAvailable ? 'authenticated' : 'no credentials'}`); +} +``` + +### OpenCode compatibility + +The `/opencode/provider` endpoint returns a `connected` array listing providers with valid credentials: + +```json +{ + "all": [...], + "connected": ["claude", "mock"] +} +``` + +## Passing credentials explicitly + +You can override auto-discovered credentials by setting environment variables before starting sandbox-agent: + +```bash +export ANTHROPIC_API_KEY=sk-ant-... +export OPENAI_API_KEY=sk-... +sandbox-agent daemon start +``` + +Or when using the SDK in embedded mode: + +```typescript +const client = await SandboxAgentClient.spawn({ + env: { + ANTHROPIC_API_KEY: process.env.MY_ANTHROPIC_KEY, + }, +}); +``` diff --git a/docs/custom-tools.mdx b/docs/custom-tools.mdx new file mode 100644 index 0000000..4690888 --- /dev/null +++ b/docs/custom-tools.mdx @@ -0,0 +1,245 @@ +--- +title: "Custom Tools" +description: "Give agents custom tools inside the sandbox using MCP servers or skills." +sidebarTitle: "Custom Tools" +icon: "wrench" +--- + +There are two ways to give agents custom tools that run inside the sandbox: + +| | MCP Server | Skill | +|---|---|---| +| **How it works** | Sandbox Agent spawns your MCP server process and routes tool calls to it via stdio | A markdown file that instructs the agent to run your script with `node` (or any command) | +| **Tool discovery** | Agent sees tools automatically via MCP protocol | Agent reads instructions from the skill file | +| **Best for** | Structured tools with typed inputs/outputs | Lightweight scripts with natural-language instructions | +| **Requires** | `@modelcontextprotocol/sdk` dependency | Just a markdown file and a script | + +Both approaches execute code inside the sandbox, so your tools have full access to the sandbox filesystem, network, and installed system tools. + +## Option A: Tools via MCP + +<Steps> + <Step title="Write your MCP server"> + Create an MCP server that exposes tools using `@modelcontextprotocol/sdk` with `StdioServerTransport`. This server will run inside the sandbox. + + ```ts src/mcp-server.ts + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + import { z } from "zod"; + + const server = new McpServer({ + name: "rand", + version: "1.0.0", + }); + + server.tool( + "random_number", + "Generate a random integer between min and max (inclusive)", + { + min: z.number().describe("Minimum value"), + max: z.number().describe("Maximum value"), + }, + async ({ min, max }) => ({ + content: [{ type: "text", text: String(Math.floor(Math.random() * (max - min + 1)) + min) }], + }), + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); + ``` + + This is a simple example. Your MCP server runs inside the sandbox, so you can execute any code you'd like: query databases, call internal APIs, run shell commands, or interact with any service available in the container. + </Step> + + <Step title="Package the MCP server"> + Bundle into a single JS file so it can be uploaded and executed without a `node_modules` folder. + + ```bash + npx esbuild src/mcp-server.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/mcp-server.cjs + ``` + + This creates `dist/mcp-server.cjs` ready to upload. + </Step> + + <Step title="Create sandbox and upload MCP server"> + Start your sandbox, then write the bundled file into it. + + <CodeGroup> + ```ts TypeScript + import { SandboxAgent } from "sandbox-agent"; + import fs from "node:fs"; + + const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, + }); + + const content = await fs.promises.readFile("./dist/mcp-server.cjs"); + await client.writeFsFile( + { path: "/opt/mcp/custom-tools/mcp-server.cjs" }, + content, + ); + ``` + + ```bash cURL + curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/mcp/custom-tools/mcp-server.cjs" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + --data-binary @./dist/mcp-server.cjs + ``` + </CodeGroup> + </Step> + + <Step title="Create a session"> + Point an MCP server config at the bundled JS file. When the session starts, Sandbox Agent spawns the MCP server process and routes tool calls to it. + + <CodeGroup> + ```ts TypeScript + await client.createSession("custom-tools", { + agent: "claude", + mcp: { + customTools: { + type: "local", + command: ["node", "/opt/mcp/custom-tools/mcp-server.cjs"], + }, + }, + }); + ``` + + ```bash cURL + curl -X POST "http://127.0.0.1:2468/v1/sessions/custom-tools" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "agent": "claude", + "mcp": { + "customTools": { + "type": "local", + "command": ["node", "/opt/mcp/custom-tools/mcp-server.cjs"] + } + } + }' + ``` + </CodeGroup> + </Step> +</Steps> + +## Option B: Tools via Skills + +Skills are markdown files that instruct the agent how to use a script. Upload the script and a skill file, then point the session at the skill directory. + +<Steps> + <Step title="Write your script"> + Write a script that the agent will execute. This runs inside the sandbox just like an MCP server, but the agent invokes it directly via its shell tool. + + ```ts src/random-number.ts + const min = Number(process.argv[2]); + const max = Number(process.argv[3]); + + if (Number.isNaN(min) || Number.isNaN(max)) { + console.error("Usage: random-number <min> <max>"); + process.exit(1); + } + + console.log(Math.floor(Math.random() * (max - min + 1)) + min); + ``` + </Step> + + <Step title="Write a skill file"> + Create a `SKILL.md` that tells the agent what the script does and how to run it. The frontmatter `name` and `description` fields are required. See [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for tips on writing effective skills. + + ```md SKILL.md + --- + name: random-number + description: Generate a random integer between min and max (inclusive). Use when the user asks for a random number. + --- + + To generate a random number, run: + + ```bash + node /opt/skills/random-number/random-number.cjs <min> <max> + ``` + + This prints a single random integer between min and max (inclusive). + </Step> + + <Step title="Package the script"> + Bundle the script just like an MCP server so it has no dependencies at runtime. + + ```bash + npx esbuild src/random-number.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/random-number.cjs + ``` + </Step> + + <Step title="Create sandbox and upload files"> + Upload both the bundled script and the skill file. + + <CodeGroup> + ```ts TypeScript + import { SandboxAgent } from "sandbox-agent"; + import fs from "node:fs"; + + const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, + }); + + const script = await fs.promises.readFile("./dist/random-number.cjs"); + await client.writeFsFile( + { path: "/opt/skills/random-number/random-number.cjs" }, + script, + ); + + const skill = await fs.promises.readFile("./SKILL.md"); + await client.writeFsFile( + { path: "/opt/skills/random-number/SKILL.md" }, + skill, + ); + ``` + + ```bash cURL + curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/skills/random-number/random-number.cjs" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + --data-binary @./dist/random-number.cjs + + curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/skills/random-number/SKILL.md" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + --data-binary @./SKILL.md + ``` + </CodeGroup> + </Step> + + <Step title="Create a session"> + Point the session at the skill directory. The agent reads `SKILL.md` and learns how to use your script. + + <CodeGroup> + ```ts TypeScript + await client.createSession("custom-tools", { + agent: "claude", + skills: { + sources: [ + { type: "local", source: "/opt/skills/random-number" }, + ], + }, + }); + ``` + + ```bash cURL + curl -X POST "http://127.0.0.1:2468/v1/sessions/custom-tools" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "agent": "claude", + "skills": { + "sources": [ + { "type": "local", "source": "/opt/skills/random-number" } + ] + } + }' + ``` + </CodeGroup> + </Step> +</Steps> + +## Notes + +- The sandbox image must include a Node.js runtime that can execute the bundled files. diff --git a/docs/deploy/computesdk.mdx b/docs/deploy/computesdk.mdx new file mode 100644 index 0000000..5e07da0 --- /dev/null +++ b/docs/deploy/computesdk.mdx @@ -0,0 +1,214 @@ +--- +title: "ComputeSDK" +description: "Deploy the daemon using ComputeSDK's provider-agnostic sandbox API." +--- + +[ComputeSDK](https://computesdk.com) provides a unified interface for managing sandboxes across multiple providers. Write once, deploy anywhere—switch providers by changing environment variables. + +## Prerequisites + +- `COMPUTESDK_API_KEY` from [console.computesdk.com](https://console.computesdk.com) +- Provider API key (one of: `E2B_API_KEY`, `DAYTONA_API_KEY`, `VERCEL_TOKEN`, `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET`, `BLAXEL_API_KEY`, `CSB_API_KEY`) +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents + +## TypeScript Example + +```typescript +import { + compute, + detectProvider, + getMissingEnvVars, + getProviderConfigFromEnv, + isProviderAuthComplete, + isValidProvider, + PROVIDER_NAMES, + type ExplicitComputeConfig, + type ProviderName, +} from "computesdk"; +import { SandboxAgent } from "sandbox-agent"; + +const PORT = 3000; +const REQUEST_TIMEOUT_MS = + Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000; + +/** + * Detects and validates the provider to use. + * Priority: COMPUTESDK_PROVIDER env var > auto-detection from API keys + */ +function resolveProvider(): ProviderName { + const providerOverride = process.env.COMPUTESDK_PROVIDER; + + if (providerOverride) { + if (!isValidProvider(providerOverride)) { + throw new Error( + `Unsupported provider "${providerOverride}". Supported: ${PROVIDER_NAMES.join(", ")}` + ); + } + if (!isProviderAuthComplete(providerOverride)) { + const missing = getMissingEnvVars(providerOverride); + throw new Error( + `Missing credentials for "${providerOverride}". Set: ${missing.join(", ")}` + ); + } + return providerOverride as ProviderName; + } + + const detected = detectProvider(); + if (!detected) { + throw new Error( + `No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}` + ); + } + return detected as ProviderName; +} + +function configureComputeSDK(): void { + const provider = resolveProvider(); + + const config: ExplicitComputeConfig = { + provider, + computesdkApiKey: process.env.COMPUTESDK_API_KEY, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + }; + + // Add provider-specific config from environment + const providerConfig = getProviderConfigFromEnv(provider); + if (Object.keys(providerConfig).length > 0) { + (config as any)[provider] = providerConfig; + } + + compute.setConfig(config); +} + +configureComputeSDK(); + +// Build environment variables to pass to sandbox +const envs: Record<string, string> = {}; +if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +// Create sandbox +const sandbox = await compute.sandbox.create({ + envs: Object.keys(envs).length > 0 ? envs : undefined, +}); + +// Helper to run commands with error handling +const run = async (cmd: string, options?: { background?: boolean }) => { + const result = await sandbox.runCommand(cmd, options); + if (typeof result?.exitCode === "number" && result.exitCode !== 0) { + throw new Error(`Command failed: ${cmd} (exit ${result.exitCode})\n${result.stderr || ""}`); + } + return result; +}; + +// Install sandbox-agent +await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"); + +// Install agents conditionally based on available API keys +if (envs.ANTHROPIC_API_KEY) { + await run("sandbox-agent install-agent claude"); +} +if (envs.OPENAI_API_KEY) { + await run("sandbox-agent install-agent codex"); +} + +// Start the server in the background +await run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, { background: true }); + +// Get the public URL for the sandbox +const baseUrl = await sandbox.getUrl({ port: PORT }); + +// Wait for server to be ready +const deadline = Date.now() + REQUEST_TIMEOUT_MS; +while (Date.now() < deadline) { + try { + const response = await fetch(`${baseUrl}/v1/health`); + if (response.ok) { + const data = await response.json(); + if (data?.status === "ok") break; + } + } catch { + // Server not ready yet + } + await new Promise((r) => setTimeout(r, 500)); +} + +// Connect to the server +const client = await SandboxAgent.connect({ baseUrl }); + +// Detect which agent to use based on available API keys +const agent = envs.ANTHROPIC_API_KEY ? "claude" : "codex"; + +// Create a session and start coding +await client.createSession("my-session", { agent }); + +await client.postMessage("my-session", { + message: "Summarize this repository", +}); + +for await (const event of client.streamEvents("my-session")) { + console.log(event.type, event.data); +} + +// Cleanup +await sandbox.destroy(); +``` + +## Supported Providers + +ComputeSDK auto-detects your provider from environment variables: + +| Provider | Environment Variables | +|----------|----------------------| +| E2B | `E2B_API_KEY` | +| Daytona | `DAYTONA_API_KEY` | +| Vercel | `VERCEL_TOKEN` or `VERCEL_OIDC_TOKEN` | +| Modal | `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` | +| Blaxel | `BLAXEL_API_KEY` | +| CodeSandbox | `CSB_API_KEY` | + +## Notes + +- **Provider resolution order**: `COMPUTESDK_PROVIDER` env var takes priority, otherwise auto-detection from API keys. +- **Conditional agent installation**: Only agents with available API keys are installed, reducing setup time. +- **Command error handling**: The example validates exit codes and throws on failures for easier debugging. +- `sandbox.runCommand(..., { background: true })` keeps the server running while your app continues. +- `sandbox.getUrl({ port })` returns a public URL for the sandbox port. +- Always destroy the sandbox when you are done to avoid leaking resources. +- If sandbox creation times out, set `COMPUTESDK_TIMEOUT_MS` to a higher value (default: 120000ms). + +## Explicit Provider Selection + +To force a specific provider instead of auto-detection, set the `COMPUTESDK_PROVIDER` environment variable: + +```bash +export COMPUTESDK_PROVIDER=e2b +``` + +Or configure programmatically using `getProviderConfigFromEnv()`: + +```typescript +import { compute, getProviderConfigFromEnv, type ExplicitComputeConfig } from "computesdk"; + +const config: ExplicitComputeConfig = { + provider: "e2b", + computesdkApiKey: process.env.COMPUTESDK_API_KEY, + requestTimeoutMs: 120_000, +}; + +// Automatically populate provider-specific config from environment +const providerConfig = getProviderConfigFromEnv("e2b"); +if (Object.keys(providerConfig).length > 0) { + (config as any).e2b = providerConfig; +} + +compute.setConfig(config); +``` + +## Direct Mode (No ComputeSDK API Key) + +To bypass the ComputeSDK gateway and use provider SDKs directly, see the provider-specific examples: + +- [E2B](/deploy/e2b) +- [Daytona](/deploy/daytona) +- [Vercel](/deploy/vercel) diff --git a/docs/deploy/index.mdx b/docs/deploy/index.mdx deleted file mode 100644 index 11d6790..0000000 --- a/docs/deploy/index.mdx +++ /dev/null @@ -1,27 +0,0 @@ ---- -title: "Deploy" -sidebarTitle: "Overview" -description: "Choose where to run the sandbox-agent server." -icon: "server" ---- - -<CardGroup cols={2}> - <Card title="Local" icon="laptop" href="/deploy/local"> - Run locally for development. The SDK can auto-spawn the server. - </Card> - <Card title="E2B" icon="cube" href="/deploy/e2b"> - Deploy inside an E2B sandbox with network access. - </Card> - <Card title="Vercel" icon="triangle" href="/deploy/vercel"> - Deploy inside a Vercel Sandbox with port forwarding. - </Card> - <Card title="Cloudflare" icon="cloud" href="/deploy/cloudflare"> - Deploy inside a Cloudflare Sandbox with port exposure. - </Card> - <Card title="Daytona" icon="cloud" href="/deploy/daytona"> - Run in a Daytona workspace with port forwarding. - </Card> - <Card title="Docker" icon="docker" href="/deploy/docker"> - Build and run in a container (development only). - </Card> -</CardGroup> diff --git a/docs/docs.json b/docs/docs.json index 61bfbf9..89dd714 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -25,65 +25,98 @@ }, "navbar": { "links": [ + { + "label": "Gigacode", + "icon": "terminal", + "href": "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" + }, { "label": "Discord", "icon": "discord", "href": "https://discord.gg/auCecybynK" }, { - "label": "GitHub", - "icon": "github", + "type": "github", "href": "https://github.com/rivet-dev/sandbox-agent" } ] }, "navigation": { - "pages": [ + "tabs": [ { - "group": "Getting started", + "tab": "Documentation", "pages": [ - "quickstart", - "building-chat-ui", - "manage-sessions", - "opencode-compatibility" - ] - }, - { - "group": "Deploy", - "pages": [ - "deploy/index", - "deploy/local", - "deploy/e2b", - "deploy/daytona", - "deploy/vercel", - "deploy/cloudflare", - "deploy/docker" - ] - }, - { - "group": "SDKs", - "pages": ["sdks/typescript", "sdks/python"] - }, - { - "group": "Reference", - "pages": [ - "cli", - "inspector", - "session-transcript-schema", - "gigacode", { - "group": "AI", - "pages": ["ai/skill", "ai/llms-txt"] + "group": "Getting started", + "pages": [ + "quickstart", + "building-chat-ui", + "manage-sessions", + { + "group": "Deploy", + "icon": "server", + "pages": [ + "deploy/local", + "deploy/computesdk", + "deploy/e2b", + "deploy/daytona", + "deploy/vercel", + "deploy/cloudflare", + "deploy/docker" + ] + } + ] }, { - "group": "Advanced", - "pages": ["daemon", "cors", "telemetry"] + "group": "SDKs", + "pages": ["sdks/typescript", "sdks/python"] + }, + { + "group": "Agent Features", + "pages": [ + "agent-sessions", + "attachments", + "skills-config", + "mcp-config", + "custom-tools" + ] + }, + { + "group": "Features", + "pages": ["file-system"] + }, + { + "group": "Reference", + "pages": [ + "cli", + "inspector", + "session-transcript-schema", + "opencode-compatibility", + { + "group": "More", + "pages": [ + "credentials", + "daemon", + "cors", + "telemetry", + { + "group": "AI", + "pages": ["ai/skill", "ai/llms-txt"] + } + ] + } + ] } ] }, { - "group": "HTTP API Reference", - "openapi": "openapi.json" + "tab": "HTTP API", + "pages": [ + { + "group": "HTTP Reference", + "openapi": "openapi.json" + } + ] } ] } diff --git a/docs/file-system.mdx b/docs/file-system.mdx new file mode 100644 index 0000000..3aae00c --- /dev/null +++ b/docs/file-system.mdx @@ -0,0 +1,184 @@ +--- +title: "File System" +description: "Read, write, and manage files inside the sandbox." +sidebarTitle: "File System" +icon: "folder" +--- + +The filesystem API lets you list, read, write, move, and delete files inside the sandbox, plus upload batches of files via tar archives. + +## Path Resolution + +- Absolute paths are used as-is. +- Relative paths use the session working directory when `sessionId` is provided. +- Without `sessionId`, relative paths resolve against the server home directory. +- Relative paths cannot contain `..` or absolute prefixes; requests that attempt to escape the root are rejected. + +The session working directory is the server process current working directory at the moment the session is created. + +## List Entries + +<CodeGroup> +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +const entries = await client.listFsEntries({ + path: "./workspace", + sessionId: "my-session", +}); + +console.log(entries); +``` + +```bash cURL +curl -X GET "http://127.0.0.1:2468/v1/fs/entries?path=./workspace&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` +</CodeGroup> + +## Read And Write Files + +`PUT /v1/fs/file` writes raw bytes. `GET /v1/fs/file` returns raw bytes. + +<CodeGroup> +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.writeFsFile({ path: "./notes.txt", sessionId: "my-session" }, "hello"); + +const bytes = await client.readFsFile({ + path: "./notes.txt", + sessionId: "my-session", +}); + +const text = new TextDecoder().decode(bytes); +console.log(text); +``` + +```bash cURL +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./notes.txt&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + --data-binary "hello" + +curl -X GET "http://127.0.0.1:2468/v1/fs/file?path=./notes.txt&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + --output ./notes.txt +``` +</CodeGroup> + +## Create Directories + +<CodeGroup> +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.mkdirFs({ + path: "./data", + sessionId: "my-session", +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=./data&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` +</CodeGroup> + +## Move, Delete, And Stat + +<CodeGroup> +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.moveFs( + { from: "./notes.txt", to: "./notes-old.txt", overwrite: true }, + { sessionId: "my-session" }, +); + +const stat = await client.statFs({ + path: "./notes-old.txt", + sessionId: "my-session", +}); + +await client.deleteFsEntry({ + path: "./notes-old.txt", + sessionId: "my-session", +}); + +console.log(stat); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/fs/move?sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"from":"./notes.txt","to":"./notes-old.txt","overwrite":true}' + +curl -X GET "http://127.0.0.1:2468/v1/fs/stat?path=./notes-old.txt&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" + +curl -X DELETE "http://127.0.0.1:2468/v1/fs/entry?path=./notes-old.txt&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" +``` +</CodeGroup> + +## Batch Upload (Tar) + +Batch upload accepts `application/x-tar` only and extracts into the destination directory. The response returns absolute paths for extracted files, capped at 1024 entries. + +<CodeGroup> +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; +import fs from "node:fs"; +import path from "node:path"; +import tar from "tar"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +const archivePath = path.join(process.cwd(), "skills.tar"); +await tar.c({ + cwd: "./skills", + file: archivePath, +}, ["."]); + +const tarBuffer = await fs.promises.readFile(archivePath); +const result = await client.uploadFsBatch(tarBuffer, { + path: "./skills", + sessionId: "my-session", +}); + +console.log(result); +``` + +```bash cURL +tar -cf skills.tar -C ./skills . + +curl -X POST "http://127.0.0.1:2468/v1/fs/upload-batch?path=./skills&sessionId=my-session" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/x-tar" \ + --data-binary @skills.tar +``` +</CodeGroup> diff --git a/docs/inspector.mdx b/docs/inspector.mdx index 4c0aad8..3a964b6 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -1,7 +1,6 @@ --- title: "Inspector" description: "Debug and inspect agent sessions with the Inspector UI." -icon: "magnifying-glass" --- The Inspector is a web-based GUI for debugging and inspecting Sandbox Agent sessions. Use it to view events, send messages, and troubleshoot agent behavior in real-time. diff --git a/docs/mcp-config.mdx b/docs/mcp-config.mdx new file mode 100644 index 0000000..668d937 --- /dev/null +++ b/docs/mcp-config.mdx @@ -0,0 +1,122 @@ +--- +title: "MCP" +description: "Configure MCP servers for agent sessions." +sidebarTitle: "MCP" +icon: "plug" +--- + +MCP (Model Context Protocol) servers extend agents with tools. Sandbox Agent can auto-load MCP servers when a session starts by passing an `mcp` map in the create-session request. + +## Session Config + +The `mcp` field is a map of server name to config. Use `type: "local"` for stdio servers and `type: "remote"` for HTTP/SSE servers: + +<CodeGroup> + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.createSession("claude-mcp", { + agent: "claude", + mcp: { + filesystem: { + type: "local", + command: "my-mcp-server", + args: ["--root", "."], + }, + github: { + type: "remote", + url: "https://example.com/mcp", + headers: { + Authorization: "Bearer ${GITHUB_TOKEN}", + }, + }, + }, +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/claude-mcp" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "agent": "claude", + "mcp": { + "filesystem": { + "type": "local", + "command": "my-mcp-server", + "args": ["--root", "."] + }, + "github": { + "type": "remote", + "url": "https://example.com/mcp", + "headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}" + } + } + } + }' +``` + +</CodeGroup> + +## Config Fields + +### Local Server + +Stdio servers that run inside the sandbox. + +| Field | Description | +|---|---| +| `type` | `local` | +| `command` | string or array (`["node", "server.js"]`) | +| `args` | array of string arguments | +| `env` | environment variables map | +| `enabled` | enable or disable the server | +| `timeoutMs` | tool timeout override | +| `cwd` | working directory for the MCP process | + +```json +{ + "type": "local", + "command": ["node", "./mcp/server.js"], + "args": ["--root", "."], + "env": { "LOG_LEVEL": "debug" }, + "cwd": "/workspace" +} +``` + +### Remote Server + +HTTP/SSE servers accessed over the network. + +| Field | Description | +|---|---| +| `type` | `remote` | +| `url` | MCP server URL | +| `headers` | static headers map | +| `bearerTokenEnvVar` | env var name to inject into `Authorization: Bearer ...` | +| `envHeaders` | map of header name to env var name | +| `oauth` | object with `clientId`, `clientSecret`, `scope`, or `false` to disable | +| `enabled` | enable or disable the server | +| `timeoutMs` | tool timeout override | +| `transport` | `http` or `sse` | + +```json +{ + "type": "remote", + "url": "https://example.com/mcp", + "headers": { "x-client": "sandbox-agent" }, + "bearerTokenEnvVar": "MCP_TOKEN", + "transport": "sse" +} +``` + +## Custom MCP Servers + +To bundle and upload your own MCP server into the sandbox, see [Custom Tools](/custom-tools). diff --git a/docs/openapi.json b/docs/openapi.json index bbe1c2e..7cf6231 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "sandbox-agent", - "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Amp, and Pi.", + "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, Cursor, Amp, and Pi.", "contact": { "name": "Rivet Gaming, LLC", "email": "developer@rivet.gg" @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.1.7" + "version": "0.1.12-rc.1" }, "servers": [ { @@ -23,10 +23,12 @@ "tags": [ "agents" ], + "summary": "List Agents", + "description": "Returns all available coding agents and their installation status.", "operationId": "list_agents", "responses": { "200": { - "description": "", + "description": "List of available agents", "content": { "application/json": { "schema": { @@ -43,6 +45,8 @@ "tags": [ "agents" ], + "summary": "Install Agent", + "description": "Installs or updates a coding agent (e.g. claude, codex, opencode, amp).", "operationId": "install_agent", "parameters": [ { @@ -70,7 +74,7 @@ "description": "Agent installed" }, "400": { - "description": "", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -80,7 +84,7 @@ } }, "404": { - "description": "", + "description": "Agent not found", "content": { "application/json": { "schema": { @@ -90,7 +94,7 @@ } }, "500": { - "description": "", + "description": "Installation failed", "content": { "application/json": { "schema": { @@ -107,6 +111,8 @@ "tags": [ "agents" ], + "summary": "List Agent Models", + "description": "Returns the available LLM models for an agent.", "operationId": "get_agent_models", "parameters": [ { @@ -121,7 +127,7 @@ ], "responses": { "200": { - "description": "", + "description": "Available models", "content": { "application/json": { "schema": { @@ -130,8 +136,8 @@ } } }, - "400": { - "description": "", + "404": { + "description": "Agent not found", "content": { "application/json": { "schema": { @@ -148,6 +154,8 @@ "tags": [ "agents" ], + "summary": "List Agent Modes", + "description": "Returns the available interaction modes for an agent.", "operationId": "get_agent_modes", "parameters": [ { @@ -162,7 +170,7 @@ ], "responses": { "200": { - "description": "", + "description": "Available modes", "content": { "application/json": { "schema": { @@ -172,7 +180,7 @@ } }, "400": { - "description": "", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -184,15 +192,398 @@ } } }, + "/v1/fs/entries": { + "get": { + "tags": [ + "fs" + ], + "summary": "List Directory", + "description": "Lists files and directories at the given path.", + "operationId": "fs_entries", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Path to list (relative or absolute)", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Directory listing", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsEntry" + } + } + } + } + } + } + } + }, + "/v1/fs/entry": { + "delete": { + "tags": [ + "fs" + ], + "summary": "Delete Entry", + "description": "Deletes a file or directory.", + "operationId": "fs_delete_entry", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File or directory path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "recursive", + "in": "query", + "description": "Delete directories recursively", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Delete result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsActionResponse" + } + } + } + } + } + } + }, + "/v1/fs/file": { + "get": { + "tags": [ + "fs" + ], + "summary": "Read File", + "description": "Reads the raw bytes of a file.", + "operationId": "fs_read_file", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File path (relative or absolute)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "File content", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "put": { + "tags": [ + "fs" + ], + "summary": "Write File", + "description": "Writes raw bytes to a file, creating it if it doesn't exist.", + "operationId": "fs_write_file", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File path (relative or absolute)", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Write result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsWriteResponse" + } + } + } + } + } + } + }, + "/v1/fs/mkdir": { + "post": { + "tags": [ + "fs" + ], + "summary": "Create Directory", + "description": "Creates a directory, including any missing parent directories.", + "operationId": "fs_mkdir", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Directory path to create", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Directory created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsActionResponse" + } + } + } + } + } + } + }, + "/v1/fs/move": { + "post": { + "tags": [ + "fs" + ], + "summary": "Move Entry", + "description": "Moves or renames a file or directory.", + "operationId": "fs_move", + "parameters": [ + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Move result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsMoveResponse" + } + } + } + } + } + } + }, + "/v1/fs/stat": { + "get": { + "tags": [ + "fs" + ], + "summary": "Get File Info", + "description": "Returns metadata (size, timestamps, type) for a path.", + "operationId": "fs_stat", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Path to stat", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "File metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsStat" + } + } + } + } + } + } + }, + "/v1/fs/upload-batch": { + "post": { + "tags": [ + "fs" + ], + "summary": "Upload Files", + "description": "Uploads a tar.gz archive and extracts it to the destination directory.", + "operationId": "fs_upload_batch", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Destination directory for extraction", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "session_id", + "in": "query", + "description": "Session id for relative paths", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upload result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsUploadBatchResponse" + } + } + } + } + } + } + }, "/v1/health": { "get": { "tags": [ "meta" ], + "summary": "Health Check", + "description": "Returns the server health status.", "operationId": "get_health", "responses": { "200": { - "description": "", + "description": "Server is healthy", "content": { "application/json": { "schema": { @@ -209,10 +600,12 @@ "tags": [ "sessions" ], + "summary": "List Sessions", + "description": "Returns all active sessions.", "operationId": "list_sessions", "responses": { "200": { - "description": "", + "description": "List of active sessions", "content": { "application/json": { "schema": { @@ -229,6 +622,8 @@ "tags": [ "sessions" ], + "summary": "Create Session", + "description": "Creates a new agent session with the given configuration.", "operationId": "create_session", "parameters": [ { @@ -253,7 +648,7 @@ }, "responses": { "200": { - "description": "", + "description": "Session created", "content": { "application/json": { "schema": { @@ -263,7 +658,7 @@ } }, "400": { - "description": "", + "description": "Invalid request", "content": { "application/json": { "schema": { @@ -273,7 +668,7 @@ } }, "409": { - "description": "", + "description": "Session already exists", "content": { "application/json": { "schema": { @@ -290,6 +685,8 @@ "tags": [ "sessions" ], + "summary": "Get Events", + "description": "Returns session events with optional offset-based pagination.", "operationId": "get_events", "parameters": [ { @@ -338,7 +735,7 @@ ], "responses": { "200": { - "description": "", + "description": "Session events", "content": { "application/json": { "schema": { @@ -348,7 +745,7 @@ } }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -365,6 +762,8 @@ "tags": [ "sessions" ], + "summary": "Subscribe to Events (SSE)", + "description": "Opens an SSE stream for real-time session events.", "operationId": "get_events_sse", "parameters": [ { @@ -411,6 +810,8 @@ "tags": [ "sessions" ], + "summary": "Send Message", + "description": "Sends a message to a session and returns immediately.", "operationId": "post_message", "parameters": [ { @@ -438,7 +839,7 @@ "description": "Message accepted" }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -455,6 +856,8 @@ "tags": [ "sessions" ], + "summary": "Send Message (Streaming)", + "description": "Sends a message and returns an SSE event stream of the agent's response.", "operationId": "post_message_stream", "parameters": [ { @@ -492,7 +895,7 @@ "description": "SSE event stream" }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -509,6 +912,8 @@ "tags": [ "sessions" ], + "summary": "Reply to Permission", + "description": "Approves or denies a permission request from the agent.", "operationId": "reply_permission", "parameters": [ { @@ -545,7 +950,7 @@ "description": "Permission reply accepted" }, "404": { - "description": "", + "description": "Session or permission not found", "content": { "application/json": { "schema": { @@ -562,6 +967,8 @@ "tags": [ "sessions" ], + "summary": "Reject Question", + "description": "Rejects a human-in-the-loop question from the agent.", "operationId": "reject_question", "parameters": [ { @@ -588,7 +995,7 @@ "description": "Question rejected" }, "404": { - "description": "", + "description": "Session or question not found", "content": { "application/json": { "schema": { @@ -605,6 +1012,8 @@ "tags": [ "sessions" ], + "summary": "Reply to Question", + "description": "Replies to a human-in-the-loop question from the agent.", "operationId": "reply_question", "parameters": [ { @@ -641,7 +1050,7 @@ "description": "Question answered" }, "404": { - "description": "", + "description": "Session or question not found", "content": { "application/json": { "schema": { @@ -658,6 +1067,8 @@ "tags": [ "sessions" ], + "summary": "Terminate Session", + "description": "Terminates a running session and cleans up resources.", "operationId": "terminate_session", "parameters": [ { @@ -675,7 +1086,7 @@ "description": "Session terminated" }, "404": { - "description": "", + "description": "Session not found", "content": { "application/json": { "schema": { @@ -710,7 +1121,6 @@ "mcpTools", "streamingDeltas", "itemStarted", - "variants", "sharedProcess" ], "properties": { @@ -768,9 +1178,6 @@ }, "toolResults": { "type": "boolean" - }, - "variants": { - "type": "boolean" } } }, @@ -805,12 +1212,17 @@ "required": [ "id", "installed", + "credentialsAvailable", "capabilities" ], "properties": { "capabilities": { "$ref": "#/components/schemas/AgentCapabilities" }, + "credentialsAvailable": { + "type": "boolean", + "description": "Whether the agent's required provider credentials are available" + }, "id": { "type": "string" }, @@ -1152,6 +1564,17 @@ "type": "string", "nullable": true }, + "directory": { + "type": "string", + "nullable": true + }, + "mcp": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/McpServerConfig" + }, + "nullable": true + }, "model": { "type": "string", "nullable": true @@ -1160,6 +1583,18 @@ "type": "string", "nullable": true }, + "skills": { + "allOf": [ + { + "$ref": "#/components/schemas/SkillsConfig" + } + ], + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, "variant": { "type": "string", "nullable": true @@ -1278,6 +1713,216 @@ "patch" ] }, + "FsActionResponse": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, + "FsDeleteQuery": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsEntriesQuery": { + "type": "object", + "properties": { + "path": { + "type": "string", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsEntry": { + "type": "object", + "required": [ + "name", + "path", + "entryType", + "size" + ], + "properties": { + "entryType": { + "$ref": "#/components/schemas/FsEntryType" + }, + "modified": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "FsEntryType": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "FsMoveRequest": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "overwrite": { + "type": "boolean", + "nullable": true + }, + "to": { + "type": "string" + } + } + }, + "FsMoveResponse": { + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + "FsPathQuery": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsSessionQuery": { + "type": "object", + "properties": { + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsStat": { + "type": "object", + "required": [ + "path", + "entryType", + "size" + ], + "properties": { + "entryType": { + "$ref": "#/components/schemas/FsEntryType" + }, + "modified": { + "type": "string", + "nullable": true + }, + "path": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "FsUploadBatchQuery": { + "type": "object", + "properties": { + "path": { + "type": "string", + "nullable": true + }, + "sessionId": { + "type": "string", + "nullable": true + } + } + }, + "FsUploadBatchResponse": { + "type": "object", + "required": [ + "paths", + "truncated" + ], + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "truncated": { + "type": "boolean" + } + } + }, + "FsWriteResponse": { + "type": "object", + "required": [ + "path", + "bytesWritten" + ], + "properties": { + "bytesWritten": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "path": { + "type": "string" + } + } + }, "HealthResponse": { "type": "object", "required": [ @@ -1347,12 +1992,198 @@ "failed" ] }, + "McpCommand": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "McpOAuthConfig": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "nullable": true + }, + "clientSecret": { + "type": "string", + "nullable": true + }, + "scope": { + "type": "string", + "nullable": true + } + } + }, + "McpOAuthConfigOrDisabled": { + "oneOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfig" + }, + { + "type": "boolean" + } + ] + }, + "McpRemoteTransport": { + "type": "string", + "enum": [ + "http", + "sse" + ] + }, + "McpServerConfig": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "command": { + "$ref": "#/components/schemas/McpCommand" + }, + "cwd": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "local" + ] + } + } + }, + { + "type": "object", + "required": [ + "url", + "type" + ], + "properties": { + "bearerTokenEnvVar": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "envHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "oauth": { + "allOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfigOrDisabled" + } + ], + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "transport": { + "allOf": [ + { + "$ref": "#/components/schemas/McpRemoteTransport" + } + ], + "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "remote" + ] + }, + "url": { + "type": "string" + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, + "MessageAttachment": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "filename": { + "type": "string", + "nullable": true + }, + "mime": { + "type": "string", + "nullable": true + }, + "path": { + "type": "string" + } + } + }, "MessageRequest": { "type": "object", "required": [ "message" ], "properties": { + "attachments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessageAttachment" + } + }, "message": { "type": "string" } @@ -1403,8 +2234,9 @@ "type": "string", "enum": [ "requested", - "approved", - "denied" + "accept", + "accept_for_session", + "reject" ] }, "ProblemDetails": { @@ -1589,7 +2421,9 @@ "agentMode", "permissionMode", "ended", - "eventCount" + "eventCount", + "createdAt", + "updatedAt" ], "properties": { "agent": { @@ -1598,6 +2432,14 @@ "agentMode": { "type": "string" }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "directory": { + "type": "string", + "nullable": true + }, "ended": { "type": "boolean" }, @@ -1606,6 +2448,13 @@ "format": "int64", "minimum": 0 }, + "mcp": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/McpServerConfig" + }, + "nullable": true + }, "model": { "type": "string", "nullable": true @@ -1620,6 +2469,22 @@ "sessionId": { "type": "string" }, + "skills": { + "allOf": [ + { + "$ref": "#/components/schemas/SkillsConfig" + } + ], + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "integer", + "format": "int64" + }, "variant": { "type": "string", "nullable": true @@ -1648,6 +2513,50 @@ } } }, + "SkillSource": { + "type": "object", + "required": [ + "type", + "source" + ], + "properties": { + "ref": { + "type": "string", + "nullable": true + }, + "skills": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "source": { + "type": "string" + }, + "subpath": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string" + } + } + }, + "SkillsConfig": { + "type": "object", + "required": [ + "sources" + ], + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SkillSource" + } + } + } + }, "StderrOutput": { "type": "object", "required": [ @@ -1683,6 +2592,31 @@ "daemon" ] }, + "TurnEventData": { + "type": "object", + "required": [ + "phase" + ], + "properties": { + "metadata": { + "nullable": true + }, + "phase": { + "$ref": "#/components/schemas/TurnPhase" + }, + "turn_id": { + "type": "string", + "nullable": true + } + } + }, + "TurnPhase": { + "type": "string", + "enum": [ + "started", + "ended" + ] + }, "TurnStreamQuery": { "type": "object", "properties": { @@ -1742,6 +2676,9 @@ }, "UniversalEventData": { "oneOf": [ + { + "$ref": "#/components/schemas/TurnEventData" + }, { "$ref": "#/components/schemas/SessionStartedData" }, @@ -1773,6 +2710,8 @@ "enum": [ "session.started", "session.ended", + "turn.started", + "turn.ended", "item.started", "item.delta", "item.completed", @@ -1840,6 +2779,10 @@ { "name": "sessions", "description": "Session management" + }, + { + "name": "fs", + "description": "Filesystem operations" } ] } \ No newline at end of file diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx index 004f048..559c16e 100644 --- a/docs/opencode-compatibility.mdx +++ b/docs/opencode-compatibility.mdx @@ -1,7 +1,6 @@ --- -title: "OpenCode SDK & UI Support" +title: "OpenCode Compatibility" description: "Connect OpenCode clients, SDKs, and web UI to Sandbox Agent." -icon: "rectangle-terminal" --- <Warning> @@ -60,10 +59,11 @@ The OpenCode web UI can connect to Sandbox Agent for a full browser-based experi </Step> <Step title="Clone and Start the OpenCode Web App"> ```bash - git clone https://github.com/opencode-ai/opencode + git clone https://github.com/anomalyco/opencode cd opencode/packages/app export VITE_OPENCODE_SERVER_HOST=127.0.0.1 export VITE_OPENCODE_SERVER_PORT=2468 + bun install bun run dev -- --host 127.0.0.1 --port 5173 ``` </Step> @@ -113,6 +113,7 @@ for await (const event of events.stream) { - **CORS**: When using the web UI from a different origin, configure `--cors-allow-origin` - **Provider Selection**: Use the provider/model selector in the UI to choose which backing agent to use (claude, codex, opencode, amp) - **Models & Variants**: Providers are grouped by backing agent (e.g. Claude Code, Codex, Amp). OpenCode models are grouped by `OpenCode (<provider>)` to preserve their native provider grouping. Each model keeps its real model ID, and variants are exposed when available (Codex/OpenCode/Amp). +- **Optional Native Proxy for TUI/Config Endpoints**: Set `OPENCODE_COMPAT_PROXY_URL` (for example `http://127.0.0.1:4096`) to proxy select OpenCode-native endpoints to a real OpenCode server. This currently applies to `/command`, `/config`, `/global/config`, and `/tui/*`. If not set, sandbox-agent uses its built-in compatibility handlers. ## Endpoint Coverage @@ -134,10 +135,15 @@ See the full endpoint compatibility table below. Most endpoints are functional f | `GET /question` | ✓ | List pending questions | | `POST /question/{id}/reply` | ✓ | Answer agent questions | | `GET /provider` | ✓ | Returns provider metadata | +| `GET /command` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub response | +| `GET /config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub response | +| `PATCH /config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise local compatibility behavior | +| `GET /global/config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub response | +| `PATCH /global/config` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise local compatibility behavior | +| `/tui/*` | ↔ | Proxied to native OpenCode when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise local compatibility behavior | | `GET /agent` | − | Returns agent list | -| `GET /config` | − | Returns config | | *other endpoints* | − | Return empty/stub responses | -✓ Functional    − Stubbed +✓ Functional    ↔ Proxied (optional)    − Stubbed </Accordion> diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx index de52162..c9c004a 100644 --- a/docs/session-transcript-schema.mdx +++ b/docs/session-transcript-schema.mdx @@ -1,7 +1,6 @@ --- title: "Session Transcript Schema" description: "Universal event schema for session transcripts across all agents." -icon: "brackets-curly" --- Each coding agent outputs events in its own native format. The sandbox-agent converts these into a universal event schema, giving you a consistent session transcript regardless of which agent you use. @@ -27,7 +26,7 @@ This table shows which agent feature coverage appears in the universal event str | Reasoning/Thinking | - | ✓ | - | - | ✓ | | Command Execution | - | ✓ | - | - | | | File Changes | - | ✓ | - | - | | -| MCP Tools | - | ✓ | - | - | | +| MCP Tools | ✓ | ✓ | ✓ | ✓ | | | Streaming Deltas | ✓ | ✓ | ✓ | - | ✓ | | Variants | | ✓ | ✓ | ✓ | ✓ | @@ -125,6 +124,13 @@ Every event from the API is wrapped in a `UniversalEvent` envelope. | `session.started` | Session has started | `{ metadata?: any }` | | `session.ended` | Session has ended | `{ reason, terminated_by, message?, exit_code? }` | +### Turn Lifecycle + +| Type | Description | Data | +|------|-------------|------| +| `turn.started` | Turn has started | `{ phase: "started", turn_id?, metadata? }` | +| `turn.ended` | Turn has ended | `{ phase: "ended", turn_id?, metadata? }` | + **SessionEndedData** | Field | Type | Values | @@ -159,7 +165,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more) | Type | Description | Data | |------|-------------|------| | `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` | -| `permission.resolved` | Permission granted or denied | `{ permission_id, action, status, metadata? }` | +| `permission.resolved` | Permission decision recorded | `{ permission_id, action, status, metadata? }` | | `question.requested` | Question pending user input | `{ question_id, prompt, options, status }` | | `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` | @@ -169,7 +175,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more) |-------|------|-------------| | `permission_id` | string | Identifier for the permission request | | `action` | string | What the agent wants to do | -| `status` | string | `requested`, `approved`, `denied` | +| `status` | string | `requested`, `accept`, `accept_for_session`, `reject` | | `metadata` | any? | Additional context | **QuestionEventData** @@ -366,6 +372,8 @@ The daemon emits synthetic events (`synthetic: true`, `source: "daemon"`) to pro |-----------|------| | `session.started` | Agent doesn't emit explicit session start | | `session.ended` | Agent doesn't emit explicit session end | +| `turn.started` | Agent doesn't emit explicit turn start | +| `turn.ended` | Agent doesn't emit explicit turn end | | `item.started` | Agent doesn't emit item start events | | `item.delta` | Agent doesn't stream deltas natively | | `question.*` | Claude Code plan mode (from ExitPlanMode tool) | diff --git a/docs/skills-config.mdx b/docs/skills-config.mdx new file mode 100644 index 0000000..5f35866 --- /dev/null +++ b/docs/skills-config.mdx @@ -0,0 +1,87 @@ +--- +title: "Skills" +description: "Auto-load skills into agent sessions." +sidebarTitle: "Skills" +icon: "sparkles" +--- + +Skills are local instruction bundles stored in `SKILL.md` files. Sandbox Agent can fetch, discover, and link skill directories into agent-specific skill paths at session start using the `skills.sources` field. The format is fully compatible with [skills.sh](https://skills.sh). + +## Session Config + +Pass `skills.sources` when creating a session to load skills from GitHub repos, local paths, or git URLs. + +<CodeGroup> + +```ts TypeScript +import { SandboxAgent } from "sandbox-agent"; + +const client = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + token: process.env.SANDBOX_TOKEN, +}); + +await client.createSession("claude-skills", { + agent: "claude", + skills: { + sources: [ + { type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }, + { type: "local", source: "/workspace/my-custom-skill" }, + ], + }, +}); +``` + +```bash cURL +curl -X POST "http://127.0.0.1:2468/v1/sessions/claude-skills" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "agent": "claude", + "skills": { + "sources": [ + { "type": "github", "source": "rivet-dev/skills", "skills": ["sandbox-agent"] }, + { "type": "local", "source": "/workspace/my-custom-skill" } + ] + } + }' +``` + +</CodeGroup> + +Each skill directory must contain `SKILL.md`. See [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for tips on writing effective skills. + +## Skill Sources + +Each entry in `skills.sources` describes where to find skills. Three source types are supported: + +| Type | `source` value | Example | +|------|---------------|---------| +| `github` | `owner/repo` | `"rivet-dev/skills"` | +| `local` | Filesystem path | `"/workspace/my-skill"` | +| `git` | Git clone URL | `"https://git.example.com/skills.git"` | + +### Optional fields + +- **`skills`** — Array of skill directory names to include. When omitted, all discovered skills are installed. +- **`ref`** — Branch, tag, or commit to check out (default: HEAD). Applies to `github` and `git` types. +- **`subpath`** — Subdirectory within the repo to search for skills. + +## Custom Skills + +To write, upload, and configure your own skills inside the sandbox, see [Custom Tools](/custom-tools). + +## Advanced + +### Discovery logic + +After resolving a source to a local directory (cloning if needed), Sandbox Agent discovers skills by: +1. Checking if the directory itself contains `SKILL.md`. +2. Scanning `skills/` subdirectory for child directories containing `SKILL.md`. +3. Scanning immediate children of the directory for `SKILL.md`. + +Discovered skills are symlinked into project-local skill roots (`.claude/skills/<name>`, `.agents/skills/<name>`, `.opencode/skill/<name>`). + +### Caching + +GitHub sources are downloaded as zip archives and git sources are cloned to `~/.sandbox-agent/skills-cache/` and updated on subsequent session creations. GitHub sources do not require `git` to be installed. diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md new file mode 100644 index 0000000..57e13cd --- /dev/null +++ b/examples/CLAUDE.md @@ -0,0 +1,17 @@ +# Examples Instructions + +## Docker Isolation + +- Docker examples must behave like standalone sandboxes. +- Do not bind mount host files or host directories into Docker example containers. +- If an example needs tools, skills, or MCP servers, install them inside the container during setup. + +## Testing Examples + +Examples can be tested by starting them in the background and communicating directly with the sandbox-agent API: + +1. Start the example: `SANDBOX_AGENT_DEV=1 pnpm start &` +2. Note the base URL and session ID from the output. +3. Send messages: `curl -X POST http://127.0.0.1:<port>/v1/sessions/<sessionId>/messages -H "Content-Type: application/json" -d '{"message":"..."}'` +4. Poll events: `curl http://127.0.0.1:<port>/v1/sessions/<sessionId>/events` +5. Approve permissions: `curl -X POST http://127.0.0.1:<port>/v1/sessions/<sessionId>/permissions/<permissionId>/reply -H "Content-Type: application/json" -d '{"reply":"once"}'` diff --git a/examples/cloudflare/src/cloudflare.ts b/examples/cloudflare/src/index.ts similarity index 100% rename from examples/cloudflare/src/cloudflare.ts rename to examples/cloudflare/src/index.ts diff --git a/examples/cloudflare/wrangler.jsonc b/examples/cloudflare/wrangler.jsonc index a1401c4..5959215 100644 --- a/examples/cloudflare/wrangler.jsonc +++ b/examples/cloudflare/wrangler.jsonc @@ -1,7 +1,7 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "sandbox-agent-cloudflare", - "main": "src/cloudflare.ts", + "main": "src/index.ts", "compatibility_date": "2025-01-01", "compatibility_flags": ["nodejs_compat"], "assets": { diff --git a/examples/computesdk/package.json b/examples/computesdk/package.json new file mode 100644 index 0000000..c801516 --- /dev/null +++ b/examples/computesdk/package.json @@ -0,0 +1,19 @@ +{ + "name": "@sandbox-agent/example-computesdk", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/computesdk.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sandbox-agent/example-shared": "workspace:*", + "computesdk": "latest" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest", + "vitest": "^3.0.0" + } +} diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts new file mode 100644 index 0000000..b21dd53 --- /dev/null +++ b/examples/computesdk/src/computesdk.ts @@ -0,0 +1,156 @@ +import { + compute, + detectProvider, + getMissingEnvVars, + getProviderConfigFromEnv, + isProviderAuthComplete, + isValidProvider, + PROVIDER_NAMES, + type ExplicitComputeConfig, + type ProviderName, +} from "computesdk"; +import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; + +const PORT = 3000; +const REQUEST_TIMEOUT_MS = + Number.parseInt(process.env.COMPUTESDK_TIMEOUT_MS || "", 10) || 120_000; + +/** + * Detects and validates the provider to use. + * Priority: COMPUTESDK_PROVIDER env var > auto-detection from API keys + */ +function resolveProvider(): ProviderName { + const providerOverride = process.env.COMPUTESDK_PROVIDER; + + if (providerOverride) { + if (!isValidProvider(providerOverride)) { + throw new Error( + `Unsupported ComputeSDK provider "${providerOverride}". Supported providers: ${PROVIDER_NAMES.join(", ")}` + ); + } + if (!isProviderAuthComplete(providerOverride)) { + const missing = getMissingEnvVars(providerOverride); + throw new Error( + `Missing credentials for provider "${providerOverride}". Set: ${missing.join(", ")}` + ); + } + console.log(`Using ComputeSDK provider: ${providerOverride} (explicit)`); + return providerOverride as ProviderName; + } + + const detected = detectProvider(); + if (!detected) { + throw new Error( + `No provider credentials found. Set one of: ${PROVIDER_NAMES.map((p) => getMissingEnvVars(p).join(", ")).join(" | ")}` + ); + } + console.log(`Using ComputeSDK provider: ${detected} (auto-detected)`); + return detected as ProviderName; +} + +function configureComputeSDK(): void { + const provider = resolveProvider(); + + const config: ExplicitComputeConfig = { + provider, + computesdkApiKey: process.env.COMPUTESDK_API_KEY, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + }; + + const providerConfig = getProviderConfigFromEnv(provider); + if (Object.keys(providerConfig).length > 0) { + const configWithProvider = + config as ExplicitComputeConfig & Record<ProviderName, Record<string, string>>; + configWithProvider[provider] = providerConfig; + } + + compute.setConfig(config); +} + +configureComputeSDK(); + +const buildEnv = (): Record<string, string> => { + const env: Record<string, string> = {}; + if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; + if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + return env; +}; + +export async function setupComputeSdkSandboxAgent(): Promise<{ + baseUrl: string; + cleanup: () => Promise<void>; +}> { + const env = buildEnv(); + + console.log("Creating ComputeSDK sandbox..."); + const sandbox = await compute.sandbox.create({ + envs: Object.keys(env).length > 0 ? env : undefined, + }); + + const run = async (cmd: string, options?: { background?: boolean }) => { + const result = await sandbox.runCommand(cmd, options); + if (typeof result?.exitCode === "number" && result.exitCode !== 0) { + throw new Error(`Command failed: ${cmd} (exit ${result.exitCode})\n${result.stderr || ""}`); + } + return result; + }; + + console.log("Installing sandbox-agent..."); + await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"); + + if (env.ANTHROPIC_API_KEY) { + console.log("Installing Claude agent..."); + await run("sandbox-agent install-agent claude"); + } + + if (env.OPENAI_API_KEY) { + console.log("Installing Codex agent..."); + await run("sandbox-agent install-agent codex"); + } + + console.log("Starting server..."); + await run(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, { background: true }); + + const baseUrl = await sandbox.getUrl({ port: PORT }); + + console.log("Waiting for server..."); + await waitForHealth({ baseUrl }); + + const cleanup = async () => { + try { + await sandbox.destroy(); + } catch (error) { + console.warn("Cleanup failed:", error instanceof Error ? error.message : error); + } + }; + + return { baseUrl, cleanup }; +} + +export async function runComputeSdkExample(): Promise<void> { + const { baseUrl, cleanup } = await setupComputeSdkSandboxAgent(); + + const handleExit = async () => { + await cleanup(); + process.exit(0); + }; + + process.once("SIGINT", handleExit); + process.once("SIGTERM", handleExit); + + await runPrompt(baseUrl); + await cleanup(); +} + +const isDirectRun = Boolean( + process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) +); + +if (isDirectRun) { + runComputeSdkExample().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + }); +} diff --git a/examples/computesdk/tests/computesdk.test.ts b/examples/computesdk/tests/computesdk.test.ts new file mode 100644 index 0000000..9d023a9 --- /dev/null +++ b/examples/computesdk/tests/computesdk.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from "vitest"; +import { buildHeaders } from "@sandbox-agent/example-shared"; +import { setupComputeSdkSandboxAgent } from "../src/computesdk.ts"; + +const hasModal = Boolean(process.env.MODAL_TOKEN_ID && process.env.MODAL_TOKEN_SECRET); +const hasVercel = Boolean(process.env.VERCEL_TOKEN || process.env.VERCEL_OIDC_TOKEN); +const hasProviderKey = Boolean( + process.env.BLAXEL_API_KEY || + process.env.CSB_API_KEY || + process.env.DAYTONA_API_KEY || + process.env.E2B_API_KEY || + hasModal || + hasVercel +); + +const shouldRun = Boolean(process.env.COMPUTESDK_API_KEY) && hasProviderKey; +const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000; + +const testFn = shouldRun ? it : it.skip; + +describe("computesdk example", () => { + testFn( + "starts sandbox-agent and responds to /v1/health", + async () => { + const { baseUrl, cleanup } = await setupComputeSdkSandboxAgent(); + try { + const response = await fetch(`${baseUrl}/v1/health`, { + headers: buildHeaders({}), + }); + expect(response.ok).toBe(true); + const data = await response.json(); + expect(data.status).toBe("ok"); + } finally { + await cleanup(); + } + }, + timeoutMs + ); +}); diff --git a/examples/computesdk/tsconfig.json b/examples/computesdk/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/computesdk/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/daytona/package.json b/examples/daytona/package.json index 281ba81..f105bac 100644 --- a/examples/daytona/package.json +++ b/examples/daytona/package.json @@ -3,13 +3,14 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/daytona.ts", + "start": "tsx src/index.ts", "start:snapshot": "tsx src/daytona-with-snapshot.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@daytonaio/sdk": "latest", - "@sandbox-agent/example-shared": "workspace:*" + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" }, "devDependencies": { "@types/node": "latest", diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index d0d1ce8..e196065 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,5 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; -import { runPrompt } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -24,12 +25,21 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.delete(60); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/daytona/src/daytona.ts b/examples/daytona/src/index.ts similarity index 65% rename from examples/daytona/src/daytona.ts rename to examples/daytona/src/index.ts index 4fe6a3b..9fbd2f4 100644 --- a/examples/daytona/src/daytona.ts +++ b/examples/daytona/src/index.ts @@ -1,5 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; -import { runPrompt } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -25,12 +26,21 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.delete(60); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/docker/package.json b/examples/docker/package.json index 289b0c3..2c29cfe 100644 --- a/examples/docker/package.json +++ b/examples/docker/package.json @@ -3,12 +3,13 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/docker.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@sandbox-agent/example-shared": "workspace:*", - "dockerode": "latest" + "dockerode": "latest", + "sandbox-agent": "workspace:*" }, "devDependencies": { "@types/dockerode": "latest", diff --git a/examples/docker/src/docker.ts b/examples/docker/src/index.ts similarity index 77% rename from examples/docker/src/docker.ts rename to examples/docker/src/index.ts index 20fafe4..1ae51e7 100644 --- a/examples/docker/src/docker.ts +++ b/examples/docker/src/index.ts @@ -1,5 +1,6 @@ import Docker from "dockerode"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const IMAGE = "alpine:latest"; const PORT = 3000; @@ -44,13 +45,19 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; await waitForHealth({ baseUrl }); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); try { await container.stop({ t: 5 }); } catch {} try { await container.remove({ force: true }); } catch {} process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/e2b/package.json b/examples/e2b/package.json index f44574c..3e28ae2 100644 --- a/examples/e2b/package.json +++ b/examples/e2b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/e2b.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/e2b/src/e2b.ts b/examples/e2b/src/index.ts similarity index 71% rename from examples/e2b/src/e2b.ts rename to examples/e2b/src/index.ts index 8d54c88..d82141d 100644 --- a/examples/e2b/src/e2b.ts +++ b/examples/e2b/src/index.ts @@ -1,5 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record<string, string> = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -29,12 +30,18 @@ const baseUrl = `https://${sandbox.getHost(3000)}`; console.log("Waiting for server..."); await waitForHealth({ baseUrl }); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.kill(); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/examples/file-system/package.json b/examples/file-system/package.json new file mode 100644 index 0000000..87921a3 --- /dev/null +++ b/examples/file-system/package.json @@ -0,0 +1,19 @@ +{ + "name": "@sandbox-agent/example-file-system", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*", + "tar": "^7" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts new file mode 100644 index 0000000..2e2c8f9 --- /dev/null +++ b/examples/file-system/src/index.ts @@ -0,0 +1,57 @@ +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; +import * as tar from "tar"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +console.log("Starting sandbox..."); +const { baseUrl, cleanup } = await startDockerSandbox({ port: 3003 }); + +console.log("Creating sample files..."); +const tmpDir = path.resolve(__dirname, "../.tmp-upload"); +const projectDir = path.join(tmpDir, "my-project"); +fs.mkdirSync(path.join(projectDir, "src"), { recursive: true }); +fs.writeFileSync(path.join(projectDir, "README.md"), "# My Project\n\nUploaded via batch tar.\n"); +fs.writeFileSync(path.join(projectDir, "src", "index.ts"), 'console.log("hello from uploaded project");\n'); +fs.writeFileSync(path.join(projectDir, "package.json"), JSON.stringify({ name: "my-project", version: "1.0.0" }, null, 2) + "\n"); +console.log(" Created 3 files in my-project/"); + +console.log("Uploading files via batch tar..."); +const client = await SandboxAgent.connect({ baseUrl }); + +const tarPath = path.join(tmpDir, "upload.tar"); +await tar.create( + { file: tarPath, cwd: tmpDir }, + ["my-project"], +); +const tarBuffer = await fs.promises.readFile(tarPath); +const uploadResult = await client.uploadFsBatch(tarBuffer, { path: "/opt" }); +console.log(` Uploaded ${uploadResult.paths.length} files: ${uploadResult.paths.join(", ")}`); + +// Cleanup temp files +fs.rmSync(tmpDir, { recursive: true, force: true }); + +console.log("Verifying uploaded files..."); +const entries = await client.listFsEntries({ path: "/opt/my-project" }); +console.log(` Found ${entries.length} entries in /opt/my-project`); +for (const entry of entries) { + console.log(` ${entry.entryType === "directory" ? "d" : "-"} ${entry.name}`); +} + +const readmeBytes = await client.readFsFile({ path: "/opt/my-project/README.md" }); +const readmeText = new TextDecoder().decode(readmeBytes); +console.log(` README.md content: ${readmeText.trim()}`); + +console.log("Creating session..."); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(' Try: "read the README in /opt/my-project"'); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); +process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); diff --git a/examples/file-system/tsconfig.json b/examples/file-system/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/file-system/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/mcp-custom-tool/package.json b/examples/mcp-custom-tool/package.json new file mode 100644 index 0000000..250bfb0 --- /dev/null +++ b/examples/mcp-custom-tool/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox-agent/example-mcp-custom-tool", + "private": true, + "type": "module", + "scripts": { + "build:mcp": "esbuild src/mcp-server.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/mcp-server.cjs", + "start": "pnpm build:mcp && tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "latest", + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*", + "zod": "latest" + }, + "devDependencies": { + "@types/node": "latest", + "esbuild": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/mcp-custom-tool/src/index.ts b/examples/mcp-custom-tool/src/index.ts new file mode 100644 index 0000000..0c0bc33 --- /dev/null +++ b/examples/mcp-custom-tool/src/index.ts @@ -0,0 +1,49 @@ +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Verify the bundled MCP server exists (built by `pnpm build:mcp`). +const serverFile = path.resolve(__dirname, "../dist/mcp-server.cjs"); +if (!fs.existsSync(serverFile)) { + console.error("Error: dist/mcp-server.cjs not found. Run `pnpm build:mcp` first."); + process.exit(1); +} + +// Start a Docker container running sandbox-agent. +console.log("Starting sandbox..."); +const { baseUrl, cleanup } = await startDockerSandbox({ port: 3004 }); + +// Upload the bundled MCP server into the sandbox filesystem. +console.log("Uploading MCP server bundle..."); +const client = await SandboxAgent.connect({ baseUrl }); + +const bundle = await fs.promises.readFile(serverFile); +const written = await client.writeFsFile( + { path: "/opt/mcp/custom-tools/mcp-server.cjs" }, + bundle, +); +console.log(` Written: ${written.path} (${written.bytesWritten} bytes)`); + +// Create a session with the uploaded MCP server as a local command. +console.log("Creating session with custom MCP tool..."); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { + agent: detectAgent(), + mcp: { + customTools: { + type: "local", + command: ["node", "/opt/mcp/custom-tools/mcp-server.cjs"], + }, + }, +}); +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(' Try: "generate a random number between 1 and 100"'); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); +process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); diff --git a/examples/mcp-custom-tool/src/mcp-server.ts b/examples/mcp-custom-tool/src/mcp-server.ts new file mode 100644 index 0000000..38c79b7 --- /dev/null +++ b/examples/mcp-custom-tool/src/mcp-server.ts @@ -0,0 +1,24 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +async function main() { + const server = new McpServer({ name: "rand", version: "1.0.0" }); + + server.tool( + "random_number", + "Generate a random integer between min and max (inclusive)", + { + min: z.number().describe("Minimum value"), + max: z.number().describe("Maximum value"), + }, + async ({ min, max }) => ({ + content: [{ type: "text", text: String(Math.floor(Math.random() * (max - min + 1)) + min) }], + }), + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main(); diff --git a/examples/mcp-custom-tool/tsconfig.json b/examples/mcp-custom-tool/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/mcp-custom-tool/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/mcp/package.json b/examples/mcp/package.json new file mode 100644 index 0000000..950cbb7 --- /dev/null +++ b/examples/mcp/package.json @@ -0,0 +1,18 @@ +{ + "name": "@sandbox-agent/example-mcp", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/mcp/src/index.ts b/examples/mcp/src/index.ts new file mode 100644 index 0000000..84be8df --- /dev/null +++ b/examples/mcp/src/index.ts @@ -0,0 +1,31 @@ +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; + +console.log("Starting sandbox..."); +const { baseUrl, cleanup } = await startDockerSandbox({ + port: 3002, + setupCommands: [ + "npm install -g --silent @modelcontextprotocol/server-everything@2026.1.26", + ], +}); + +console.log("Creating session with everything MCP server..."); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { + agent: detectAgent(), + mcp: { + everything: { + type: "local", + command: ["mcp-server-everything"], + timeoutMs: 10000, + }, + }, +}); +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(' Try: "generate a random number between 1 and 100"'); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); +process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); diff --git a/examples/mcp/tsconfig.json b/examples/mcp/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/mcp/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/shared/Dockerfile b/examples/shared/Dockerfile new file mode 100644 index 0000000..1a960d6 --- /dev/null +++ b/examples/shared/Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-bookworm-slim +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \ + rm -rf /var/lib/apt/lists/* && \ + npm install -g --silent @sandbox-agent/cli@latest && \ + sandbox-agent install-agent claude diff --git a/examples/shared/Dockerfile.dev b/examples/shared/Dockerfile.dev new file mode 100644 index 0000000..87ba956 --- /dev/null +++ b/examples/shared/Dockerfile.dev @@ -0,0 +1,58 @@ +FROM node:22-bookworm-slim AS frontend +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /build + +# Copy workspace root config +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Copy packages needed for the inspector build chain: +# inspector -> sandbox-agent SDK -> cli-shared +COPY sdks/typescript/ sdks/typescript/ +COPY sdks/cli-shared/ sdks/cli-shared/ +COPY frontend/packages/inspector/ frontend/packages/inspector/ +COPY docs/openapi.json docs/ + +# Create stub package.json for workspace packages referenced in pnpm-workspace.yaml +# but not needed for the inspector build (avoids install errors). +RUN set -e; for dir in \ + sdks/cli sdks/gigacode \ + resources/agent-schemas resources/vercel-ai-sdk-schemas \ + scripts/release scripts/sandbox-testing \ + examples/shared examples/docker examples/e2b examples/vercel \ + examples/daytona examples/cloudflare examples/file-system \ + examples/mcp examples/mcp-custom-tool \ + examples/skills examples/skills-custom-tool \ + frontend/packages/website; do \ + mkdir -p "$dir"; \ + printf '{"name":"@stub/%s","private":true,"version":"0.0.0"}\n' "$(basename "$dir")" > "$dir/package.json"; \ + done; \ + for parent in sdks/cli/platforms sdks/gigacode/platforms; do \ + for plat in darwin-arm64 darwin-x64 linux-arm64 linux-x64 win32-x64; do \ + mkdir -p "$parent/$plat"; \ + printf '{"name":"@stub/%s-%s","private":true,"version":"0.0.0"}\n' "$(basename "$parent")" "$plat" > "$parent/$plat/package.json"; \ + done; \ + done + +RUN pnpm install --no-frozen-lockfile +ENV SKIP_OPENAPI_GEN=1 +RUN pnpm --filter sandbox-agent build && \ + pnpm --filter @sandbox-agent/inspector build + +FROM rust:1.88.0-bookworm AS builder +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY server/ ./server/ +COPY gigacode/ ./gigacode/ +COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/ +COPY --from=frontend /build/frontend/packages/inspector/dist/ ./frontend/packages/inspector/dist/ +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/build/target \ + cargo build -p sandbox-agent --release && \ + cp target/release/sandbox-agent /sandbox-agent + +FROM node:22-bookworm-slim +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates > /dev/null 2>&1 && \ + rm -rf /var/lib/apt/lists/* +COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent +RUN sandbox-agent install-agent claude diff --git a/examples/shared/package.json b/examples/shared/package.json index 18906ef..4c868ed 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -3,15 +3,18 @@ "private": true, "type": "module", "exports": { - ".": "./src/sandbox-agent-client.ts" + ".": "./src/sandbox-agent-client.ts", + "./docker": "./src/docker.ts" }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { + "dockerode": "latest", "sandbox-agent": "workspace:*" }, "devDependencies": { + "@types/dockerode": "latest", "@types/node": "latest", "typescript": "latest" } diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts new file mode 100644 index 0000000..5ec8a8c --- /dev/null +++ b/examples/shared/src/docker.ts @@ -0,0 +1,301 @@ +import Docker from "dockerode"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { waitForHealth } from "./sandbox-agent-client.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; +const EXAMPLE_IMAGE_DEV = "sandbox-agent-examples-dev:latest"; +const DOCKERFILE_DIR = path.resolve(__dirname, ".."); +const REPO_ROOT = path.resolve(DOCKERFILE_DIR, "../.."); + +export interface DockerSandboxOptions { + /** Container port used by sandbox-agent inside Docker. */ + port: number; + /** Optional fixed host port mapping. If omitted, Docker assigns a free host port automatically. */ + hostPort?: number; + /** Additional shell commands to run before starting sandbox-agent. */ + setupCommands?: string[]; + /** Docker image to use. Defaults to the pre-built sandbox-agent-examples image. */ + image?: string; +} + +export interface DockerSandbox { + baseUrl: string; + cleanup: () => Promise<void>; +} + +const DIRECT_CREDENTIAL_KEYS = [ + "ANTHROPIC_API_KEY", + "CLAUDE_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + "ANTHROPIC_AUTH_TOKEN", + "OPENAI_API_KEY", + "CODEX_API_KEY", + "CEREBRAS_API_KEY", + "OPENCODE_API_KEY", +] as const; + +function stripShellQuotes(value: string): string { + const trimmed = value.trim(); + if (trimmed.length >= 2 && trimmed.startsWith("\"") && trimmed.endsWith("\"")) { + return trimmed.slice(1, -1); + } + if (trimmed.length >= 2 && trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseExtractedCredentials(output: string): Record<string, string> { + const parsed: Record<string, string> = {}; + for (const rawLine of output.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const cleanLine = line.startsWith("export ") ? line.slice(7) : line; + const match = cleanLine.match(/^([A-Z0-9_]+)=(.*)$/); + if (!match) continue; + const [, key, rawValue] = match; + const value = stripShellQuotes(rawValue); + if (!value) continue; + parsed[key] = value; + } + return parsed; +} + +interface ClaudeCredentialFile { + hostPath: string; + containerPath: string; + base64Content: string; +} + +function readClaudeCredentialFiles(): ClaudeCredentialFile[] { + const homeDir = process.env.HOME || ""; + if (!homeDir) return []; + + const candidates: Array<{ hostPath: string; containerPath: string }> = [ + { + hostPath: path.join(homeDir, ".claude", ".credentials.json"), + containerPath: "/root/.claude/.credentials.json", + }, + { + hostPath: path.join(homeDir, ".claude-oauth-credentials.json"), + containerPath: "/root/.claude-oauth-credentials.json", + }, + ]; + + const files: ClaudeCredentialFile[] = []; + for (const candidate of candidates) { + if (!fs.existsSync(candidate.hostPath)) continue; + try { + const raw = fs.readFileSync(candidate.hostPath, "utf8"); + files.push({ + hostPath: candidate.hostPath, + containerPath: candidate.containerPath, + base64Content: Buffer.from(raw, "utf8").toString("base64"), + }); + } catch { + // Ignore unreadable credential file candidates. + } + } + return files; +} + +function collectCredentialEnv(): Record<string, string> { + const merged: Record<string, string> = {}; + let extracted: Record<string, string> = {}; + try { + const output = execFileSync( + "sandbox-agent", + ["credentials", "extract-env"], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + extracted = parseExtractedCredentials(output); + } catch { + // Fall back to direct env vars if extraction is unavailable. + } + + for (const [key, value] of Object.entries(extracted)) { + if (value) merged[key] = value; + } + for (const key of DIRECT_CREDENTIAL_KEYS) { + const direct = process.env[key]; + if (direct) merged[key] = direct; + } + return merged; +} + +function shellSingleQuotedLiteral(value: string): string { + return `'${value.replace(/'/g, `'\"'\"'`)}'`; +} + +function stripAnsi(value: string): string { + return value.replace( + /[\u001B\u009B][[\]()#;?]*(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007|(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><])/g, + "", + ); +} + +async function ensureExampleImage(_docker: Docker): Promise<string> { + const dev = !!process.env.SANDBOX_AGENT_DEV; + const imageName = dev ? EXAMPLE_IMAGE_DEV : EXAMPLE_IMAGE; + + if (dev) { + console.log(" Building sandbox image from source (may take a while, only runs once)..."); + try { + execFileSync("docker", [ + "build", "-t", imageName, + "-f", path.join(DOCKERFILE_DIR, "Dockerfile.dev"), + REPO_ROOT, + ], { + stdio: ["ignore", "ignore", "pipe"], + }); + } catch (err: unknown) { + const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; + throw new Error(`Failed to build sandbox image: ${stderr}`); + } + } else { + console.log(" Building sandbox image (may take a while, only runs once)..."); + try { + execFileSync("docker", ["build", "-t", imageName, DOCKERFILE_DIR], { + stdio: ["ignore", "ignore", "pipe"], + }); + } catch (err: unknown) { + const stderr = err instanceof Error && "stderr" in err ? String((err as { stderr: unknown }).stderr) : ""; + throw new Error(`Failed to build sandbox image: ${stderr}`); + } + } + + return imageName; +} + +/** + * Start a Docker container running sandbox-agent and wait for it to be healthy. + * Registers SIGINT/SIGTERM handlers for cleanup. + */ +export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<DockerSandbox> { + const { port, hostPort } = opts; + const useCustomImage = !!opts.image; + let image = opts.image ?? EXAMPLE_IMAGE; + // TODO: Replace setupCommands shell bootstrapping with native sandbox-agent exec API once available. + const setupCommands = [...(opts.setupCommands ?? [])]; + const credentialEnv = collectCredentialEnv(); + const claudeCredentialFiles = readClaudeCredentialFiles(); + const bootstrapEnv: Record<string, string> = {}; + + if (claudeCredentialFiles.length > 0) { + delete credentialEnv.ANTHROPIC_API_KEY; + delete credentialEnv.CLAUDE_API_KEY; + delete credentialEnv.CLAUDE_CODE_OAUTH_TOKEN; + delete credentialEnv.ANTHROPIC_AUTH_TOKEN; + + const credentialBootstrapCommands = claudeCredentialFiles.flatMap((file, index) => { + const envKey = `SANDBOX_AGENT_CLAUDE_CREDENTIAL_${index}_B64`; + bootstrapEnv[envKey] = file.base64Content; + return [ + `mkdir -p ${shellSingleQuotedLiteral(path.posix.dirname(file.containerPath))}`, + `printf %s "$${envKey}" | base64 -d > ${shellSingleQuotedLiteral(file.containerPath)}`, + ]; + }); + setupCommands.unshift(...credentialBootstrapCommands); + } + + for (const [key, value] of Object.entries(credentialEnv)) { + if (!process.env[key]) process.env[key] = value; + } + + const docker = new Docker({ socketPath: "/var/run/docker.sock" }); + + if (useCustomImage) { + try { + await docker.getImage(image).inspect(); + } catch { + console.log(` Pulling ${image}...`); + await new Promise<void>((resolve, reject) => { + docker.pull(image, (err: Error | null, stream: NodeJS.ReadableStream) => { + if (err) return reject(err); + docker.modem.followProgress(stream, (err: Error | null) => (err ? reject(err) : resolve())); + }); + }); + } + } else { + image = await ensureExampleImage(docker); + } + + const bootCommands = [ + ...setupCommands, + `sandbox-agent server --no-token --host 0.0.0.0 --port ${port}`, + ]; + + const container = await docker.createContainer({ + Image: image, + WorkingDir: "/root", + Cmd: ["sh", "-c", bootCommands.join(" && ")], + Env: [ + ...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), + ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`), + ], + ExposedPorts: { [`${port}/tcp`]: {} }, + HostConfig: { + AutoRemove: true, + PortBindings: { [`${port}/tcp`]: [{ HostPort: hostPort ? `${hostPort}` : "0" }] }, + }, + }); + await container.start(); + + const logChunks: string[] = []; + const startupLogs = await container.logs({ + follow: true, + stdout: true, + stderr: true, + since: 0, + }) as NodeJS.ReadableStream; + const stdoutStream = new PassThrough(); + const stderrStream = new PassThrough(); + stdoutStream.on("data", (chunk) => { + logChunks.push(stripAnsi(String(chunk))); + }); + stderrStream.on("data", (chunk) => { + logChunks.push(stripAnsi(String(chunk))); + }); + docker.modem.demuxStream(startupLogs, stdoutStream, stderrStream); + const stopStartupLogs = () => { + const stream = startupLogs as NodeJS.ReadableStream & { destroy?: () => void }; + try { stream.destroy?.(); } catch {} + }; + + const inspect = await container.inspect(); + const mappedPorts = inspect.NetworkSettings?.Ports?.[`${port}/tcp`]; + const mappedHostPort = mappedPorts?.[0]?.HostPort; + if (!mappedHostPort) { + throw new Error(`Failed to resolve mapped host port for container port ${port}`); + } + const baseUrl = `http://127.0.0.1:${mappedHostPort}`; + + try { + await waitForHealth({ baseUrl }); + } catch (err) { + stopStartupLogs(); + console.error(" Container logs:"); + for (const chunk of logChunks) { + process.stderr.write(` ${chunk}`); + } + throw err; + } + stopStartupLogs(); + console.log(` Ready (${baseUrl})`); + + const cleanup = async () => { + stopStartupLogs(); + try { await container.stop({ t: 5 }); } catch {} + try { await container.remove({ force: true }); } catch {} + process.exit(0); + }; + process.once("SIGINT", cleanup); + process.once("SIGTERM", cleanup); + + return { baseUrl, cleanup }; +} diff --git a/examples/shared/src/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts index 8258ee8..df8fa51 100644 --- a/examples/shared/src/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -3,11 +3,7 @@ * Provides minimal helpers for connecting to and interacting with sandbox-agent servers. */ -import { createInterface } from "node:readline/promises"; -import { randomUUID } from "node:crypto"; import { setTimeout as delay } from "node:timers/promises"; -import { SandboxAgent } from "sandbox-agent"; -import type { PermissionEventData, QuestionEventData } from "sandbox-agent"; function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); @@ -27,10 +23,12 @@ export function buildInspectorUrl({ baseUrl, token, headers, + sessionId, }: { baseUrl: string; token?: string; headers?: Record<string, string>; + sessionId?: string; }): string { const normalized = normalizeBaseUrl(ensureUrl(baseUrl)); const params = new URLSearchParams(); @@ -41,7 +39,8 @@ export function buildInspectorUrl({ params.set("headers", JSON.stringify(headers)); } const queryString = params.toString(); - return `${normalized}/ui/${queryString ? `?${queryString}` : ""}`; + const sessionPath = sessionId ? `sessions/${sessionId}` : ""; + return `${normalized}/ui/${sessionPath}${queryString ? `?${queryString}` : ""}`; } export function logInspectorUrl({ @@ -110,125 +109,39 @@ export async function waitForHealth({ throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error; } -function detectAgent(): string { +export function generateSessionId(): string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "session-"; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + return id; +} + +export function detectAgent(): string { if (process.env.SANDBOX_AGENT) return process.env.SANDBOX_AGENT; - if (process.env.ANTHROPIC_API_KEY) return "claude"; - if (process.env.OPENAI_API_KEY) return "codex"; + const hasClaude = Boolean( + process.env.ANTHROPIC_API_KEY || + process.env.CLAUDE_API_KEY || + process.env.CLAUDE_CODE_OAUTH_TOKEN || + process.env.ANTHROPIC_AUTH_TOKEN, + ); + const openAiLikeKey = process.env.OPENAI_API_KEY || process.env.CODEX_API_KEY || ""; + const hasCodexApiKey = openAiLikeKey.startsWith("sk-"); + if (hasCodexApiKey && hasClaude) { + console.log("Both Claude and Codex API keys detected; defaulting to codex. Set SANDBOX_AGENT to override."); + return "codex"; + } + if (!hasCodexApiKey && openAiLikeKey) { + console.log("OpenAI/Codex credential is not an API key (expected sk-...), skipping codex auto-select."); + } + if (hasCodexApiKey) return "codex"; + if (hasClaude) { + if (openAiLikeKey && !hasCodexApiKey) { + console.log("Using claude by default."); + } + return "claude"; + } return "claude"; } -export async function runPrompt(baseUrl: string): Promise<void> { - console.log(`UI: ${buildInspectorUrl({ baseUrl })}`); - - const client = await SandboxAgent.connect({ baseUrl }); - - const agent = detectAgent(); - console.log(`Using agent: ${agent}`); - const sessionId = randomUUID(); - await client.createSession(sessionId, { agent }); - console.log(`Session ${sessionId}. Press Ctrl+C to quit.`); - - const rl = createInterface({ input: process.stdin, output: process.stdout }); - - let isThinking = false; - let hasStartedOutput = false; - let turnResolve: (() => void) | null = null; - let sessionEnded = false; - - const processEvents = async () => { - for await (const event of client.streamEvents(sessionId)) { - if (event.type === "item.started") { - const item = (event.data as any)?.item; - if (item?.role === "assistant") { - isThinking = true; - hasStartedOutput = false; - process.stdout.write("Thinking..."); - } - } - - if (event.type === "item.delta" && isThinking) { - const delta = (event.data as any)?.delta; - if (delta) { - if (!hasStartedOutput) { - process.stdout.write("\r\x1b[K"); - hasStartedOutput = true; - } - const text = typeof delta === "string" ? delta : delta.type === "text" ? delta.text || "" : ""; - if (text) process.stdout.write(text); - } - } - - if (event.type === "item.completed") { - const item = (event.data as any)?.item; - if (item?.role === "assistant") { - isThinking = false; - process.stdout.write("\n"); - turnResolve?.(); - turnResolve = null; - } - } - - if (event.type === "permission.requested") { - const data = event.data as PermissionEventData; - if (isThinking && !hasStartedOutput) { - process.stdout.write("\r\x1b[K"); - } - console.log(`[Auto-approved] ${data.action}`); - await client.replyPermission(sessionId, data.permission_id, { reply: "once" }); - } - - if (event.type === "question.requested") { - const data = event.data as QuestionEventData; - if (isThinking && !hasStartedOutput) { - process.stdout.write("\r\x1b[K"); - } - console.log(`[Question rejected] ${data.prompt}`); - await client.rejectQuestion(sessionId, data.question_id); - } - - if (event.type === "error") { - const data = event.data as any; - console.error(`\nError: ${data?.message || JSON.stringify(data)}`); - } - - if (event.type === "session.ended") { - const data = event.data as any; - const reason = data?.reason || "unknown"; - if (reason === "error") { - console.error(`\nAgent exited with error: ${data?.message || ""}`); - if (data?.exit_code !== undefined) { - console.error(` Exit code: ${data.exit_code}`); - } - } else { - console.log(`Agent session ${reason}`); - } - sessionEnded = true; - turnResolve?.(); - turnResolve = null; - } - } - }; - - processEvents().catch((err) => { - if (!sessionEnded) { - console.error("Event stream error:", err instanceof Error ? err.message : err); - } - }); - - while (true) { - const line = await rl.question("> "); - if (!line.trim()) continue; - - const turnComplete = new Promise<void>((resolve) => { - turnResolve = resolve; - }); - - try { - await client.postMessage(sessionId, { message: line.trim() }); - await turnComplete; - } catch (error) { - console.error(error instanceof Error ? error.message : error); - turnResolve = null; - } - } -} diff --git a/examples/skills-custom-tool/SKILL.md b/examples/skills-custom-tool/SKILL.md new file mode 100644 index 0000000..67afa25 --- /dev/null +++ b/examples/skills-custom-tool/SKILL.md @@ -0,0 +1,12 @@ +--- +name: random-number +description: Generate a random integer between min and max (inclusive). Use when the user asks for a random number. +--- + +To generate a random number, run: + +```bash +node /opt/skills/random-number/random-number.cjs <min> <max> +``` + +This prints a single random integer between min and max (inclusive). diff --git a/examples/skills-custom-tool/package.json b/examples/skills-custom-tool/package.json new file mode 100644 index 0000000..7edf635 --- /dev/null +++ b/examples/skills-custom-tool/package.json @@ -0,0 +1,20 @@ +{ + "name": "@sandbox-agent/example-skills-custom-tool", + "private": true, + "type": "module", + "scripts": { + "build:script": "esbuild src/random-number.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/random-number.cjs", + "start": "pnpm build:script && tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "esbuild": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/skills-custom-tool/src/index.ts b/examples/skills-custom-tool/src/index.ts new file mode 100644 index 0000000..c53498b --- /dev/null +++ b/examples/skills-custom-tool/src/index.ts @@ -0,0 +1,53 @@ +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Verify the bundled script exists (built by `pnpm build:script`). +const scriptFile = path.resolve(__dirname, "../dist/random-number.cjs"); +if (!fs.existsSync(scriptFile)) { + console.error("Error: dist/random-number.cjs not found. Run `pnpm build:script` first."); + process.exit(1); +} + +// Start a Docker container running sandbox-agent. +console.log("Starting sandbox..."); +const { baseUrl, cleanup } = await startDockerSandbox({ port: 3005 }); + +// Upload the bundled script and SKILL.md into the sandbox filesystem. +console.log("Uploading script and skill file..."); +const client = await SandboxAgent.connect({ baseUrl }); + +const script = await fs.promises.readFile(scriptFile); +const scriptResult = await client.writeFsFile( + { path: "/opt/skills/random-number/random-number.cjs" }, + script, +); +console.log(` Script: ${scriptResult.path} (${scriptResult.bytesWritten} bytes)`); + +const skillMd = await fs.promises.readFile(path.resolve(__dirname, "../SKILL.md")); +const skillResult = await client.writeFsFile( + { path: "/opt/skills/random-number/SKILL.md" }, + skillMd, +); +console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`); + +// Create a session with the uploaded skill as a local source. +console.log("Creating session with custom skill..."); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { + agent: detectAgent(), + skills: { + sources: [{ type: "local", source: "/opt/skills/random-number" }], + }, +}); +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(' Try: "generate a random number between 1 and 100"'); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); +process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); diff --git a/examples/skills-custom-tool/src/random-number.ts b/examples/skills-custom-tool/src/random-number.ts new file mode 100644 index 0000000..2b3d758 --- /dev/null +++ b/examples/skills-custom-tool/src/random-number.ts @@ -0,0 +1,9 @@ +const min = Number(process.argv[2]); +const max = Number(process.argv[3]); + +if (Number.isNaN(min) || Number.isNaN(max)) { + console.error("Usage: random-number <min> <max>"); + process.exit(1); +} + +console.log(Math.floor(Math.random() * (max - min + 1)) + min); diff --git a/examples/skills-custom-tool/tsconfig.json b/examples/skills-custom-tool/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/skills-custom-tool/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/skills/package.json b/examples/skills/package.json new file mode 100644 index 0000000..65829dc --- /dev/null +++ b/examples/skills/package.json @@ -0,0 +1,18 @@ +{ + "name": "@sandbox-agent/example-skills", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/skills/src/index.ts b/examples/skills/src/index.ts new file mode 100644 index 0000000..2e1990e --- /dev/null +++ b/examples/skills/src/index.ts @@ -0,0 +1,26 @@ +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; + +console.log("Starting sandbox..."); +const { baseUrl, cleanup } = await startDockerSandbox({ + port: 3001, +}); + +console.log("Creating session with skill source..."); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { + agent: detectAgent(), + skills: { + sources: [ + { type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }, + ], + }, +}); +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(' Try: "How do I start sandbox-agent?"'); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); +process.on("SIGINT", () => { clearInterval(keepAlive); cleanup().then(() => process.exit(0)); }); diff --git a/examples/skills/tsconfig.json b/examples/skills/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/skills/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} diff --git a/examples/vercel/package.json b/examples/vercel/package.json index 9f0569d..a193a36 100644 --- a/examples/vercel/package.json +++ b/examples/vercel/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "start": "tsx src/vercel.ts", + "start": "tsx src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/examples/vercel/src/vercel.ts b/examples/vercel/src/index.ts similarity index 73% rename from examples/vercel/src/vercel.ts rename to examples/vercel/src/index.ts index ed2d836..93093ae 100644 --- a/examples/vercel/src/vercel.ts +++ b/examples/vercel/src/index.ts @@ -1,5 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; -import { runPrompt, waitForHealth } from "@sandbox-agent/example-shared"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record<string, string> = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -40,12 +41,18 @@ const baseUrl = sandbox.domain(3000); console.log("Waiting for server..."); await waitForHealth({ baseUrl }); +const client = await SandboxAgent.connect({ baseUrl }); +const sessionId = generateSessionId(); +await client.createSession(sessionId, { agent: detectAgent() }); + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); const cleanup = async () => { + clearInterval(keepAlive); await sandbox.stop(); process.exit(0); }; process.once("SIGINT", cleanup); process.once("SIGTERM", cleanup); - -await runPrompt(baseUrl); -await cleanup(); diff --git a/frontend/packages/inspector/Dockerfile b/frontend/packages/inspector/Dockerfile index 09cd504..5cfd5bc 100644 --- a/frontend/packages/inspector/Dockerfile +++ b/frontend/packages/inspector/Dockerfile @@ -1,15 +1,20 @@ FROM node:22-alpine AS build WORKDIR /app -RUN npm install -g pnpm +RUN npm install -g pnpm@9 # Copy package files for all workspaces COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/typescript/package.json ./sdks/typescript/ +COPY sdks/cli-shared/package.json ./sdks/cli-shared/ # Install dependencies RUN pnpm install --filter @sandbox-agent/inspector... +# Copy cli-shared source and build it +COPY sdks/cli-shared ./sdks/cli-shared +RUN cd sdks/cli-shared && pnpm exec tsup + # Copy SDK source (with pre-generated types) COPY sdks/typescript ./sdks/typescript diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index 539c467..225d119 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -336,6 +336,12 @@ color: var(--danger); } + .banner.config-note { + background: rgba(255, 159, 10, 0.12); + border-left: 3px solid var(--warning); + color: var(--warning); + } + .banner.success { background: rgba(48, 209, 88, 0.1); border-left: 3px solid var(--success); @@ -471,11 +477,12 @@ position: relative; } - .sidebar-add-menu { + .sidebar-add-menu, + .session-create-menu { position: absolute; top: 36px; left: 0; - min-width: 200px; + min-width: 220px; background: var(--surface); border: 1px solid var(--border-2); border-radius: 8px; @@ -487,6 +494,405 @@ z-index: 60; } + .session-create-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 4px; + margin-bottom: 4px; + } + + .session-create-back { + width: 24px; + height: 24px; + background: transparent; + border: 1px solid var(--border-2); + border-radius: 4px; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition); + flex-shrink: 0; + } + + .session-create-back:hover { + border-color: var(--accent); + color: var(--accent); + } + + .session-create-agent-name { + font-size: 12px; + font-weight: 600; + color: var(--text); + } + + .session-create-form { + display: flex; + flex-direction: column; + gap: 0; + padding: 4px 2px; + } + + .session-create-form .setup-field { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + height: 28px; + } + + .session-create-form .setup-label { + width: 72px; + flex-shrink: 0; + text-align: right; + } + + .session-create-form .setup-select, + .session-create-form .setup-input { + flex: 1; + min-width: 0; + } + + .session-create-section { + overflow: hidden; + } + + .session-create-section-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + height: 28px; + padding: 0; + background: transparent; + border: none; + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + transition: color var(--transition); + } + + .session-create-section-toggle:hover { + color: var(--text); + } + + .session-create-section-toggle .setup-label { + width: 72px; + flex-shrink: 0; + text-align: right; + } + + .session-create-section-count { + font-size: 11px; + font-weight: 400; + color: var(--muted); + } + + .session-create-section-arrow { + margin-left: auto; + color: var(--muted-2); + flex-shrink: 0; + } + + .session-create-section-body { + margin: 4px 0 6px; + padding: 8px; + border: 1px solid var(--border-2); + border-radius: 4px; + background: var(--surface-2); + } + + .session-create-textarea { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 4px; + padding: 6px 8px; + font-size: 10px; + color: var(--text); + outline: none; + resize: vertical; + min-height: 60px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + transition: border-color var(--transition); + } + + .session-create-textarea:focus { + border-color: var(--accent); + } + + .session-create-textarea::placeholder { + color: var(--muted-2); + } + + .session-create-inline-error { + font-size: 10px; + color: var(--danger); + margin-top: 4px; + line-height: 1.4; + } + + .session-create-skill-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; + } + + .session-create-skill-item { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 8px; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 4px; + } + + .session-create-skill-path { + flex: 1; + min-width: 0; + font-size: 10px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .session-create-skill-remove { + width: 18px; + height: 18px; + background: transparent; + border: none; + border-radius: 3px; + color: var(--muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all var(--transition); + } + + .session-create-skill-remove:hover { + color: var(--danger); + background: rgba(255, 59, 48, 0.12); + } + + .session-create-skill-add-row { + display: flex; + } + + .session-create-skill-input { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 4px 8px; + font-size: 10px; + color: var(--text); + outline: none; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + } + + .session-create-skill-input::placeholder { + color: var(--muted-2); + } + + .session-create-skill-type-badge { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: rgba(255, 79, 0, 0.15); + color: var(--accent); + flex-shrink: 0; + } + + .session-create-skill-type-row { + display: flex; + gap: 4px; + } + + .session-create-skill-type-select { + width: 80px; + flex-shrink: 0; + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 4px 6px; + font-size: 10px; + color: var(--text); + outline: none; + cursor: pointer; + } + + .session-create-mcp-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; + } + + .session-create-mcp-item { + display: flex; + align-items: center; + gap: 4px; + padding: 3px 4px 3px 8px; + background: var(--surface-2); + border: 1px solid var(--border-2); + border-radius: 4px; + } + + .session-create-mcp-info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + } + + .session-create-mcp-name { + font-size: 11px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + } + + .session-create-mcp-type { + font-size: 9px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--muted); + background: var(--surface); + padding: 1px 4px; + border-radius: 3px; + white-space: nowrap; + } + + .session-create-mcp-summary { + font-size: 10px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + .session-create-mcp-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + .session-create-mcp-edit { + display: flex; + flex-direction: column; + gap: 4px; + } + + .session-create-mcp-name-input { + width: 100%; + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 4px; + padding: 4px 8px; + font-size: 11px; + color: var(--text); + outline: none; + } + + .session-create-mcp-name-input:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .session-create-mcp-name-input::placeholder { + color: var(--muted-2); + } + + .session-create-mcp-edit-actions { + display: flex; + gap: 4px; + } + + .session-create-mcp-save, + .session-create-mcp-cancel { + flex: 1; + padding: 4px 8px; + border-radius: 4px; + border: none; + font-size: 10px; + font-weight: 600; + cursor: pointer; + transition: background var(--transition); + } + + .session-create-mcp-save { + background: var(--accent); + color: #fff; + } + + .session-create-mcp-save:hover { + background: var(--accent-hover); + } + + .session-create-mcp-cancel { + background: var(--border-2); + color: var(--text-secondary); + } + + .session-create-mcp-cancel:hover { + background: var(--muted-2); + } + + .session-create-add-btn { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + padding: 4px 8px; + background: transparent; + border: 1px dashed var(--border-2); + border-radius: 4px; + color: var(--muted); + font-size: 10px; + cursor: pointer; + transition: all var(--transition); + } + + .session-create-add-btn:hover { + border-color: var(--accent); + color: var(--accent); + } + + .session-create-actions { + padding: 4px 2px 2px; + margin-top: 4px; + } + + .session-create-actions .button.primary { + width: 100%; + padding: 8px 12px; + font-size: 12px; + } + + /* Empty state variant of session-create-menu */ + .empty-state-menu-wrapper .session-create-menu { + top: 100%; + left: 50%; + transform: translateX(-50%); + margin-top: 8px; + } + .sidebar-add-option { background: transparent; border: 1px solid transparent; @@ -515,12 +921,40 @@ .agent-option-left { display: flex; flex-direction: column; + align-items: flex-start; gap: 2px; min-width: 0; } .agent-option-name { white-space: nowrap; + min-width: 0; + } + + .agent-option-version { + font-size: 10px; + color: var(--muted); + white-space: nowrap; + } + + .sidebar-add-option:hover .agent-option-version { + color: rgba(255, 255, 255, 0.6); + } + + .agent-option-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + } + + .agent-option-arrow { + color: var(--muted-2); + transition: color var(--transition); + } + + .sidebar-add-option:hover .agent-option-arrow { + color: rgba(255, 255, 255, 0.6); } .agent-badge { @@ -535,9 +969,6 @@ flex-shrink: 0; } - .agent-badge.version { - color: var(--muted); - } .sidebar-add-status { padding: 6px 8px; @@ -1043,6 +1474,36 @@ height: 16px; } + /* Session Config Bar */ + .session-config-bar { + display: flex; + align-items: flex-start; + gap: 20px; + padding: 10px 16px 12px; + border-top: 1px solid var(--border); + flex-shrink: 0; + flex-wrap: wrap; + } + + .session-config-field { + display: flex; + flex-direction: column; + gap: 2px; + } + + .session-config-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted); + } + + .session-config-value { + font-size: 12px; + color: #8e8e93; + } + /* Setup Row */ .setup-row { display: flex; @@ -1207,6 +1668,29 @@ color: #fff; } + .setup-config-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + } + + .setup-config-btn { + border: 1px solid var(--border-2); + border-radius: 4px; + background: var(--surface); + color: var(--text-secondary); + } + + .setup-config-btn:hover { + border-color: var(--accent); + color: var(--accent); + } + + .setup-config-btn.error { + color: var(--danger); + border-color: rgba(255, 59, 48, 0.4); + } + .setup-version { font-size: 10px; color: var(--muted); @@ -1311,6 +1795,15 @@ margin-bottom: 0; } + .config-textarea { + min-height: 130px; + } + + .config-inline-error { + margin-top: 8px; + margin-bottom: 0; + } + .card-header { display: flex; align-items: center; @@ -1319,6 +1812,16 @@ margin-bottom: 8px; } + .card-header-pills { + display: flex; + align-items: center; + gap: 6px; + } + + .spinner-icon { + animation: spin 0.8s linear infinite; + } + .card-title { font-size: 13px; font-weight: 600; diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index 2479599..3ee7180 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -3,11 +3,13 @@ import { SandboxAgentError, SandboxAgent, type AgentInfo, + type CreateSessionRequest, type AgentModelInfo, type AgentModeInfo, type PermissionEventData, type QuestionEventData, type SessionInfo, + type SkillSource, type UniversalEvent, type UniversalItem } from "sandbox-agent"; @@ -32,6 +34,41 @@ type ItemDeltaEventData = { delta: string; }; +export type McpServerEntry = { + name: string; + configJson: string; + error: string | null; +}; + +type ParsedMcpConfig = { + value: NonNullable<CreateSessionRequest["mcp"]>; + count: number; + error: string | null; +}; + +const buildMcpConfig = (entries: McpServerEntry[]): ParsedMcpConfig => { + if (entries.length === 0) { + return { value: {}, count: 0, error: null }; + } + const firstError = entries.find((e) => e.error); + if (firstError) { + return { value: {}, count: entries.length, error: `${firstError.name}: ${firstError.error}` }; + } + const value: NonNullable<CreateSessionRequest["mcp"]> = {}; + for (const entry of entries) { + try { + value[entry.name] = JSON.parse(entry.configJson); + } catch { + return { value: {}, count: entries.length, error: `${entry.name}: Invalid JSON` }; + } + } + return { value, count: entries.length, error: null }; +}; + +const buildSkillsConfig = (sources: SkillSource[]): NonNullable<CreateSessionRequest["skills"]> => { + return { sources }; +}; + const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => { return { item_id: itemId, @@ -53,6 +90,23 @@ const getCurrentOriginEndpoint = () => { return window.location.origin; }; +const getSessionIdFromPath = (): string => { + const basePath = import.meta.env.BASE_URL; + const path = window.location.pathname; + const relative = path.startsWith(basePath) ? path.slice(basePath.length) : path; + const match = relative.match(/^sessions\/(.+)/); + return match ? match[1] : ""; +}; + +const updateSessionPath = (id: string) => { + const basePath = import.meta.env.BASE_URL; + const params = window.location.search; + const newPath = id ? `${basePath}sessions/${id}${params}` : `${basePath}${params}`; + if (window.location.pathname + window.location.search !== newPath) { + window.history.replaceState(null, "", newPath); + } +}; + const getInitialConnection = () => { if (typeof window === "undefined") { return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record<string, string>, hasUrlParam: false }; @@ -103,11 +157,7 @@ export default function App() { const [modelsErrorByAgent, setModelsErrorByAgent] = useState<Record<string, string | null>>({}); const [agentId, setAgentId] = useState("claude"); - const [agentMode, setAgentMode] = useState(""); - const [permissionMode, setPermissionMode] = useState("default"); - const [model, setModel] = useState(""); - const [variant, setVariant] = useState(""); - const [sessionId, setSessionId] = useState(""); + const [sessionId, setSessionId] = useState(getSessionIdFromPath()); const [sessionError, setSessionError] = useState<string | null>(null); const [message, setMessage] = useState(""); @@ -115,6 +165,8 @@ export default function App() { const [offset, setOffset] = useState(0); const offsetRef = useRef(0); const [eventsLoading, setEventsLoading] = useState(false); + const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]); + const [skillSources, setSkillSources] = useState<SkillSource[]>([]); const [polling, setPolling] = useState(false); const pollTimerRef = useRef<number | null>(null); @@ -377,50 +429,52 @@ export default function App() { stopSse(); stopTurnStream(); setSessionId(session.sessionId); + updateSessionPath(session.sessionId); setAgentId(session.agent); - setAgentMode(session.agentMode); - setPermissionMode(session.permissionMode); - setModel(session.model ?? ""); - setVariant(session.variant ?? ""); setEvents([]); setOffset(0); offsetRef.current = 0; setSessionError(null); }; - const createNewSession = async (nextAgentId?: string) => { + const createNewSession = async ( + nextAgentId: string, + config: { model: string; agentMode: string; permissionMode: string; variant: string } + ) => { stopPolling(); stopSse(); stopTurnStream(); - const selectedAgent = nextAgentId ?? agentId; - if (nextAgentId) { - setAgentId(nextAgentId); + setAgentId(nextAgentId); + if (parsedMcpConfig.error) { + setSessionError(parsedMcpConfig.error); + return; } const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "session-"; for (let i = 0; i < 8; i++) { id += chars[Math.floor(Math.random() * chars.length)]; } - setSessionId(id); - setEvents([]); - setOffset(0); - offsetRef.current = 0; setSessionError(null); try { - const body: { - agent: string; - agentMode?: string; - permissionMode?: string; - model?: string; - variant?: string; - } = { agent: selectedAgent }; - if (agentMode) body.agentMode = agentMode; - if (permissionMode) body.permissionMode = permissionMode; - if (model) body.model = model; - if (variant) body.variant = variant; + const body: CreateSessionRequest = { agent: nextAgentId }; + if (config.agentMode) body.agentMode = config.agentMode; + if (config.permissionMode) body.permissionMode = config.permissionMode; + if (config.model) body.model = config.model; + if (config.variant) body.variant = config.variant; + if (parsedMcpConfig.count > 0) { + body.mcp = parsedMcpConfig.value; + } + if (parsedSkillsConfig.sources.length > 0) { + body.skills = parsedSkillsConfig; + } await getClient().createSession(id, body); + setSessionId(id); + updateSessionPath(id); + setEvents([]); + setOffset(0); + offsetRef.current = 0; await fetchSessions(); } catch (error) { setSessionError(getErrorMessage(error, "Unable to create session")); @@ -762,6 +816,30 @@ export default function App() { }); break; } + case "turn.started": { + entries.push({ + id: event.event_id, + kind: "meta", + time: event.time, + meta: { + title: "Turn started", + severity: "info" + } + }); + break; + } + case "turn.ended": { + entries.push({ + id: event.event_id, + kind: "meta", + time: event.time, + meta: { + title: "Turn ended", + severity: "info" + } + }); + break; + } default: break; } @@ -852,38 +930,10 @@ export default function App() { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }, [transcriptEntries]); - useEffect(() => { - if (connected && agentId && !modesByAgent[agentId]) { - loadModes(agentId); - } - }, [connected, agentId]); - - useEffect(() => { - if (connected && agentId && !modelsByAgent[agentId]) { - loadModels(agentId); - } - }, [connected, agentId]); - - useEffect(() => { - const modes = modesByAgent[agentId]; - if (modes && modes.length > 0 && !agentMode) { - setAgentMode(modes[0].id); - } - }, [modesByAgent, agentId]); - const currentAgent = agents.find((agent) => agent.id === agentId); - const activeModes = modesByAgent[agentId] ?? []; - const modesLoading = modesLoadingByAgent[agentId] ?? false; - const modesError = modesErrorByAgent[agentId] ?? null; - const modelOptions = modelsByAgent[agentId] ?? []; - const modelsLoading = modelsLoadingByAgent[agentId] ?? false; - const modelsError = modelsErrorByAgent[agentId] ?? null; - const defaultModel = defaultModelByAgent[agentId] ?? ""; - const selectedModelId = model || defaultModel; - const selectedModel = modelOptions.find((entry) => entry.id === selectedModelId); - const variantOptions = selectedModel?.variants ?? []; - const defaultVariant = selectedModel?.defaultVariant ?? ""; - const supportsVariants = Boolean(currentAgent?.capabilities?.variants); + const currentSessionInfo = sessions.find((s) => s.sessionId === sessionId); + const parsedMcpConfig = useMemo(() => buildMcpConfig(mcpServers), [mcpServers]); + const parsedSkillsConfig = useMemo(() => buildSkillsConfig(skillSources), [skillSources]); const agentDisplayNames: Record<string, string> = { claude: "Claude Code", codex: "Codex", @@ -894,6 +944,15 @@ export default function App() { }; const agentLabel = agentDisplayNames[agentId] ?? agentId; + const handleSelectAgent = useCallback((targetAgentId: string) => { + if (connected && !modesByAgent[targetAgentId]) { + loadModes(targetAgentId); + } + if (connected && !modelsByAgent[targetAgentId]) { + loadModels(targetAgentId); + } + }, [connected, modesByAgent, modelsByAgent]); + const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); @@ -957,17 +1016,28 @@ export default function App() { onSelectSession={selectSession} onRefresh={fetchSessions} onCreateSession={createNewSession} + onSelectAgent={handleSelectAgent} agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} agentsLoading={agentsLoading} agentsError={agentsError} sessionsLoading={sessionsLoading} sessionsError={sessionsError} + modesByAgent={modesByAgent} + modelsByAgent={modelsByAgent} + defaultModelByAgent={defaultModelByAgent} + modesLoadingByAgent={modesLoadingByAgent} + modelsLoadingByAgent={modelsLoadingByAgent} + modesErrorByAgent={modesErrorByAgent} + modelsErrorByAgent={modelsErrorByAgent} + mcpServers={mcpServers} + onMcpServersChange={setMcpServers} + mcpConfigError={parsedMcpConfig.error} + skillSources={skillSources} + onSkillSourcesChange={setSkillSources} /> <ChatPanel sessionId={sessionId} - polling={polling} - turnStreaming={turnStreaming} transcriptEntries={transcriptEntries} sessionError={sessionError} message={message} @@ -975,36 +1045,19 @@ export default function App() { onSendMessage={sendMessage} onKeyDown={handleKeyDown} onCreateSession={createNewSession} + onSelectAgent={handleSelectAgent} agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)} agentsLoading={agentsLoading} agentsError={agentsError} messagesEndRef={messagesEndRef} - agentId={agentId} agentLabel={agentLabel} - agentMode={agentMode} - permissionMode={permissionMode} - model={model} - variant={variant} - modelOptions={modelOptions} - defaultModel={defaultModel} - modelsLoading={modelsLoading} - modelsError={modelsError} - variantOptions={variantOptions} - defaultVariant={defaultVariant} - supportsVariants={supportsVariants} - streamMode={streamMode} - activeModes={activeModes} currentAgentVersion={currentAgent?.version ?? null} - modesLoading={modesLoading} - modesError={modesError} - onAgentModeChange={setAgentMode} - onPermissionModeChange={setPermissionMode} - onModelChange={setModel} - onVariantChange={setVariant} - onStreamModeChange={setStreamMode} - onToggleStream={toggleStream} + sessionModel={currentSessionInfo?.model ?? null} + sessionVariant={currentSessionInfo?.variant ?? null} + sessionPermissionMode={currentSessionInfo?.permissionMode ?? null} + sessionMcpServerCount={currentSessionInfo?.mcp ? Object.keys(currentSessionInfo.mcp).length : 0} + sessionSkillSourceCount={currentSessionInfo?.skills?.sources?.length ?? 0} onEndSession={endSession} - hasSession={Boolean(sessionId)} eventError={eventError} questionRequests={questionRequests} permissionRequests={permissionRequests} @@ -1013,6 +1066,18 @@ export default function App() { onAnswerQuestion={answerQuestion} onRejectQuestion={rejectQuestion} onReplyPermission={replyPermission} + modesByAgent={modesByAgent} + modelsByAgent={modelsByAgent} + defaultModelByAgent={defaultModelByAgent} + modesLoadingByAgent={modesLoadingByAgent} + modelsLoadingByAgent={modelsLoadingByAgent} + modesErrorByAgent={modesErrorByAgent} + modelsErrorByAgent={modelsErrorByAgent} + mcpServers={mcpServers} + onMcpServersChange={setMcpServers} + mcpConfigError={parsedMcpConfig.error} + skillSources={skillSources} + onSkillSourcesChange={setSkillSources} /> <DebugPanel diff --git a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx new file mode 100644 index 0000000..d216e19 --- /dev/null +++ b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx @@ -0,0 +1,750 @@ +import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, Pencil, Plus, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "sandbox-agent"; +import type { McpServerEntry } from "../App"; + +export type SessionConfig = { + model: string; + agentMode: string; + permissionMode: string; + variant: string; +}; + +const agentLabels: Record<string, string> = { + claude: "Claude Code", + codex: "Codex", + opencode: "OpenCode", + amp: "Amp", + mock: "Mock" +}; + +const validateServerJson = (json: string): string | null => { + const trimmed = json.trim(); + if (!trimmed) return "Config is required"; + try { + const parsed = JSON.parse(trimmed); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return "Must be a JSON object"; + } + if (!parsed.type) return 'Missing "type" field'; + if (parsed.type !== "local" && parsed.type !== "remote") { + return 'Type must be "local" or "remote"'; + } + if (parsed.type === "local" && !parsed.command) return 'Local server requires "command"'; + if (parsed.type === "remote" && !parsed.url) return 'Remote server requires "url"'; + return null; + } catch { + return "Invalid JSON"; + } +}; + +const getServerType = (configJson: string): string | null => { + try { + const parsed = JSON.parse(configJson); + return parsed?.type ?? null; + } catch { + return null; + } +}; + +const getServerSummary = (configJson: string): string => { + try { + const parsed = JSON.parse(configJson); + if (parsed?.type === "local") { + const cmd = Array.isArray(parsed.command) ? parsed.command.join(" ") : parsed.command; + return cmd ?? "local"; + } + if (parsed?.type === "remote") { + return parsed.url ?? "remote"; + } + return parsed?.type ?? ""; + } catch { + return ""; + } +}; + +const skillSourceSummary = (source: SkillSource): string => { + let summary = source.source; + if (source.skills && source.skills.length > 0) { + summary += ` [${source.skills.join(", ")}]`; + } + return summary; +}; + +const SessionCreateMenu = ({ + agents, + agentsLoading, + agentsError, + modesByAgent, + modelsByAgent, + defaultModelByAgent, + modesLoadingByAgent, + modelsLoadingByAgent, + modesErrorByAgent, + modelsErrorByAgent, + mcpServers, + onMcpServersChange, + mcpConfigError, + skillSources, + onSkillSourcesChange, + onSelectAgent, + onCreateSession, + open, + onClose +}: { + agents: AgentInfo[]; + agentsLoading: boolean; + agentsError: string | null; + modesByAgent: Record<string, AgentModeInfo[]>; + modelsByAgent: Record<string, AgentModelInfo[]>; + defaultModelByAgent: Record<string, string>; + modesLoadingByAgent: Record<string, boolean>; + modelsLoadingByAgent: Record<string, boolean>; + modesErrorByAgent: Record<string, string | null>; + modelsErrorByAgent: Record<string, string | null>; + mcpServers: McpServerEntry[]; + onMcpServersChange: (servers: McpServerEntry[]) => void; + mcpConfigError: string | null; + skillSources: SkillSource[]; + onSkillSourcesChange: (sources: SkillSource[]) => void; + onSelectAgent: (agentId: string) => void; + onCreateSession: (agentId: string, config: SessionConfig) => void; + open: boolean; + onClose: () => void; +}) => { + const [phase, setPhase] = useState<"agent" | "config">("agent"); + const [selectedAgent, setSelectedAgent] = useState(""); + const [agentMode, setAgentMode] = useState(""); + const [permissionMode, setPermissionMode] = useState("default"); + const [model, setModel] = useState(""); + const [variant, setVariant] = useState(""); + + const [mcpExpanded, setMcpExpanded] = useState(false); + const [skillsExpanded, setSkillsExpanded] = useState(false); + + // Skill add/edit state + const [addingSkill, setAddingSkill] = useState(false); + const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(null); + const [skillType, setSkillType] = useState<"github" | "local" | "git">("github"); + const [skillSource, setSkillSource] = useState(""); + const [skillFilter, setSkillFilter] = useState(""); + const [skillRef, setSkillRef] = useState(""); + const [skillSubpath, setSkillSubpath] = useState(""); + const [skillLocalError, setSkillLocalError] = useState<string | null>(null); + const skillSourceRef = useRef<HTMLInputElement>(null); + + // MCP add/edit state + const [addingMcp, setAddingMcp] = useState(false); + const [editingMcpIndex, setEditingMcpIndex] = useState<number | null>(null); + const [mcpName, setMcpName] = useState(""); + const [mcpJson, setMcpJson] = useState(""); + const [mcpLocalError, setMcpLocalError] = useState<string | null>(null); + const mcpNameRef = useRef<HTMLInputElement>(null); + const mcpJsonRef = useRef<HTMLTextAreaElement>(null); + + const cancelSkillEdit = () => { + setAddingSkill(false); + setEditingSkillIndex(null); + setSkillType("github"); + setSkillSource(""); + setSkillFilter(""); + setSkillRef(""); + setSkillSubpath(""); + setSkillLocalError(null); + }; + + // Reset state when menu closes + useEffect(() => { + if (!open) { + setPhase("agent"); + setSelectedAgent(""); + setAgentMode(""); + setPermissionMode("default"); + setModel(""); + setVariant(""); + setMcpExpanded(false); + setSkillsExpanded(false); + cancelSkillEdit(); + setAddingMcp(false); + setEditingMcpIndex(null); + setMcpName(""); + setMcpJson(""); + setMcpLocalError(null); + } + }, [open]); + + // Auto-select first mode when modes load for selected agent + useEffect(() => { + if (!selectedAgent) return; + const modes = modesByAgent[selectedAgent]; + if (modes && modes.length > 0 && !agentMode) { + setAgentMode(modes[0].id); + } + }, [modesByAgent, selectedAgent, agentMode]); + + // Focus skill source input when adding + useEffect(() => { + if ((addingSkill || editingSkillIndex !== null) && skillSourceRef.current) { + skillSourceRef.current.focus(); + } + }, [addingSkill, editingSkillIndex]); + + // Focus MCP name input when adding + useEffect(() => { + if (addingMcp && mcpNameRef.current) { + mcpNameRef.current.focus(); + } + }, [addingMcp]); + + // Focus MCP json textarea when editing + useEffect(() => { + if (editingMcpIndex !== null && mcpJsonRef.current) { + mcpJsonRef.current.focus(); + } + }, [editingMcpIndex]); + + if (!open) return null; + + const handleAgentClick = (agentId: string) => { + setSelectedAgent(agentId); + setPhase("config"); + onSelectAgent(agentId); + }; + + const handleBack = () => { + setPhase("agent"); + setSelectedAgent(""); + setAgentMode(""); + setPermissionMode("default"); + setModel(""); + setVariant(""); + }; + + const handleCreate = () => { + if (mcpConfigError) return; + onCreateSession(selectedAgent, { model, agentMode, permissionMode, variant }); + onClose(); + }; + + // Skill source helpers + const startAddSkill = () => { + setAddingSkill(true); + setEditingSkillIndex(null); + setSkillType("github"); + setSkillSource("rivet-dev/skills"); + setSkillFilter("sandbox-agent"); + setSkillRef(""); + setSkillSubpath(""); + setSkillLocalError(null); + }; + + const startEditSkill = (index: number) => { + const entry = skillSources[index]; + setEditingSkillIndex(index); + setAddingSkill(false); + setSkillType(entry.type as "github" | "local" | "git"); + setSkillSource(entry.source); + setSkillFilter(entry.skills?.join(", ") ?? ""); + setSkillRef(entry.ref ?? ""); + setSkillSubpath(entry.subpath ?? ""); + setSkillLocalError(null); + }; + + const commitSkill = () => { + const src = skillSource.trim(); + if (!src) { + setSkillLocalError("Source is required"); + return; + } + const entry: SkillSource = { + type: skillType, + source: src, + }; + const filterList = skillFilter.trim() + ? skillFilter.split(",").map((s) => s.trim()).filter(Boolean) + : undefined; + if (filterList && filterList.length > 0) entry.skills = filterList; + if (skillRef.trim()) entry.ref = skillRef.trim(); + if (skillSubpath.trim()) entry.subpath = skillSubpath.trim(); + + if (editingSkillIndex !== null) { + const updated = [...skillSources]; + updated[editingSkillIndex] = entry; + onSkillSourcesChange(updated); + } else { + onSkillSourcesChange([...skillSources, entry]); + } + cancelSkillEdit(); + }; + + const removeSkill = (index: number) => { + onSkillSourcesChange(skillSources.filter((_, i) => i !== index)); + if (editingSkillIndex === index) { + cancelSkillEdit(); + } + }; + + const isEditingSkill = addingSkill || editingSkillIndex !== null; + + const startAddMcp = () => { + setAddingMcp(true); + setEditingMcpIndex(null); + setMcpName("everything"); + setMcpJson('{\n "type": "local",\n "command": "npx",\n "args": ["@modelcontextprotocol/server-everything"]\n}'); + setMcpLocalError(null); + }; + + const startEditMcp = (index: number) => { + const entry = mcpServers[index]; + setEditingMcpIndex(index); + setAddingMcp(false); + setMcpName(entry.name); + setMcpJson(entry.configJson); + setMcpLocalError(entry.error); + }; + + const cancelMcpEdit = () => { + setAddingMcp(false); + setEditingMcpIndex(null); + setMcpName(""); + setMcpJson(""); + setMcpLocalError(null); + }; + + const commitMcp = () => { + const name = mcpName.trim(); + if (!name) { + setMcpLocalError("Server name is required"); + return; + } + const error = validateServerJson(mcpJson); + if (error) { + setMcpLocalError(error); + return; + } + // Check for duplicate names (except when editing the same entry) + const duplicate = mcpServers.findIndex((e) => e.name === name); + if (duplicate !== -1 && duplicate !== editingMcpIndex) { + setMcpLocalError(`Server "${name}" already exists`); + return; + } + + const entry: McpServerEntry = { name, configJson: mcpJson.trim(), error: null }; + + if (editingMcpIndex !== null) { + const updated = [...mcpServers]; + updated[editingMcpIndex] = entry; + onMcpServersChange(updated); + } else { + onMcpServersChange([...mcpServers, entry]); + } + cancelMcpEdit(); + }; + + const removeMcp = (index: number) => { + onMcpServersChange(mcpServers.filter((_, i) => i !== index)); + if (editingMcpIndex === index) { + cancelMcpEdit(); + } + }; + + const isEditingMcp = addingMcp || editingMcpIndex !== null; + + if (phase === "agent") { + return ( + <div className="session-create-menu"> + {agentsLoading && <div className="sidebar-add-status">Loading agents...</div>} + {agentsError && <div className="sidebar-add-status error">{agentsError}</div>} + {!agentsLoading && !agentsError && agents.length === 0 && ( + <div className="sidebar-add-status">No agents available.</div> + )} + {!agentsLoading && !agentsError && + agents.map((agent) => ( + <button + key={agent.id} + className="sidebar-add-option" + onClick={() => handleAgentClick(agent.id)} + > + <div className="agent-option-left"> + <span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> + {agent.version && <span className="agent-option-version">{agent.version}</span>} + </div> + <div className="agent-option-badges"> + {agent.installed && <span className="agent-badge installed">Installed</span>} + <ArrowRight size={12} className="agent-option-arrow" /> + </div> + </button> + ))} + </div> + ); + } + + // Phase 2: config form + const activeModes = modesByAgent[selectedAgent] ?? []; + const modesLoading = modesLoadingByAgent[selectedAgent] ?? false; + const modesError = modesErrorByAgent[selectedAgent] ?? null; + const modelOptions = modelsByAgent[selectedAgent] ?? []; + const modelsLoading = modelsLoadingByAgent[selectedAgent] ?? false; + const modelsError = modelsErrorByAgent[selectedAgent] ?? null; + const defaultModel = defaultModelByAgent[selectedAgent] ?? ""; + const selectedModelId = model || defaultModel; + const selectedModelObj = modelOptions.find((entry) => entry.id === selectedModelId); + const variantOptions = selectedModelObj?.variants ?? []; + const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0; + const hasModelOptions = modelOptions.length > 0; + const modelCustom = + model && hasModelOptions && !modelOptions.some((entry) => entry.id === model); + const supportsVariants = + modelsLoading || + Boolean(modelsError) || + modelOptions.some((entry) => (entry.variants?.length ?? 0) > 0); + const showVariantSelect = + supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0); + const hasVariantOptions = variantOptions.length > 0; + const variantCustom = variant && hasVariantOptions && !variantOptions.includes(variant); + const agentLabel = agentLabels[selectedAgent] ?? selectedAgent; + + return ( + <div className="session-create-menu"> + <div className="session-create-header"> + <button className="session-create-back" onClick={handleBack} title="Back to agents"> + <ArrowLeft size={14} /> + </button> + <span className="session-create-agent-name">{agentLabel}</span> + </div> + + <div className="session-create-form"> + <div className="setup-field"> + <span className="setup-label">Model</span> + {showModelSelect ? ( + <select + className="setup-select" + value={model} + onChange={(e) => { setModel(e.target.value); setVariant(""); }} + title="Model" + disabled={modelsLoading || Boolean(modelsError)} + > + {modelsLoading ? ( + <option value="">Loading models...</option> + ) : modelsError ? ( + <option value="">{modelsError}</option> + ) : ( + <> + <option value=""> + {defaultModel ? `Default (${defaultModel})` : "Default"} + </option> + {modelCustom && <option value={model}>{model} (custom)</option>} + {modelOptions.map((entry) => ( + <option key={entry.id} value={entry.id}> + {entry.name ?? entry.id} + </option> + ))} + </> + )} + </select> + ) : ( + <input + className="setup-input" + value={model} + onChange={(e) => setModel(e.target.value)} + placeholder="Model" + title="Model" + /> + )} + </div> + + <div className="setup-field"> + <span className="setup-label">Mode</span> + <select + className="setup-select" + value={agentMode} + onChange={(e) => setAgentMode(e.target.value)} + title="Mode" + disabled={modesLoading || Boolean(modesError)} + > + {modesLoading ? ( + <option value="">Loading modes...</option> + ) : modesError ? ( + <option value="">{modesError}</option> + ) : activeModes.length > 0 ? ( + activeModes.map((m) => ( + <option key={m.id} value={m.id}> + {m.name || m.id} + </option> + )) + ) : ( + <option value="">Mode</option> + )} + </select> + </div> + + <div className="setup-field"> + <span className="setup-label">Permission</span> + <select + className="setup-select" + value={permissionMode} + onChange={(e) => setPermissionMode(e.target.value)} + title="Permission Mode" + > + <option value="default">Default</option> + <option value="plan">Plan</option> + <option value="bypass">Bypass</option> + </select> + </div> + + {supportsVariants && ( + <div className="setup-field"> + <span className="setup-label">Variant</span> + {showVariantSelect ? ( + <select + className="setup-select" + value={variant} + onChange={(e) => setVariant(e.target.value)} + title="Variant" + disabled={modelsLoading || Boolean(modelsError)} + > + {modelsLoading ? ( + <option value="">Loading variants...</option> + ) : modelsError ? ( + <option value="">{modelsError}</option> + ) : ( + <> + <option value="">Default</option> + {variantCustom && <option value={variant}>{variant} (custom)</option>} + {variantOptions.map((entry) => ( + <option key={entry} value={entry}> + {entry} + </option> + ))} + </> + )} + </select> + ) : ( + <input + className="setup-input" + value={variant} + onChange={(e) => setVariant(e.target.value)} + placeholder="Variant" + title="Variant" + /> + )} + </div> + )} + + {/* MCP Servers - collapsible */} + <div className="session-create-section"> + <button + type="button" + className="session-create-section-toggle" + onClick={() => setMcpExpanded(!mcpExpanded)} + > + <span className="setup-label">MCP</span> + <span className="session-create-section-count">{mcpServers.length} server{mcpServers.length !== 1 ? "s" : ""}</span> + {mcpExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />} + </button> + {mcpExpanded && ( + <div className="session-create-section-body"> + {mcpServers.length > 0 && !isEditingMcp && ( + <div className="session-create-mcp-list"> + {mcpServers.map((entry, index) => ( + <div key={entry.name} className="session-create-mcp-item"> + <div className="session-create-mcp-info"> + <span className="session-create-mcp-name">{entry.name}</span> + {getServerType(entry.configJson) && ( + <span className="session-create-mcp-type">{getServerType(entry.configJson)}</span> + )} + <span className="session-create-mcp-summary mono">{getServerSummary(entry.configJson)}</span> + </div> + <div className="session-create-mcp-actions"> + <button + type="button" + className="session-create-skill-remove" + onClick={() => startEditMcp(index)} + title="Edit server" + > + <Pencil size={10} /> + </button> + <button + type="button" + className="session-create-skill-remove" + onClick={() => removeMcp(index)} + title="Remove server" + > + <X size={12} /> + </button> + </div> + </div> + ))} + </div> + )} + {isEditingMcp ? ( + <div className="session-create-mcp-edit"> + <input + ref={mcpNameRef} + className="session-create-mcp-name-input" + value={mcpName} + onChange={(e) => { setMcpName(e.target.value); setMcpLocalError(null); }} + placeholder="server-name" + disabled={editingMcpIndex !== null} + /> + <textarea + ref={mcpJsonRef} + className="session-create-textarea mono" + value={mcpJson} + onChange={(e) => { setMcpJson(e.target.value); setMcpLocalError(null); }} + placeholder='{"type":"local","command":"node","args":["./server.js"]}' + rows={4} + /> + {mcpLocalError && ( + <div className="session-create-inline-error">{mcpLocalError}</div> + )} + <div className="session-create-mcp-edit-actions"> + <button type="button" className="session-create-mcp-save" onClick={commitMcp}> + {editingMcpIndex !== null ? "Save" : "Add"} + </button> + <button type="button" className="session-create-mcp-cancel" onClick={cancelMcpEdit}> + Cancel + </button> + </div> + </div> + ) : ( + <button + type="button" + className="session-create-add-btn" + onClick={startAddMcp} + > + <Plus size={12} /> + Add server + </button> + )} + {mcpConfigError && !isEditingMcp && ( + <div className="session-create-inline-error">{mcpConfigError}</div> + )} + </div> + )} + </div> + + {/* Skills - collapsible with source-based list */} + <div className="session-create-section"> + <button + type="button" + className="session-create-section-toggle" + onClick={() => setSkillsExpanded(!skillsExpanded)} + > + <span className="setup-label">Skills</span> + <span className="session-create-section-count">{skillSources.length} source{skillSources.length !== 1 ? "s" : ""}</span> + {skillsExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />} + </button> + {skillsExpanded && ( + <div className="session-create-section-body"> + {skillSources.length > 0 && !isEditingSkill && ( + <div className="session-create-skill-list"> + {skillSources.map((entry, index) => ( + <div key={`${entry.type}-${entry.source}-${index}`} className="session-create-skill-item"> + <span className="session-create-skill-type-badge">{entry.type}</span> + <span className="session-create-skill-path mono">{skillSourceSummary(entry)}</span> + <div className="session-create-mcp-actions"> + <button + type="button" + className="session-create-skill-remove" + onClick={() => startEditSkill(index)} + title="Edit source" + > + <Pencil size={10} /> + </button> + <button + type="button" + className="session-create-skill-remove" + onClick={() => removeSkill(index)} + title="Remove source" + > + <X size={12} /> + </button> + </div> + </div> + ))} + </div> + )} + {isEditingSkill ? ( + <div className="session-create-mcp-edit"> + <div className="session-create-skill-type-row"> + <select + className="session-create-skill-type-select" + value={skillType} + onChange={(e) => { setSkillType(e.target.value as "github" | "local" | "git"); setSkillLocalError(null); }} + > + <option value="github">github</option> + <option value="local">local</option> + <option value="git">git</option> + </select> + <input + ref={skillSourceRef} + className="session-create-skill-input mono" + value={skillSource} + onChange={(e) => { setSkillSource(e.target.value); setSkillLocalError(null); }} + placeholder={skillType === "github" ? "owner/repo" : skillType === "local" ? "/path/to/skill" : "https://git.example.com/repo.git"} + /> + </div> + <input + className="session-create-skill-input mono" + value={skillFilter} + onChange={(e) => setSkillFilter(e.target.value)} + placeholder="Filter skills (comma-separated, optional)" + /> + {skillType !== "local" && ( + <div className="session-create-skill-type-row"> + <input + className="session-create-skill-input mono" + value={skillRef} + onChange={(e) => setSkillRef(e.target.value)} + placeholder="Branch/tag (optional)" + /> + <input + className="session-create-skill-input mono" + value={skillSubpath} + onChange={(e) => setSkillSubpath(e.target.value)} + placeholder="Subpath (optional)" + /> + </div> + )} + {skillLocalError && ( + <div className="session-create-inline-error">{skillLocalError}</div> + )} + <div className="session-create-mcp-edit-actions"> + <button type="button" className="session-create-mcp-save" onClick={commitSkill}> + {editingSkillIndex !== null ? "Save" : "Add"} + </button> + <button type="button" className="session-create-mcp-cancel" onClick={cancelSkillEdit}> + Cancel + </button> + </div> + </div> + ) : ( + <button + type="button" + className="session-create-add-btn" + onClick={startAddSkill} + > + <Plus size={12} /> + Add source + </button> + )} + </div> + )} + </div> + </div> + + <div className="session-create-actions"> + <button + className="button primary" + onClick={handleCreate} + disabled={Boolean(mcpConfigError)} + > + Create Session + </button> + </div> + </div> + ); +}; + +export default SessionCreateMenu; diff --git a/frontend/packages/inspector/src/components/SessionSidebar.tsx b/frontend/packages/inspector/src/components/SessionSidebar.tsx index 996044d..ed4e19a 100644 --- a/frontend/packages/inspector/src/components/SessionSidebar.tsx +++ b/frontend/packages/inspector/src/components/SessionSidebar.tsx @@ -1,6 +1,17 @@ import { Plus, RefreshCw } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import type { AgentInfo, SessionInfo } from "sandbox-agent"; +import type { AgentInfo, AgentModelInfo, AgentModeInfo, SessionInfo, SkillSource } from "sandbox-agent"; +import type { McpServerEntry } from "../App"; +import SessionCreateMenu, { type SessionConfig } from "./SessionCreateMenu"; + +const agentLabels: Record<string, string> = { + claude: "Claude Code", + codex: "Codex", + opencode: "OpenCode", + amp: "Amp", + pi: "Pi", + mock: "Mock" +}; const SessionSidebar = ({ sessions, @@ -8,22 +19,48 @@ const SessionSidebar = ({ onSelectSession, onRefresh, onCreateSession, + onSelectAgent, agents, agentsLoading, agentsError, sessionsLoading, - sessionsError + sessionsError, + modesByAgent, + modelsByAgent, + defaultModelByAgent, + modesLoadingByAgent, + modelsLoadingByAgent, + modesErrorByAgent, + modelsErrorByAgent, + mcpServers, + onMcpServersChange, + mcpConfigError, + skillSources, + onSkillSourcesChange }: { sessions: SessionInfo[]; selectedSessionId: string; onSelectSession: (session: SessionInfo) => void; onRefresh: () => void; - onCreateSession: (agentId: string) => void; + onCreateSession: (agentId: string, config: SessionConfig) => void; + onSelectAgent: (agentId: string) => void; agents: AgentInfo[]; agentsLoading: boolean; agentsError: string | null; sessionsLoading: boolean; sessionsError: string | null; + modesByAgent: Record<string, AgentModeInfo[]>; + modelsByAgent: Record<string, AgentModelInfo[]>; + defaultModelByAgent: Record<string, string>; + modesLoadingByAgent: Record<string, boolean>; + modelsLoadingByAgent: Record<string, boolean>; + modesErrorByAgent: Record<string, string | null>; + modelsErrorByAgent: Record<string, string | null>; + mcpServers: McpServerEntry[]; + onMcpServersChange: (servers: McpServerEntry[]) => void; + mcpConfigError: string | null; + skillSources: SkillSource[]; + onSkillSourcesChange: (sources: SkillSource[]) => void; }) => { const [showMenu, setShowMenu] = useState(false); const menuRef = useRef<HTMLDivElement | null>(null); @@ -40,15 +77,6 @@ const SessionSidebar = ({ return () => document.removeEventListener("mousedown", handler); }, [showMenu]); - const agentLabels: Record<string, string> = { - claude: "Claude Code", - codex: "Codex", - opencode: "OpenCode", - amp: "Amp", - pi: "Pi", - mock: "Mock" - }; - return ( <div className="session-sidebar"> <div className="sidebar-header"> @@ -65,32 +93,27 @@ const SessionSidebar = ({ > <Plus size={14} /> </button> - {showMenu && ( - <div className="sidebar-add-menu"> - {agentsLoading && <div className="sidebar-add-status">Loading agents...</div>} - {agentsError && <div className="sidebar-add-status error">{agentsError}</div>} - {!agentsLoading && !agentsError && agents.length === 0 && ( - <div className="sidebar-add-status">No agents available.</div> - )} - {!agentsLoading && !agentsError && - agents.map((agent) => ( - <button - key={agent.id} - className="sidebar-add-option" - onClick={() => { - onCreateSession(agent.id); - setShowMenu(false); - }} - > - <div className="agent-option-left"> - <span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> - {agent.version && <span className="agent-badge version">v{agent.version}</span>} - </div> - {agent.installed && <span className="agent-badge installed">Installed</span>} - </button> - ))} - </div> - )} + <SessionCreateMenu + agents={agents} + agentsLoading={agentsLoading} + agentsError={agentsError} + modesByAgent={modesByAgent} + modelsByAgent={modelsByAgent} + defaultModelByAgent={defaultModelByAgent} + modesLoadingByAgent={modesLoadingByAgent} + modelsLoadingByAgent={modelsLoadingByAgent} + modesErrorByAgent={modesErrorByAgent} + modelsErrorByAgent={modelsErrorByAgent} + mcpServers={mcpServers} + onMcpServersChange={onMcpServersChange} + mcpConfigError={mcpConfigError} + skillSources={skillSources} + onSkillSourcesChange={onSkillSourcesChange} + onSelectAgent={onSelectAgent} + onCreateSession={onCreateSession} + open={showMenu} + onClose={() => setShowMenu(false)} + /> </div> </div> </div> diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index ff2be69..41423ec 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -1,16 +1,15 @@ -import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react"; +import { MessageSquare, Plus, Square, Terminal } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent"; +import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData, SkillSource } from "sandbox-agent"; +import type { McpServerEntry } from "../../App"; import ApprovalsTab from "../debug/ApprovalsTab"; +import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu"; import ChatInput from "./ChatInput"; import ChatMessages from "./ChatMessages"; -import ChatSetup from "./ChatSetup"; import type { TimelineEntry } from "./types"; const ChatPanel = ({ sessionId, - polling, - turnStreaming, transcriptEntries, sessionError, message, @@ -18,35 +17,18 @@ const ChatPanel = ({ onSendMessage, onKeyDown, onCreateSession, + onSelectAgent, agents, agentsLoading, agentsError, messagesEndRef, - agentId, agentLabel, - agentMode, - permissionMode, - model, - variant, - modelOptions, - defaultModel, - modelsLoading, - modelsError, - variantOptions, - defaultVariant, - supportsVariants, - streamMode, - activeModes, currentAgentVersion, - hasSession, - modesLoading, - modesError, - onAgentModeChange, - onPermissionModeChange, - onModelChange, - onVariantChange, - onStreamModeChange, - onToggleStream, + sessionModel, + sessionVariant, + sessionPermissionMode, + sessionMcpServerCount, + sessionSkillSourceCount, onEndSession, eventError, questionRequests, @@ -55,47 +37,40 @@ const ChatPanel = ({ onSelectQuestionOption, onAnswerQuestion, onRejectQuestion, - onReplyPermission + onReplyPermission, + modesByAgent, + modelsByAgent, + defaultModelByAgent, + modesLoadingByAgent, + modelsLoadingByAgent, + modesErrorByAgent, + modelsErrorByAgent, + mcpServers, + onMcpServersChange, + mcpConfigError, + skillSources, + onSkillSourcesChange }: { sessionId: string; - polling: boolean; - turnStreaming: boolean; transcriptEntries: TimelineEntry[]; sessionError: string | null; message: string; onMessageChange: (value: string) => void; onSendMessage: () => void; onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void; - onCreateSession: (agentId: string) => void; + onCreateSession: (agentId: string, config: SessionConfig) => void; + onSelectAgent: (agentId: string) => void; agents: AgentInfo[]; agentsLoading: boolean; agentsError: string | null; messagesEndRef: React.RefObject<HTMLDivElement>; - agentId: string; agentLabel: string; - agentMode: string; - permissionMode: string; - model: string; - variant: string; - modelOptions: AgentModelInfo[]; - defaultModel: string; - modelsLoading: boolean; - modelsError: string | null; - variantOptions: string[]; - defaultVariant: string; - supportsVariants: boolean; - streamMode: "poll" | "sse" | "turn"; - activeModes: AgentModeInfo[]; currentAgentVersion?: string | null; - hasSession: boolean; - modesLoading: boolean; - modesError: string | null; - onAgentModeChange: (value: string) => void; - onPermissionModeChange: (value: string) => void; - onModelChange: (value: string) => void; - onVariantChange: (value: string) => void; - onStreamModeChange: (value: "poll" | "sse" | "turn") => void; - onToggleStream: () => void; + sessionModel?: string | null; + sessionVariant?: string | null; + sessionPermissionMode?: string | null; + sessionMcpServerCount: number; + sessionSkillSourceCount: number; onEndSession: () => void; eventError: string | null; questionRequests: QuestionEventData[]; @@ -105,6 +80,18 @@ const ChatPanel = ({ onAnswerQuestion: (request: QuestionEventData) => void; onRejectQuestion: (requestId: string) => void; onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void; + modesByAgent: Record<string, AgentModeInfo[]>; + modelsByAgent: Record<string, AgentModelInfo[]>; + defaultModelByAgent: Record<string, string>; + modesLoadingByAgent: Record<string, boolean>; + modelsLoadingByAgent: Record<string, boolean>; + modesErrorByAgent: Record<string, string | null>; + modelsErrorByAgent: Record<string, string | null>; + mcpServers: McpServerEntry[]; + onMcpServersChange: (servers: McpServerEntry[]) => void; + mcpConfigError: string | null; + skillSources: SkillSource[]; + onSkillSourcesChange: (sources: SkillSource[]) => void; }) => { const [showAgentMenu, setShowAgentMenu] = useState(false); const menuRef = useRef<HTMLDivElement | null>(null); @@ -121,19 +108,7 @@ const ChatPanel = ({ return () => document.removeEventListener("mousedown", handler); }, [showAgentMenu]); - const agentLabels: Record<string, string> = { - claude: "Claude Code", - codex: "Codex", - opencode: "OpenCode", - amp: "Amp", - pi: "Pi", - mock: "Mock" - }; - const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0; - const isTurnMode = streamMode === "turn"; - const isStreaming = isTurnMode ? turnStreaming : polling; - const turnLabel = turnStreaming ? "Streaming" : "On Send"; return ( <div className="chat-panel"> @@ -142,12 +117,6 @@ const ChatPanel = ({ <MessageSquare className="button-icon" /> <span className="panel-title">{sessionId ? "Session" : "No Session"}</span> {sessionId && <span className="session-id-display">{sessionId}</span>} - {sessionId && ( - <span className="session-agent-display"> - {agentLabel} - {currentAgentVersion && <span className="session-agent-version">v{currentAgentVersion}</span>} - </span> - )} </div> <div className="panel-header-right"> {sessionId && ( @@ -161,42 +130,6 @@ const ChatPanel = ({ End </button> )} - <div className="setup-stream"> - <select - className="setup-select-small" - value={streamMode} - onChange={(e) => onStreamModeChange(e.target.value as "poll" | "sse" | "turn")} - title="Stream Mode" - disabled={!sessionId} - > - <option value="poll">Poll</option> - <option value="sse">SSE</option> - <option value="turn">Turn</option> - </select> - <button - className={`setup-stream-btn ${isStreaming ? "active" : ""}`} - onClick={onToggleStream} - title={isTurnMode ? "Turn streaming starts on send" : polling ? "Stop streaming" : "Start streaming"} - disabled={!sessionId || isTurnMode} - > - {isTurnMode ? ( - <> - <PlayCircle size={14} /> - <span>{turnLabel}</span> - </> - ) : polling ? ( - <> - <PauseCircle size={14} /> - <span>Pause</span> - </> - ) : ( - <> - <PlayCircle size={14} /> - <span>Resume</span> - </> - )} - </button> - </div> </div> </div> @@ -214,32 +147,27 @@ const ChatPanel = ({ <Plus className="button-icon" /> Create Session </button> - {showAgentMenu && ( - <div className="empty-state-menu"> - {agentsLoading && <div className="sidebar-add-status">Loading agents...</div>} - {agentsError && <div className="sidebar-add-status error">{agentsError}</div>} - {!agentsLoading && !agentsError && agents.length === 0 && ( - <div className="sidebar-add-status">No agents available.</div> - )} - {!agentsLoading && !agentsError && - agents.map((agent) => ( - <button - key={agent.id} - className="sidebar-add-option" - onClick={() => { - onCreateSession(agent.id); - setShowAgentMenu(false); - }} - > - <div className="agent-option-left"> - <span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span> - {agent.version && <span className="agent-badge version">v{agent.version}</span>} - </div> - {agent.installed && <span className="agent-badge installed">Installed</span>} - </button> - ))} - </div> - )} + <SessionCreateMenu + agents={agents} + agentsLoading={agentsLoading} + agentsError={agentsError} + modesByAgent={modesByAgent} + modelsByAgent={modelsByAgent} + defaultModelByAgent={defaultModelByAgent} + modesLoadingByAgent={modesLoadingByAgent} + modelsLoadingByAgent={modelsLoadingByAgent} + modesErrorByAgent={modesErrorByAgent} + modelsErrorByAgent={modelsErrorByAgent} + mcpServers={mcpServers} + onMcpServersChange={onMcpServersChange} + mcpConfigError={mcpConfigError} + skillSources={skillSources} + onSkillSourcesChange={onSkillSourcesChange} + onSelectAgent={onSelectAgent} + onCreateSession={onCreateSession} + open={showAgentMenu} + onClose={() => setShowAgentMenu(false)} + /> </div> </div> ) : transcriptEntries.length === 0 && !sessionError ? ( @@ -247,7 +175,7 @@ const ChatPanel = ({ <Terminal className="empty-state-icon" /> <div className="empty-state-title">Ready to Chat</div> <p className="empty-state-text">Send a message to start a conversation with the agent.</p> - {agentId === "mock" && ( + {agentLabel === "Mock" && ( <div className="mock-agent-hint"> The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send <code>help</code> for available commands. </div> @@ -284,30 +212,37 @@ const ChatPanel = ({ onSendMessage={onSendMessage} onKeyDown={onKeyDown} placeholder={sessionId ? "Send a message..." : "Select or create a session first"} - disabled={!sessionId || turnStreaming} + disabled={!sessionId} /> - <ChatSetup - agentMode={agentMode} - permissionMode={permissionMode} - model={model} - variant={variant} - modelOptions={modelOptions} - defaultModel={defaultModel} - modelsLoading={modelsLoading} - modelsError={modelsError} - variantOptions={variantOptions} - defaultVariant={defaultVariant} - supportsVariants={supportsVariants} - activeModes={activeModes} - modesLoading={modesLoading} - modesError={modesError} - onAgentModeChange={onAgentModeChange} - onPermissionModeChange={onPermissionModeChange} - onModelChange={onModelChange} - onVariantChange={onVariantChange} - hasSession={hasSession} - /> + {sessionId && ( + <div className="session-config-bar"> + <div className="session-config-field"> + <span className="session-config-label">Agent</span> + <span className="session-config-value">{agentLabel}</span> + </div> + <div className="session-config-field"> + <span className="session-config-label">Model</span> + <span className="session-config-value">{sessionModel || "-"}</span> + </div> + <div className="session-config-field"> + <span className="session-config-label">Variant</span> + <span className="session-config-value">{sessionVariant || "-"}</span> + </div> + <div className="session-config-field"> + <span className="session-config-label">Permission</span> + <span className="session-config-value">{sessionPermissionMode || "-"}</span> + </div> + <div className="session-config-field"> + <span className="session-config-label">MCP Servers</span> + <span className="session-config-value">{sessionMcpServerCount}</span> + </div> + <div className="session-config-field"> + <span className="session-config-label">Skills</span> + <span className="session-config-value">{sessionSkillSourceCount}</span> + </div> + </div> + )} </div> ); }; diff --git a/frontend/packages/inspector/src/components/chat/ChatSetup.tsx b/frontend/packages/inspector/src/components/chat/ChatSetup.tsx deleted file mode 100644 index 3fa42d1..0000000 --- a/frontend/packages/inspector/src/components/chat/ChatSetup.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import type { AgentModelInfo, AgentModeInfo } from "sandbox-agent"; - -const ChatSetup = ({ - agentMode, - permissionMode, - model, - variant, - modelOptions, - defaultModel, - modelsLoading, - modelsError, - variantOptions, - defaultVariant, - supportsVariants, - activeModes, - hasSession, - modesLoading, - modesError, - onAgentModeChange, - onPermissionModeChange, - onModelChange, - onVariantChange -}: { - agentMode: string; - permissionMode: string; - model: string; - variant: string; - modelOptions: AgentModelInfo[]; - defaultModel: string; - modelsLoading: boolean; - modelsError: string | null; - variantOptions: string[]; - defaultVariant: string; - supportsVariants: boolean; - activeModes: AgentModeInfo[]; - hasSession: boolean; - modesLoading: boolean; - modesError: string | null; - onAgentModeChange: (value: string) => void; - onPermissionModeChange: (value: string) => void; - onModelChange: (value: string) => void; - onVariantChange: (value: string) => void; -}) => { - const hasModelOptions = modelOptions.length > 0; - const showModelSelect = hasModelOptions && !modelsError; - const hasVariantOptions = variantOptions.length > 0; - const showVariantSelect = supportsVariants && hasVariantOptions && !modelsError; - const modelCustom = - model && hasModelOptions && !modelOptions.some((entry) => entry.id === model); - const variantCustom = - variant && hasVariantOptions && !variantOptions.includes(variant); - - return ( - <div className="setup-row"> - <div className="setup-field"> - <span className="setup-label">Mode</span> - <select - className="setup-select" - value={agentMode} - onChange={(e) => onAgentModeChange(e.target.value)} - title="Mode" - disabled={!hasSession || modesLoading || Boolean(modesError)} - > - {modesLoading ? ( - <option value="">Loading modes...</option> - ) : modesError ? ( - <option value="">{modesError}</option> - ) : activeModes.length > 0 ? ( - activeModes.map((mode) => ( - <option key={mode.id} value={mode.id}> - {mode.name || mode.id} - </option> - )) - ) : ( - <option value="">Mode</option> - )} - </select> - </div> - - <div className="setup-field"> - <span className="setup-label">Permission</span> - <select - className="setup-select" - value={permissionMode} - onChange={(e) => onPermissionModeChange(e.target.value)} - title="Permission Mode" - disabled={!hasSession} - > - <option value="default">Default</option> - <option value="plan">Plan</option> - <option value="bypass">Bypass</option> - </select> - </div> - - <div className="setup-field"> - <span className="setup-label">Model</span> - {showModelSelect ? ( - <select - className="setup-select" - value={model} - onChange={(e) => onModelChange(e.target.value)} - title="Model" - disabled={!hasSession || modelsLoading || Boolean(modelsError)} - > - {modelsLoading ? ( - <option value="">Loading models...</option> - ) : modelsError ? ( - <option value="">{modelsError}</option> - ) : ( - <> - <option value=""> - {defaultModel ? `Default (${defaultModel})` : "Default"} - </option> - {modelCustom && <option value={model}>{model} (custom)</option>} - {modelOptions.map((entry) => ( - <option key={entry.id} value={entry.id}> - {entry.name ?? entry.id} - </option> - ))} - </> - )} - </select> - ) : ( - <input - className="setup-input" - value={model} - onChange={(e) => onModelChange(e.target.value)} - placeholder="Model" - title="Model" - disabled={!hasSession} - /> - )} - </div> - - <div className="setup-field"> - <span className="setup-label">Variant</span> - {showVariantSelect ? ( - <select - className="setup-select" - value={variant} - onChange={(e) => onVariantChange(e.target.value)} - title="Variant" - disabled={!hasSession || !supportsVariants || modelsLoading || Boolean(modelsError)} - > - {modelsLoading ? ( - <option value="">Loading variants...</option> - ) : modelsError ? ( - <option value="">{modelsError}</option> - ) : ( - <> - <option value=""> - {defaultVariant ? `Default (${defaultVariant})` : "Default"} - </option> - {variantCustom && <option value={variant}>{variant} (custom)</option>} - {variantOptions.map((entry) => ( - <option key={entry} value={entry}> - {entry} - </option> - ))} - </> - )} - </select> - ) : ( - <input - className="setup-input" - value={variant} - onChange={(e) => onVariantChange(e.target.value)} - placeholder={supportsVariants ? "Variant" : "Variants unsupported"} - title="Variant" - disabled={!hasSession || !supportsVariants} - /> - )} - </div> - </div> - ); -}; - -export default ChatSetup; diff --git a/frontend/packages/inspector/src/components/debug/AgentsTab.tsx b/frontend/packages/inspector/src/components/debug/AgentsTab.tsx index 1d6216c..1e5e2b3 100644 --- a/frontend/packages/inspector/src/components/debug/AgentsTab.tsx +++ b/frontend/packages/inspector/src/components/debug/AgentsTab.tsx @@ -1,4 +1,5 @@ -import { Download, RefreshCw } from "lucide-react"; +import { Download, Loader2, RefreshCw } from "lucide-react"; +import { useState } from "react"; import type { AgentInfo, AgentModeInfo } from "sandbox-agent"; import FeatureCoverageBadges from "../agents/FeatureCoverageBadges"; import { emptyFeatureCoverage } from "../../types/agents"; @@ -16,10 +17,21 @@ const AgentsTab = ({ defaultAgents: string[]; modesByAgent: Record<string, AgentModeInfo[]>; onRefresh: () => void; - onInstall: (agentId: string, reinstall: boolean) => void; + onInstall: (agentId: string, reinstall: boolean) => Promise<void>; loading: boolean; error: string | null; }) => { + const [installingAgent, setInstallingAgent] = useState<string | null>(null); + + const handleInstall = async (agentId: string, reinstall: boolean) => { + setInstallingAgent(agentId); + try { + await onInstall(agentId, reinstall); + } finally { + setInstallingAgent(null); + } + }; + return ( <> <div className="inline-row" style={{ marginBottom: 16 }}> @@ -39,42 +51,57 @@ const AgentsTab = ({ : defaultAgents.map((id) => ({ id, installed: false, + credentialsAvailable: false, version: undefined, path: undefined, capabilities: emptyFeatureCoverage - }))).map((agent) => ( - <div key={agent.id} className="card"> - <div className="card-header"> - <span className="card-title">{agent.id}</span> - <span className={`pill ${agent.installed ? "success" : "danger"}`}> - {agent.installed ? "Installed" : "Missing"} - </span> - </div> - <div className="card-meta"> - {agent.version ? `v${agent.version}` : "Version unknown"} - {agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>} - </div> - <div className="card-meta" style={{ marginTop: 8 }}> - Feature coverage - </div> - <div style={{ marginTop: 8 }}> - <FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} /> - </div> - {modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && ( - <div className="card-meta" style={{ marginTop: 8 }}> - Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")} + }))).map((agent) => { + const isInstalling = installingAgent === agent.id; + return ( + <div key={agent.id} className="card"> + <div className="card-header"> + <span className="card-title">{agent.id}</span> + <div className="card-header-pills"> + <span className={`pill ${agent.installed ? "success" : "danger"}`}> + {agent.installed ? "Installed" : "Missing"} + </span> + <span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}> + {agent.credentialsAvailable ? "Authenticated" : "No Credentials"} + </span> + </div> + </div> + <div className="card-meta"> + {agent.version ?? "Version unknown"} + {agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>} + </div> + <div className="card-meta" style={{ marginTop: 8 }}> + Feature coverage + </div> + <div style={{ marginTop: 8 }}> + <FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} /> + </div> + {modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && ( + <div className="card-meta" style={{ marginTop: 8 }}> + Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")} + </div> + )} + <div className="card-actions"> + <button + className="button secondary small" + onClick={() => handleInstall(agent.id, agent.installed)} + disabled={isInstalling} + > + {isInstalling ? ( + <Loader2 className="button-icon spinner-icon" /> + ) : ( + <Download className="button-icon" /> + )} + {isInstalling ? "Installing..." : agent.installed ? "Reinstall" : "Install"} + </button> </div> - )} - <div className="card-actions"> - <button className="button secondary small" onClick={() => onInstall(agent.id, false)}> - <Download className="button-icon" /> Install - </button> - <button className="button ghost small" onClick={() => onInstall(agent.id, true)}> - Reinstall - </button> </div> - </div> - ))} + ); + })} </> ); }; diff --git a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx index 9c5de66..6fa5e3c 100644 --- a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx +++ b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx @@ -40,7 +40,7 @@ const DebugPanel = ({ defaultAgents: string[]; modesByAgent: Record<string, AgentModeInfo[]>; onRefreshAgents: () => void; - onInstallAgent: (agentId: string, reinstall: boolean) => void; + onInstallAgent: (agentId: string, reinstall: boolean) => Promise<void>; agentsLoading: boolean; agentsError: string | null; }) => { diff --git a/frontend/packages/inspector/src/components/debug/eventUtils.ts b/frontend/packages/inspector/src/components/debug/eventUtils.ts index 3e946ff..6f307a2 100644 --- a/frontend/packages/inspector/src/components/debug/eventUtils.ts +++ b/frontend/packages/inspector/src/components/debug/eventUtils.ts @@ -30,6 +30,10 @@ export const getEventIcon = (type: string) => { return PlayCircle; case "session.ended": return PauseCircle; + case "turn.started": + return PlayCircle; + case "turn.ended": + return PauseCircle; case "item.started": return MessageSquare; case "item.delta": diff --git a/frontend/packages/website/Dockerfile b/frontend/packages/website/Dockerfile index 0c2b315..c585bbb 100644 --- a/frontend/packages/website/Dockerfile +++ b/frontend/packages/website/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine AS build WORKDIR /app -RUN npm install -g pnpm +RUN npm install -g pnpm@9 # Copy website package COPY frontend/packages/website/package.json ./ diff --git a/gigacode/src/main.rs b/gigacode/src/main.rs index 87e93aa..6710c17 100644 --- a/gigacode/src/main.rs +++ b/gigacode/src/main.rs @@ -17,9 +17,19 @@ fn run() -> Result<(), CliError> { no_token: cli.no_token, gigacode: true, }; - let command = cli - .command - .unwrap_or_else(|| Command::Opencode(OpencodeArgs::default())); + let yolo = cli.yolo; + let command = match cli.command { + Some(Command::Opencode(mut args)) => { + args.yolo = args.yolo || yolo; + Command::Opencode(args) + } + Some(other) => other, + None => { + let mut args = OpencodeArgs::default(); + args.yolo = yolo; + Command::Opencode(args) + } + }; if let Err(err) = init_logging(&command) { eprintln!("failed to init logging: {err}"); return Err(err); diff --git a/justfile b/justfile index 714768c..cca4f55 100644 --- a/justfile +++ b/justfile @@ -27,8 +27,12 @@ release-build-all: # ============================================================================= [group('dev')] -dev: - pnpm dev -F @sandbox-agent/inspector +dev-daemon: + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p sandbox-agent -- daemon start --upgrade + +[group('dev')] +dev: dev-daemon + pnpm dev -F @sandbox-agent/inspector -- --host 0.0.0.0 [group('dev')] build: @@ -50,17 +54,27 @@ fmt: [group('dev')] install-fast-sa: - cargo build --release -p sandbox-agent + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build --release -p sandbox-agent + rm -f ~/.cargo/bin/sandbox-agent cp target/release/sandbox-agent ~/.cargo/bin/sandbox-agent [group('dev')] -install-fast-gigacode: - cargo build --release -p gigacode +install-gigacode: + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo build --release -p gigacode + rm -f ~/.cargo/bin/gigacode cp target/release/gigacode ~/.cargo/bin/gigacode +[group('dev')] +run-sa *ARGS: + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p sandbox-agent -- {{ ARGS }} + +[group('dev')] +run-gigacode *ARGS: + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo run -p gigacode -- {{ ARGS }} + [group('dev')] dev-docs: - cd docs && pnpm dlx mintlify dev + cd docs && pnpm dlx mintlify dev --host 0.0.0.0 install: pnpm install @@ -77,4 +91,3 @@ install-release: pnpm build --filter @sandbox-agent/inspector... cargo install --path server/packages/sandbox-agent cargo install --path gigacode - diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 819556a..09ec9bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,7 +17,7 @@ importers: version: 2.7.6 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) examples/cloudflare: dependencies: @@ -36,10 +36,10 @@ importers: devDependencies: '@cloudflare/workers-types': specifier: latest - version: 4.20260206.0 + version: 4.20260207.0 '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 '@types/react': specifier: ^18.3.3 version: 18.3.27 @@ -48,19 +48,41 @@ importers: version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^4.5.0 - version: 4.7.0(vite@6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@6.4.1(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) typescript: specifier: latest version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: latest - version: 4.63.0(@cloudflare/workers-types@4.20260206.0) + version: 4.63.0(@cloudflare/workers-types@4.20260207.0) + + examples/computesdk: + dependencies: + '@sandbox-agent/example-shared': + specifier: workspace:* + version: link:../shared + computesdk: + specifier: latest + version: 2.2.0 + devDependencies: + '@types/node': + specifier: latest + version: 25.2.3 + tsx: + specifier: latest + version: 4.21.0 + typescript: + specifier: latest + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) examples/daytona: dependencies: @@ -70,10 +92,13 @@ importers: '@sandbox-agent/example-shared': specifier: workspace:* version: link:../shared + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript devDependencies: '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 tsx: specifier: latest version: 4.21.0 @@ -89,13 +114,16 @@ importers: dockerode: specifier: latest version: 4.0.9 + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript devDependencies: '@types/dockerode': specifier: latest version: 4.0.1 '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 tsx: specifier: latest version: 4.21.0 @@ -104,7 +132,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) examples/e2b: dependencies: @@ -120,7 +148,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 tsx: specifier: latest version: 4.21.0 @@ -129,17 +157,133 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - examples/shared: + examples/file-system: dependencies: + '@sandbox-agent/example-shared': + specifier: workspace:* + version: link:../shared + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript + tar: + specifier: ^7 + version: 7.5.6 + devDependencies: + '@types/node': + specifier: latest + version: 25.2.2 + tsx: + specifier: latest + version: 4.21.0 + typescript: + specifier: latest + version: 5.9.3 + + examples/mcp: + dependencies: + '@sandbox-agent/example-shared': + specifier: workspace:* + version: link:../shared sandbox-agent: specifier: workspace:* version: link:../../sdks/typescript devDependencies: '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 + tsx: + specifier: latest + version: 4.21.0 + typescript: + specifier: latest + version: 5.9.3 + + examples/mcp-custom-tool: + dependencies: + '@modelcontextprotocol/sdk': + specifier: latest + version: 1.26.0(zod@4.3.6) + '@sandbox-agent/example-shared': + specifier: workspace:* + version: link:../shared + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript + zod: + specifier: latest + version: 4.3.6 + devDependencies: + '@types/node': + specifier: latest + version: 25.2.2 + esbuild: + specifier: latest + version: 0.27.3 + tsx: + specifier: latest + version: 4.21.0 + typescript: + specifier: latest + version: 5.9.3 + + examples/shared: + dependencies: + dockerode: + specifier: latest + version: 4.0.9 + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript + devDependencies: + '@types/dockerode': + specifier: latest + version: 4.0.1 + '@types/node': + specifier: latest + version: 25.2.2 + typescript: + specifier: latest + version: 5.9.3 + + examples/skills: + dependencies: + '@sandbox-agent/example-shared': + specifier: workspace:* + version: link:../shared + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript + devDependencies: + '@types/node': + specifier: latest + version: 25.2.2 + tsx: + specifier: latest + version: 4.21.0 + typescript: + specifier: latest + version: 5.9.3 + + examples/skills-custom-tool: + dependencies: + '@sandbox-agent/example-shared': + specifier: workspace:* + version: link:../shared + sandbox-agent: + specifier: workspace:* + version: link:../../sdks/typescript + devDependencies: + '@types/node': + specifier: latest + version: 25.2.2 + esbuild: + specifier: latest + version: 0.27.3 + tsx: + specifier: latest + version: 4.21.0 typescript: specifier: latest version: 5.9.3 @@ -158,7 +302,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 tsx: specifier: latest version: 4.21.0 @@ -167,7 +311,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) frontend/packages/inspector: dependencies: @@ -189,7 +333,7 @@ importers: version: 18.3.7(@types/react@18.3.27) '@vitejs/plugin-react': specifier: ^4.3.1 - version: 4.7.0(vite@5.4.21(@types/node@25.2.1)) + version: 4.7.0(vite@5.4.21(@types/node@25.2.3)) sandbox-agent: specifier: workspace:* version: link:../../../sdks/typescript @@ -198,19 +342,19 @@ importers: version: 5.9.3 vite: specifier: ^5.4.7 - version: 5.4.21(@types/node@25.2.1) + version: 5.4.21(@types/node@25.2.3) frontend/packages/website: dependencies: '@astrojs/react': specifier: ^4.2.0 - version: 4.4.2(@types/node@25.2.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) + version: 4.4.2(@types/node@25.2.3)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.16.15(@types/node@25.2.1)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) + version: 6.0.2(astro@5.16.15(@types/node@25.2.3)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) astro: specifier: ^5.1.0 - version: 5.16.15(@types/node@25.2.1)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 5.16.15(@types/node@25.2.3)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) framer-motion: specifier: ^12.0.0 version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -241,7 +385,7 @@ importers: dependencies: '@anthropic-ai/claude-code': specifier: latest - version: 2.1.34 + version: 2.1.37 '@openai/codex': specifier: latest version: 0.98.0 @@ -326,7 +470,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 25.2.1 + version: 25.2.2 tsx: specifier: latest version: 4.21.0 @@ -358,7 +502,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/cli-shared: devDependencies: @@ -406,7 +550,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/gigacode/platforms/darwin-arm64: {} @@ -423,6 +567,10 @@ importers: '@sandbox-agent/cli-shared': specifier: workspace:* version: link:../cli-shared + optionalDependencies: + '@sandbox-agent/cli': + specifier: workspace:* + version: link:../cli devDependencies: '@types/node': specifier: ^22.0.0 @@ -446,8 +594,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@anthropic-ai/claude-code@2.1.34': - resolution: {integrity: sha512-uQ3yv41lvCExj2Ju/pCZ1KIKub5d5V3RQyeSKICPoJzk/H2Ktp0zonZeLkD/Q56qa4vPpA8MmvsBmFkAr+Z42w==} + '@anthropic-ai/claude-code@2.1.37': + resolution: {integrity: sha512-YNrhAhWh/WAXAibZWfGBIUcMp+5caHGJKPkOjKSgYnCNQf7f+fP7eVTF1tr5FvvEksk2d9/HJgnh1fqOo1mP/A==} engines: {node: '>=18.0.0'} hasBin: true @@ -822,8 +970,11 @@ packages: cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260206.0': - resolution: {integrity: sha512-rHbE1XM3mfwzoyOiKm1oFRTp00Cv4U5UiuMDQwmu/pc79yOA3nDiOC0lue8aOpobBrP4tPHQqsPcWG606Zrw/w==} + '@cloudflare/workers-types@4.20260207.0': + resolution: {integrity: sha512-PSxgnAOK0EtTytlY7/+gJcsQJYg0Qo7KlOMSC/wiBE+pBqKjuKdd1ZgM+NvpPNqZAjWV5jqAMTTNYEmgk27gYw==} + + '@computesdk/cmd@0.4.1': + resolution: {integrity: sha512-hhcYrwMnOpRSwWma3gkUeAVsDFG56nURwSaQx8vCepv0IuUv39bK4mMkgszolnUQrVjBDdW7b3lV+l5B2S8fRA==} '@connectrpc/connect-web@2.0.0-rc.3': resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} @@ -880,6 +1031,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -904,6 +1061,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -928,6 +1091,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -952,6 +1121,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -976,6 +1151,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -1000,6 +1181,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -1024,6 +1211,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -1048,6 +1241,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -1072,6 +1271,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -1096,6 +1301,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -1120,6 +1331,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -1144,6 +1361,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -1168,6 +1391,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -1192,6 +1421,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -1216,6 +1451,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -1240,6 +1481,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -1264,6 +1511,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} @@ -1282,6 +1535,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -1306,6 +1565,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} @@ -1324,6 +1589,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -1348,6 +1619,12 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} @@ -1366,6 +1643,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -1390,6 +1673,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -1414,6 +1703,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -1438,6 +1733,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -1462,6 +1763,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -1480,6 +1787,12 @@ packages: engines: {node: '>=6'} hasBin: true + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@iarna/toml@2.2.5': resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==} @@ -1741,6 +2054,16 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2239,8 +2562,11 @@ packages: '@types/node@24.10.9': resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} - '@types/node@25.2.1': - resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==} + '@types/node@25.2.2': + resolution: {integrity: sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ==} + + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2307,11 +2633,26 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-align@3.0.1: resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} @@ -2432,6 +2773,10 @@ packages: blake3-wasm@2.1.5: resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -2474,6 +2819,10 @@ packages: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -2482,6 +2831,10 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -2595,6 +2948,9 @@ packages: compare-versions@6.1.1: resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + computesdk@2.2.0: + resolution: {integrity: sha512-gAAL8vMLkYUFH138OwbebTG9AYMh4RudhRvYboJvRdc9NQAafVHfvZtPwg4YVKPB3VpsfK5m9pkgv60Xr2cE1g==} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -2602,16 +2958,36 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cpu-features@0.0.10: resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} @@ -2673,6 +3049,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2747,6 +3127,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.278: resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} @@ -2759,6 +3142,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -2819,10 +3206,18 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -2833,6 +3228,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -2843,6 +3242,14 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@8.0.1: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} @@ -2855,9 +3262,22 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} @@ -2865,6 +3285,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.2.5: resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} hasBin: true @@ -2885,6 +3308,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} @@ -2916,6 +3343,10 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2933,6 +3364,10 @@ packages: react-dom: optional: true + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -3046,6 +3481,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hono@4.11.8: + resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==} + engines: {node: '>=16.9.0'} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -3058,6 +3497,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -3066,6 +3509,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3075,6 +3522,14 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -3116,6 +3571,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@3.0.0: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3143,6 +3601,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -3162,6 +3623,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3278,6 +3745,14 @@ packages: mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3377,10 +3852,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -3436,6 +3919,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -3471,12 +3958,20 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + ofetch@1.5.1: resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3538,6 +4033,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3560,6 +4059,9 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3589,6 +4091,10 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -3669,18 +4175,34 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + qs@6.14.1: + resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} + engines: {node: '>=0.6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -3763,6 +4285,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -3800,6 +4326,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -3832,6 +4362,17 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -3851,6 +4392,22 @@ packages: shiki@3.21.0: resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -3886,6 +4443,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -4020,6 +4581,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -4116,6 +4681,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -4187,6 +4756,10 @@ packages: unist-util-visit@5.1.0: resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + unstorage@1.17.4: resolution: {integrity: sha512-fHK0yNg38tBiJKp/Vgsq4j0JEsCmgqH58HAn707S7zGkArbZsVr/CwINoi+nh3h98BRCwKvx1K3Xg9u3VV83sw==} peerDependencies: @@ -4262,6 +4835,10 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -4543,6 +5120,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -4550,7 +5130,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/claude-code@2.1.34': + '@anthropic-ai/claude-code@2.1.37': optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -4595,15 +5175,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@25.2.1)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': + '@astrojs/react@4.4.2(@types/node@25.2.3)(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(jiti@1.21.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2)': dependencies: '@types/react': 18.3.27 '@types/react-dom': 18.3.7(@types/react@18.3.27) - '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + '@vitejs/plugin-react': 4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) ultrahtml: 1.6.0 - vite: 6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -4618,9 +5198,9 @@ snapshots: - tsx - yaml - '@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.2.1)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': + '@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.2.3)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': dependencies: - astro: 5.16.15(@types/node@25.2.1)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 5.16.15(@types/node@25.2.3)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) autoprefixer: 10.4.23(postcss@8.5.6) postcss: 8.5.6 postcss-load-config: 4.0.2(postcss@8.5.6) @@ -5333,7 +5913,9 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260205.0': optional: true - '@cloudflare/workers-types@4.20260206.0': {} + '@cloudflare/workers-types@4.20260207.0': {} + + '@computesdk/cmd@0.4.1': {} '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.11.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.11.0))': dependencies: @@ -5403,6 +5985,9 @@ snapshots: '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true @@ -5415,6 +6000,9 @@ snapshots: '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm@0.21.5': optional: true @@ -5427,6 +6015,9 @@ snapshots: '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-x64@0.21.5': optional: true @@ -5439,6 +6030,9 @@ snapshots: '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true @@ -5451,6 +6045,9 @@ snapshots: '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true @@ -5463,6 +6060,9 @@ snapshots: '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true @@ -5475,6 +6075,9 @@ snapshots: '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true @@ -5487,6 +6090,9 @@ snapshots: '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true @@ -5499,6 +6105,9 @@ snapshots: '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true @@ -5511,6 +6120,9 @@ snapshots: '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true @@ -5523,6 +6135,9 @@ snapshots: '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true @@ -5535,6 +6150,9 @@ snapshots: '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true @@ -5547,6 +6165,9 @@ snapshots: '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true @@ -5559,6 +6180,9 @@ snapshots: '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true @@ -5571,6 +6195,9 @@ snapshots: '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true @@ -5583,6 +6210,9 @@ snapshots: '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true @@ -5595,6 +6225,9 @@ snapshots: '@esbuild/linux-x64@0.27.2': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + '@esbuild/netbsd-arm64@0.25.12': optional: true @@ -5604,6 +6237,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true @@ -5616,6 +6252,9 @@ snapshots: '@esbuild/netbsd-x64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + '@esbuild/openbsd-arm64@0.25.12': optional: true @@ -5625,6 +6264,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true @@ -5637,6 +6279,9 @@ snapshots: '@esbuild/openbsd-x64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + '@esbuild/openharmony-arm64@0.25.12': optional: true @@ -5646,6 +6291,9 @@ snapshots: '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true @@ -5658,6 +6306,9 @@ snapshots: '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true @@ -5670,6 +6321,9 @@ snapshots: '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true @@ -5682,6 +6336,9 @@ snapshots: '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true @@ -5694,6 +6351,9 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + '@fastify/busboy@2.1.1': {} '@grpc/grpc-js@1.14.3': @@ -5715,6 +6375,10 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 + '@hono/node-server@1.19.9(hono@4.11.8)': + dependencies: + hono: 4.11.8 + '@iarna/toml@2.2.5': {} '@img/colour@1.0.0': {} @@ -5917,6 +6581,28 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.8) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.11.8 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6465,13 +7151,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.2.1 + '@types/node': 24.10.9 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.2.1 + '@types/node': 25.2.2 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -6504,7 +7190,11 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.2.1': + '@types/node@25.2.2': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -6546,7 +7236,7 @@ snapshots: - bare-abort-controller - react-native-b4a - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.1))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.3))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -6554,11 +7244,11 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 5.4.21(@types/node@25.2.1) + vite: 5.4.21(@types/node@25.2.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -6566,7 +7256,19 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -6578,13 +7280,21 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.2.1))': + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.2.2))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@25.2.1) + vite: 5.4.21(@types/node@25.2.2) + + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.2.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.2.3) '@vitest/pretty-format@3.2.4': dependencies: @@ -6612,8 +7322,24 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-align@3.0.1: dependencies: string-width: 4.2.3 @@ -6651,7 +7377,7 @@ snapshots: assertion-error@2.0.1: {} - astro@5.16.15(@types/node@25.2.1)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + astro@5.16.15(@types/node@25.2.3)(jiti@1.21.7)(rollup@4.56.0)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@astrojs/compiler': 2.13.0 '@astrojs/internal-helpers': 0.7.5 @@ -6708,8 +7434,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.4 vfile: 6.0.3 - vite: 6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + vite: 6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vitefu: 1.1.1(vite@6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -6806,6 +7532,20 @@ snapshots: blake3-wasm@2.1.5: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} bowser@2.13.1: {} @@ -6850,15 +7590,17 @@ snapshots: buildcheck@0.0.7: optional: true - bundle-require@5.1.0(esbuild@0.27.2): + bundle-require@5.1.0(esbuild@0.27.3): dependencies: - esbuild: 0.27.2 + esbuild: 0.27.3 load-tsconfig: 0.2.5 busboy@1.6.0: dependencies: streamsearch: 1.1.0 + bytes@3.1.2: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -6866,6 +7608,11 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} camelcase@8.0.0: {} @@ -6975,16 +7722,33 @@ snapshots: compare-versions@6.1.1: {} + computesdk@2.2.0: + dependencies: + '@computesdk/cmd': 0.4.1 + confbox@0.1.8: {} consola@3.4.2: {} + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + convert-source-map@2.0.0: {} cookie-es@1.2.2: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cpu-features@0.0.10: dependencies: buildcheck: 0.0.7 @@ -7043,6 +7807,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -7134,6 +7900,8 @@ snapshots: eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} + electron-to-chromium@1.5.278: {} emoji-regex@10.6.0: {} @@ -7142,6 +7910,8 @@ snapshots: emoji-regex@9.2.2: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -7289,8 +8059,39 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@5.0.0: {} estree-walker@2.0.2: {} @@ -7299,6 +8100,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + etag@1.8.1: {} + eventemitter3@5.0.4: {} events-universal@1.0.1: @@ -7309,6 +8112,12 @@ snapshots: events@3.3.0: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@8.0.1: dependencies: cross-spawn: 7.0.6 @@ -7327,8 +8136,48 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + extend@3.0.2: {} + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} fast-glob@3.3.3: @@ -7339,6 +8188,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-uri@3.1.0: {} + fast-xml-parser@5.2.5: dependencies: strnum: 2.1.2 @@ -7355,6 +8206,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -7386,6 +8248,8 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + fraction.js@5.3.4: {} framer-motion@12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -7397,6 +8261,8 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} + fs-constants@1.0.0: {} fsevents@2.3.3: @@ -7577,6 +8443,8 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hono@4.11.8: {} + html-escaper@3.0.3: {} html-void-elements@3.0.0: {} @@ -7590,18 +8458,34 @@ snapshots: http-cache-semantics@4.2.0: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + human-signals@5.0.0: {} iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} import-meta-resolve@4.2.0: {} inherits@2.0.4: {} + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + iron-webcrypto@1.2.1: {} is-binary-path@2.1.0: @@ -7630,6 +8514,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-stream@3.0.0: {} is-wsl@3.1.0: @@ -7654,6 +8540,8 @@ snapshots: jiti@1.21.7: {} + jose@6.1.3: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -7666,6 +8554,10 @@ snapshots: jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + json5@2.2.3: {} jsonlines@0.1.1: {} @@ -7846,6 +8738,10 @@ snapshots: mdn-data@2.12.2: {} + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -8048,10 +8944,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-fn@4.0.0: {} miniflare@4.20260205.0: @@ -8110,6 +9012,8 @@ snapshots: nanoid@3.3.11: {} + negotiator@1.0.0: {} + neotraverse@0.6.18: {} nlcst-to-string@4.0.0: @@ -8136,6 +9040,8 @@ snapshots: object-hash@3.0.0: {} + object-inspect@1.13.4: {} + ofetch@1.5.1: dependencies: destr: 2.0.5 @@ -8144,6 +9050,10 @@ snapshots: ohash@2.0.11: {} + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8216,6 +9126,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -8234,6 +9146,8 @@ snapshots: path-to-regexp@6.3.0: {} + path-to-regexp@8.3.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -8250,6 +9164,8 @@ snapshots: pirates@4.0.7: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -8325,9 +9241,14 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.2.1 + '@types/node': 24.10.9 long: 5.3.2 + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} pump@3.0.3: @@ -8335,10 +9256,23 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + qs@6.14.1: + dependencies: + side-channel: 1.1.0 + queue-microtask@1.2.3: {} radix3@1.1.2: {} + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -8454,6 +9388,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8524,6 +9460,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -8546,6 +9492,33 @@ snapshots: semver@7.7.3: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -8596,6 +9569,34 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -8622,6 +9623,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} stream-browserify@3.0.0: @@ -8806,6 +9809,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -8833,12 +9838,12 @@ snapshots: tsup@8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: - bundle-require: 5.1.0(esbuild@0.27.2) + bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.27.2 + esbuild: 0.27.3 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -8897,6 +9902,12 @@ snapshots: type-fest@4.41.0: {} + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript@5.9.3: {} ufo@1.6.3: {} @@ -8981,6 +9992,8 @@ snapshots: unist-util-is: 6.0.1 unist-util-visit-parents: 6.0.2 + unpipe@1.0.0: {} + unstorage@1.17.4: dependencies: anymatch: 3.1.3 @@ -9002,6 +10015,8 @@ snapshots: uuid@10.0.0: {} + vary@1.1.2: {} + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -9038,13 +10053,34 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 6.4.1(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-node@3.2.4(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -9068,13 +10104,22 @@ snapshots: '@types/node': 22.19.7 fsevents: 2.3.3 - vite@5.4.21(@types/node@25.2.1): + vite@5.4.21(@types/node@25.2.2): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.56.0 optionalDependencies: - '@types/node': 25.2.1 + '@types/node': 25.2.2 + fsevents: 2.3.3 + + vite@5.4.21(@types/node@25.2.3): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.56.0 + optionalDependencies: + '@types/node': 25.2.3 fsevents: 2.3.3 vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): @@ -9092,7 +10137,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -9101,21 +10146,36 @@ snapshots: rollup: 4.56.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.2.1 + '@types/node': 25.2.2 fsevents: 2.3.3 jiti: 1.21.7 tsx: 4.21.0 yaml: 2.8.2 - vitefu@1.1.1(vite@6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): + vite@6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 optionalDependencies: - vite: 6.4.1(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + '@types/node': 25.2.3 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.21.0 + yaml: 2.8.2 + + vitefu@1.1.1(vite@6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)): + optionalDependencies: + vite: 6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.1)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -9153,11 +10213,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.1)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.2)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -9175,12 +10235,54 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 5.4.21(@types/node@25.2.1) - vite-node: 3.2.4(@types/node@25.2.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 5.4.21(@types/node@25.2.2) + vite-node: 3.2.4(@types/node@25.2.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 25.2.1 + '@types/node': 25.2.2 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.3)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@25.2.3) + vite-node: 3.2.4(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.2.3 transitivePeerDependencies: - jiti - less @@ -9230,7 +10332,7 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260205.0 '@cloudflare/workerd-windows-64': 1.20260205.0 - wrangler@4.63.0(@cloudflare/workers-types@4.20260206.0): + wrangler@4.63.0(@cloudflare/workers-types@4.20260207.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0) @@ -9241,7 +10343,7 @@ snapshots: unenv: 2.0.0-rc.24 workerd: 1.20260205.0 optionalDependencies: - '@cloudflare/workers-types': 4.20260206.0 + '@cloudflare/workers-types': 4.20260207.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil @@ -9326,6 +10428,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3 @@ -9335,4 +10441,6 @@ snapshots: zod@3.25.76: {} + zod@4.3.6: {} + zwitch@2.0.4: {} diff --git a/research/agents/amp.md b/research/agents/amp.md index ff314dd..c9bced7 100644 --- a/research/agents/amp.md +++ b/research/agents/amp.md @@ -415,6 +415,31 @@ if let Some(model) = options.model.as_deref() { 3. **Wait for Amp API** — Amp may add model/mode discovery in a future release 4. **Scrape ampcode.com** — Check if the web UI exposes available modes/models +## Command Execution & Process Management + +### Agent Tool Execution + +Amp executes commands via the `Bash` tool, similar to Claude Code. Synchronous execution, blocks the agent turn. Permission rules can pre-authorize specific commands: + +```typescript +{ tool: "Bash", matches: { command: "git *" }, action: "allow" } +``` + +### No User-Initiated Command Injection + +Amp does not expose any mechanism for external clients to inject command results into the agent's context. No `!` prefix equivalent, no command injection API. + +### Comparison + +| Capability | Supported? | Notes | +|-----------|-----------|-------| +| Agent runs commands | Yes (`Bash` tool) | Synchronous, blocks agent turn | +| User runs commands → agent sees output | No | | +| External API for command injection | No | | +| Command source tracking | No | | +| Background process management | No | Shell `&` only | +| PTY / interactive terminal | No | | + ## Notes - Amp is similar to Claude Code (same streaming format) diff --git a/research/agents/claude.md b/research/agents/claude.md index b78f278..450dd48 100644 --- a/research/agents/claude.md +++ b/research/agents/claude.md @@ -279,6 +279,44 @@ x-api-key: <ANTHROPIC_API_KEY> anthropic-version: 2023-06-01 ``` +## Command Execution & Process Management + +### Agent Tool Execution + +The agent executes commands via the `Bash` tool. This is synchronous - the agent blocks until the command exits. Tool schema: + +```json +{ + "command": "string", + "timeout": "number", + "workingDirectory": "string" +} +``` + +There is no background process support. If the agent needs a long-running process (e.g., dev server), it uses shell backgrounding (`&`) within a single `Bash` tool call. + +### User-Initiated Command Execution (`!` prefix) + +Claude Code's TUI supports `!command` syntax where the user types `!npm test` to run a command directly. The output is injected into the conversation as a user message so the agent can see it on the next turn. + +**This is a client-side TUI feature only.** It is not exposed in the API schema or streaming protocol. The CLI runs the command locally and stuffs the output into the next user message. There is no protocol-level concept of "user ran a command" vs "agent ran a command." + +### No External Command Injection API + +External clients (SDKs, frontends) cannot programmatically inject command results into Claude's conversation context. The only way to provide command output to the agent is: +- Include it in the user prompt text +- Use the `!` prefix in the interactive TUI + +### Comparison + +| Capability | Supported? | Notes | +|-----------|-----------|-------| +| Agent runs commands | Yes (`Bash` tool) | Synchronous, blocks agent turn | +| User runs commands → agent sees output | Yes (`!cmd` in TUI) | Client-side only, not in protocol | +| External API for command injection | No | | +| Background process management | No | Shell `&` only | +| PTY / interactive terminal | No | | + ## Notes - Claude CLI manages its own OAuth refresh internally diff --git a/research/agents/codex.md b/research/agents/codex.md index 8d3d970..d1e93ae 100644 --- a/research/agents/codex.md +++ b/research/agents/codex.md @@ -347,6 +347,68 @@ Requires a running Codex app-server process. Send the JSON-RPC request to the ap - Requires an active app-server process (cannot query models without starting one) - No standalone CLI command like `codex models` +## Command Execution & Process Management + +### Agent Tool Execution + +Codex executes commands via `LocalShellAction`. The agent proposes a command, and external clients approve/deny via JSON-RPC (`item/commandExecution/requestApproval`). + +### Command Source Tracking (`ExecCommandSource`) + +Codex is the only agent that explicitly tracks **who initiated a command** at the protocol level: + +```json +{ + "ExecCommandSource": { + "enum": ["agent", "user_shell", "unified_exec_startup", "unified_exec_interaction"] + } +} +``` + +| Source | Meaning | +|--------|---------| +| `agent` | Agent decided to run this command via tool call | +| `user_shell` | User ran a command in a shell (equivalent to Claude Code's `!` prefix) | +| `unified_exec_startup` | Startup script ran this command | +| `unified_exec_interaction` | Interactive execution | + +This means user-initiated shell commands are **first-class protocol events** in Codex, not a client-side hack like Claude Code's `!` prefix. + +### Command Execution Events + +Codex emits structured events for command execution: + +- `exec_command_begin` - Command started (includes `source`, `command`, `cwd`, `turn_id`) +- `exec_command_output_delta` - Streaming output chunk (includes `stream: stdout|stderr`) +- `exec_command_end` - Command completed (includes `exit_code`, `source`) + +### Parsed Command Analysis (`CommandAction`) + +Codex provides semantic analysis of what a command does: + +```json +{ + "commandActions": [ + { "type": "read", "path": "/src/main.ts" }, + { "type": "write", "path": "/src/utils.ts" }, + { "type": "install", "package": "lodash" } + ] +} +``` + +Action types: `read`, `write`, `listFiles`, `search`, `install`, `remove`, `other`. + +### Comparison + +| Capability | Supported? | Notes | +|-----------|-----------|-------| +| Agent runs commands | Yes (`LocalShellAction`) | With approval workflow | +| User runs commands → agent sees output | Yes (`user_shell` source) | First-class protocol event | +| External API for command injection | Yes (JSON-RPC approval) | Can approve/deny before execution | +| Command source tracking | Yes (`ExecCommandSource` enum) | Distinguishes agent vs user vs startup | +| Background process management | No | | +| PTY / interactive terminal | No | | + ## Notes - SDK is dynamically imported to reduce bundle size diff --git a/research/agents/opencode.md b/research/agents/opencode.md index 8708282..b698fb7 100644 --- a/research/agents/opencode.md +++ b/research/agents/opencode.md @@ -585,6 +585,60 @@ const response = await client.provider.list(); When an OpenCode server is running, call `GET /provider` on its HTTP port. Returns full model metadata including capabilities, costs, context limits, and modalities. +## Command Execution & Process Management + +### Agent Tool Execution + +The agent executes commands via internal tools (not exposed in the HTTP API). The agent's tool calls are synchronous within its turn. Tool parts have states: `pending`, `running`, `completed`, `error`. + +### PTY System (`/pty/*`) - User-Facing Terminals + +Separate from the agent's command execution. PTYs are server-scoped interactive terminals for the user: + +- `POST /pty` - Create PTY (command, args, cwd, title, env) +- `GET /pty` - List all PTYs +- `GET /pty/{ptyID}` - Get PTY info +- `PUT /pty/{ptyID}` - Update PTY (title, resize via `size: {rows, cols}`) +- `DELETE /pty/{ptyID}` - Kill and remove PTY +- `GET /pty/{ptyID}/connect` - WebSocket for bidirectional I/O + +PTY events (globally broadcast via SSE): `pty.created`, `pty.updated`, `pty.exited`, `pty.deleted`. + +The agent does NOT use the PTY system. PTYs are for the user's interactive terminal panel, independent of any AI session. + +### Session Commands (`/session/{id}/command`, `/session/{id}/shell`) - Context Injection + +External clients can inject command results into an AI session's conversation context: + +- `POST /session/{sessionID}/command` - Executes a command and records the result as an `AssistantMessage` in the session. Required fields: `command`, `arguments`. The output becomes part of the AI's context for subsequent turns. +- `POST /session/{sessionID}/shell` - Similar but wraps in `sh -c`. Required fields: `command`, `agent`. +- `GET /command` - Lists available command definitions (metadata, not execution). + +Session commands emit `command.executed` events with `sessionID` + `messageID`. + +**Key distinction**: These endpoints execute commands directly (not via the AI), then inject the output into the session as if the AI produced it. The AI doesn't actively run the command - it just finds the output in its conversation history on the next turn. + +### Three Separate Execution Mechanisms + +| Mechanism | Who uses it | Scoped to | AI sees output? | +|-----------|-------------|-----------|----------------| +| Agent tools (internal) | AI agent | Session turn | Yes (immediate) | +| PTY (`/pty/*`) | User/frontend | Server (global) | No | +| Session commands (`/session/{id}/*`) | Frontend/SDK client | Session | Yes (next turn) | + +The agent has no tool to interact with PTYs and cannot access the session command endpoints. When the agent needs to run a background process, it uses its internal bash-equivalent tool with shell backgrounding (`&`). + +### Comparison + +| Capability | Supported? | Notes | +|-----------|-----------|-------| +| Agent runs commands | Yes (internal tools) | Synchronous, blocks agent turn | +| User runs commands → agent sees output | Yes (`/session/{id}/command`) | HTTP API, first-class | +| External API for command injection | Yes | Session-scoped endpoints | +| Command source tracking | Implicit | Endpoint implies source (no enum) | +| Background process management | No | Shell `&` only for agent | +| PTY / interactive terminal | Yes (`/pty/*`) | Server-scoped, WebSocket I/O | + ## Notes - OpenCode is the most feature-rich runtime (streaming, questions, permissions) diff --git a/research/process-terminal-design.md b/research/process-terminal-design.md new file mode 100644 index 0000000..d7ba8d5 --- /dev/null +++ b/research/process-terminal-design.md @@ -0,0 +1,374 @@ +# Research: Process & Terminal System Design + +Research on PTY/terminal and process management APIs across sandbox platforms, with design recommendations for sandbox-agent. + +## Competitive Landscape + +### Transport Comparison + +| Platform | PTY Transport | Command Transport | Unified? | +|----------|--------------|-------------------|----------| +| **OpenCode** | WebSocket (`/pty/{id}/connect`) | REST (session-scoped, AI-mediated) | No | +| **E2B** | gRPC server-stream (output) + unary RPC (input) | Same gRPC service | Yes | +| **Daytona** | WebSocket | REST | No | +| **Kubernetes** | WebSocket (channel byte mux) | Same WebSocket | Yes | +| **Docker** | HTTP connection hijack | Same connection | Yes | +| **Fly.io** | SSH over WireGuard | REST (sync, 60s max) | No | +| **Vercel Sandboxes** | No PTY API | REST SDK (async generator for logs) | N/A | +| **Gitpod** | gRPC (Listen=output, Write=input) | Same gRPC service | Yes | + +### Resize Mechanism + +| Platform | How | Notes | +|----------|-----|-------| +| **OpenCode** | `PUT /pty/{id}` with `size: {rows, cols}` | Separate REST call | +| **E2B** | Separate `Update` RPC | Separate gRPC call | +| **Daytona** | Separate HTTP POST | Sends SIGWINCH | +| **Kubernetes** | In-band WebSocket message (channel byte 4) | `{"Width": N, "Height": N}` | +| **Docker** | `POST /exec/{id}/resize?h=N&w=N` | Separate REST call | +| **Gitpod** | Separate `SetSize` RPC | Separate gRPC call | + +**Consensus**: Almost all platforms use a separate call for resize. Only Kubernetes does it in-band. Since resize is a control signal (not data), a separate mechanism is cleaner. + +### I/O Multiplexing + +I/O multiplexing is how platforms distinguish between stdout, stderr, and PTY data on a shared connection. + +| Platform | Method | Detail | +|----------|--------|--------| +| **Docker** | 8-byte binary header per frame | Byte 0 = stream type (0=stdin, 1=stdout, 2=stderr). When TTY=true, no mux (raw stream). | +| **Kubernetes** | 1-byte channel prefix per WebSocket message | 0=stdin, 1=stdout, 2=stderr, 3=error, 4=resize, 255=close | +| **E2B** | gRPC `oneof` in protobuf | `DataEvent.output` is `oneof { bytes stdout, bytes stderr, bytes pty }` | +| **OpenCode** | None | PTY is a unified stream. Commands capture stdout/stderr separately in response. | +| **Daytona** | None | PTY is unified. Commands return structured `{stdout, stderr}`. | + +**Key insight**: When a process runs with a PTY allocated, stdout and stderr are merged by the kernel into a single stream. Multiplexing only matters for non-PTY command execution. OpenCode and Daytona handle this by keeping PTY (unified stream) and commands (structured response) as separate APIs. + +### Reconnection + +| Platform | Method | Replays missed output? | +|----------|--------|----------------------| +| **E2B** | `Connect` RPC by PID or tag | No - only new events from reconnect point | +| **Daytona** | New WebSocket to same PTY session | No | +| **Kubernetes** | Not supported (connection = session) | N/A | +| **Docker** | Not supported (connection = session) | N/A | +| **OpenCode** | `GET /pty/{id}/connect` (WebSocket) | Unknown (not documented) | + +### Process Identification + +| Platform | ID Type | Notes | +|----------|---------|-------| +| **OpenCode** | String (`pty_N`) | Pattern `^pty.*` | +| **E2B** | PID (uint32) or tag (string) | Dual selector | +| **Daytona** | Session ID / PID | | +| **Docker** | Exec ID (string, server-generated) | | +| **Kubernetes** | Connection-scoped | No ID - the WebSocket IS the process | +| **Gitpod** | Alias (string) | Human-readable | + +### Scoping + +| Platform | PTY Scope | Command Scope | +|----------|-----------|---------------| +| **OpenCode** | Server-wide (global) | Session-specific (AI-mediated) | +| **E2B** | Sandbox-wide | Sandbox-wide | +| **Daytona** | Sandbox-wide | Sandbox-wide | +| **Docker** | Container-scoped | Container-scoped | +| **Kubernetes** | Pod-scoped | Pod-scoped | + +## Key Questions & Analysis + +### Q: Should PTY transport be WebSocket? + +**Yes.** WebSocket is the right choice for PTY I/O: +- Bidirectional: client sends keystrokes, server sends terminal output +- Low latency: no HTTP request overhead per keystroke +- Persistent connection: terminal sessions are long-lived +- Industry consensus: OpenCode, Daytona, and Kubernetes all use WebSocket for PTY + +### Q: Should command transport be WebSocket or REST? + +**REST is sufficient for commands. WebSocket is not needed.** + +The distinction comes down to the nature of each operation: + +- **PTY**: Long-lived, bidirectional, interactive. User types, terminal responds. Needs WebSocket. +- **Commands**: Request-response. Client says "run `ls -la`", server runs it, returns stdout/stderr/exit_code. This is a natural REST operation. + +The "full duplex" question: commands don't need full duplex because: +1. Input is sent once at invocation (the command string) +2. Output is collected and returned when the process exits +3. There's no ongoing interactive input during execution + +For **streaming output** of long-running commands (e.g., `npm install`), there are two clean options: +1. **SSE**: Server-Sent Events for output streaming (output-only, which is all you need) +2. **PTY**: If the user needs to interact with the process (send ctrl+c, provide stdin), they should use a PTY instead + +This matches how OpenCode separates the two: commands are REST, PTYs are WebSocket. + +**Recommendation**: Keep commands as REST. If a command needs streaming output or interactive input, the user should create a PTY instead. This avoids building a second WebSocket protocol for a use case that PTYs already cover. + +### Q: Should resize be WebSocket in-band or separate POST? + +**Separate endpoint (PUT or POST).** + +Reasons: +- Resize is a control signal, not data. Mixing it into the data stream requires a framing protocol to distinguish resize messages from terminal input. +- OpenCode already defines `PUT /pty/{id}` with `size: {rows, cols}` - this is the existing spec. +- E2B, Daytona, Docker, and Gitpod all use separate calls. +- Only Kubernetes does in-band (because their channel-byte protocol already has a mux layer). +- A separate endpoint is simpler to implement, test, and debug. + +**Recommendation**: Use `PUT /pty/{id}` with `size` field (matching OpenCode spec). Alternatively, a dedicated `POST /pty/{id}/resize` if we want to keep update and resize semantically separate. + +### Q: What is I/O multiplexing? + +I/O multiplexing is the mechanism for distinguishing between different data streams (stdout, stderr, stdin, control signals) on a single connection. + +**When it matters**: Non-PTY command execution where stdout and stderr need to be kept separate. + +**When it doesn't matter**: PTY sessions. When a PTY is allocated, the kernel merges stdout and stderr into a single stream (the PTY master fd). There is only one output stream. This is why terminals show stdout and stderr interleaved - the PTY doesn't distinguish them. + +**For sandbox-agent**: Since PTYs are unified streams and commands use REST (separate stdout/stderr in the JSON response), we don't need a multiplexing protocol. The API design naturally separates the two cases. + +### Q: How should reconnect work? + +**Reconnect is an application-level concept, not just HTTP/WebSocket reconnection.** + +The distinction: + +- **HTTP/WebSocket reconnect**: The transport-level connection drops and is re-established. This is handled by the client library automatically (retry logic, exponential backoff). The server doesn't need to know. +- **Process reconnect**: The client disconnects from a running process but the process keeps running. Later, the client (or a different client) connects to the same process and starts receiving output again. + +**E2B's model**: Disconnecting a stream (via AbortController) leaves the process running. `Connect` RPC by PID or tag re-establishes the output stream. Missed output during disconnection is lost. This works because: +1. Processes are long-lived (servers, shells) +2. For terminals, the screen state can be recovered by the shell/application redrawing +3. For commands, if you care about all output, don't disconnect + +**Recommendation for sandbox-agent**: Reconnect should be supported at the application level: +1. `GET /pty/{id}/connect` (WebSocket) can be called multiple times for the same PTY +2. If the WebSocket drops, the PTY process keeps running +3. Client reconnects by opening a new WebSocket to the same endpoint +4. No output replay (too complex, rarely needed - terminal apps redraw on reconnect via SIGWINCH) +5. This is essentially what OpenCode's `/pty/{id}/connect` endpoint already implies + +This naturally leads to the **persistent process system** concept (see below). + +### Q: How are PTY events different from PTY transport? + +Two completely separate channels serving different purposes: + +**PTY Events** (via SSE on `/event` or `/sessions/{id}/events/sse`): +- Lifecycle notifications: `pty.created`, `pty.updated`, `pty.exited`, `pty.deleted` +- Lightweight JSON metadata (PTY id, status, exit code) +- Broadcast to all subscribers +- Used by UIs to update PTY lists, show status indicators, handle cleanup + +**PTY Transport** (via WebSocket on `/pty/{id}/connect`): +- Raw terminal I/O: binary input/output bytes +- High-frequency, high-bandwidth +- Point-to-point (one client connected to one PTY) +- Used by terminal emulators (xterm.js) to render the terminal + +**Analogy**: Events are like email notifications ("a new terminal was opened"). Transport is like the phone call (the actual terminal session). + +### Q: How are PTY and commands different in OpenCode? + +They serve fundamentally different purposes: + +**PTY (`/pty/*`)** - Direct execution environment: +- Server-scoped (not tied to any AI session) +- Creates a real terminal process +- User interacts directly via WebSocket +- Not part of the AI conversation +- Think: "the terminal panel in VS Code" + +**Commands (`/session/{sessionID}/command`, `/session/{sessionID}/shell`)** - AI-mediated execution: +- Session-scoped (tied to an AI session) +- The command is sent **to the AI assistant** for execution +- Creates an `AssistantMessage` in the session's conversation history +- Output becomes part of the AI's context +- Think: "asking Claude to run a command as a tool call" + +**Why commands are session-specific**: Because they're AI operations, not direct execution. When you call `POST /session/{id}/command`, the server: +1. Creates an assistant message in the session +2. Runs the command +3. Captures output as message parts +4. Emits `message.part.updated` events +5. The AI can see this output in subsequent turns + +This is how the AI "uses terminal tools" - the command infrastructure provides the bridge between the AI session and system execution. + +### Q: Should scoping be system-wide? + +**Yes, for both PTY and commands.** + +Current OpenCode behavior: +- PTYs: Already server-wide (global) +- Commands: Session-scoped (for AI context injection) + +**For sandbox-agent**, since we're the orchestration layer (not the AI): +- **PTYs**: System-wide. Any client should be able to list, connect to, or manage any PTY. +- **Commands/processes**: System-wide. Process execution is a system primitive, not an AI primitive. If a caller wants to associate a process with a session, they can do so at their layer. + +The session-scoping of commands in OpenCode is an OpenCode-specific concern (AI context injection). Sandbox-agent should provide the lower-level primitive (system-wide process execution) and let the OpenCode compat layer handle the session association. + +## Persistent Process System + +### The Concept + +A persistent process system means: +1. **Spawn** a process (PTY or command) via API +2. Process runs independently of any client connection +3. **Connect/disconnect** to the process I/O at will +4. Process continues running through disconnections +5. **Query** process status, list running processes +6. **Kill/signal** processes explicitly + +This is distinct from the typical "connection = process lifetime" model (Kubernetes, Docker exec) where closing the connection kills the process. + +### How E2B Does It + +E2B's `Process` service is the best reference implementation: + +``` +Start(cmd, pty?) → stream of events (output) +Connect(pid/tag) → stream of events (reconnect) +SendInput(pid, data) → ok +Update(pid, size) → ok (resize) +SendSignal(pid, signal) → ok +List() → running processes +``` + +Key design choices: +- **Unified service**: PTY and command are the same service, differentiated by the `pty` field in `StartRequest` +- **Process outlives connection**: Disconnecting the output stream (aborting the `Start`/`Connect` RPC) does NOT kill the process +- **Explicit termination**: Must call `SendSignal(SIGKILL)` to stop a process +- **Tag-based selection**: Processes can be tagged at creation for later lookup without knowing the PID + +### Recommendation for Sandbox-Agent + +Sandbox-agent should implement a **persistent process manager** that: + +1. **Is system-wide** (not session-scoped) +2. **Supports both PTY and non-PTY modes** +3. **Decouples process lifetime from connection lifetime** +4. **Exposes via both REST (lifecycle) and WebSocket (I/O)** + +#### Proposed API Surface + +**Process Lifecycle (REST)**: +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/v1/processes` | Create/spawn a process (PTY or command) | +| `GET` | `/v1/processes` | List all processes | +| `GET` | `/v1/processes/{id}` | Get process info (status, pid, exit code) | +| `DELETE` | `/v1/processes/{id}` | Kill process (SIGTERM, then SIGKILL) | +| `POST` | `/v1/processes/{id}/signal` | Send signal (SIGTERM, SIGKILL, SIGINT, etc.) | +| `POST` | `/v1/processes/{id}/resize` | Resize PTY (rows, cols) | +| `POST` | `/v1/processes/{id}/input` | Send stdin/pty input (REST fallback) | + +**Process I/O (WebSocket)**: +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/v1/processes/{id}/connect` | WebSocket for bidirectional I/O | + +**Process Events (SSE)**: +| Event | Description | +|-------|-------------| +| `process.created` | Process spawned | +| `process.updated` | Process metadata changed | +| `process.exited` | Process terminated (includes exit code) | +| `process.deleted` | Process record removed | + +#### Create Request + +```json +{ + "command": "bash", + "args": ["-i", "-l"], + "cwd": "/workspace", + "env": {"TERM": "xterm-256color"}, + "pty": { // Optional - if present, allocate PTY + "rows": 24, + "cols": 80 + }, + "tag": "main-terminal", // Optional - for lookup by name + "label": "Terminal 1" // Optional - display name +} +``` + +#### Process Object + +```json +{ + "id": "proc_abc123", + "tag": "main-terminal", + "label": "Terminal 1", + "command": "bash", + "args": ["-i", "-l"], + "cwd": "/workspace", + "pid": 12345, + "pty": true, + "status": "running", // "running" | "exited" + "exit_code": null, // Set when exited + "created_at": "2025-01-15T...", + "exited_at": null +} +``` + +#### OpenCode Compatibility Layer + +The OpenCode compat layer maps to this system: + +| OpenCode Endpoint | Maps To | +|-------------------|---------| +| `POST /pty` | `POST /v1/processes` (with `pty` field) | +| `GET /pty` | `GET /v1/processes?pty=true` | +| `GET /pty/{id}` | `GET /v1/processes/{id}` | +| `PUT /pty/{id}` | `POST /v1/processes/{id}/resize` + metadata update | +| `DELETE /pty/{id}` | `DELETE /v1/processes/{id}` | +| `GET /pty/{id}/connect` | `GET /v1/processes/{id}/connect` | +| `POST /session/{id}/command` | Create process + capture output into session | +| `POST /session/{id}/shell` | Create process (shell mode) + capture output into session | + +### Open Questions + +1. **Output buffering for reconnect**: Should we buffer recent output (e.g., last 64KB) so reconnecting clients get some history? E2B doesn't do this, but it would improve UX for flaky connections. + +2. **Process limits**: Should there be a max number of concurrent processes? E2B doesn't expose one, but sandbox environments have limited resources. + +3. **Auto-cleanup**: Should processes be auto-cleaned after exiting? Options: + - Keep forever until explicitly deleted + - Auto-delete after N seconds/minutes + - Keep metadata but release resources + +4. **Input via REST vs WebSocket-only**: The REST `POST /processes/{id}/input` endpoint is useful for one-shot input (e.g., "send ctrl+c") without establishing a WebSocket. E2B has both `SendInput` (unary) and `StreamInput` (streaming) for this reason. + +5. **Multiple WebSocket connections to same process**: Should we allow multiple clients to connect to the same process simultaneously? (Pair programming, monitoring). E2B supports this via multiple `Connect` calls. + +## User-Initiated Command Injection ("Run command, give AI context") + +A common pattern across agents: the user (or frontend) runs a command and the output is injected into the AI's conversation context. This is distinct from the agent running a command via its own tools. + +| Agent | Feature | Mechanism | Protocol-level? | +|-------|---------|-----------|----------------| +| **Claude Code** | `!command` prefix in TUI | CLI runs command locally, injects output as user message | No - client-side hack, not in API schema | +| **Codex** | `user_shell` source | `ExecCommandSource` enum distinguishes `agent` vs `user_shell` vs `unified_exec_*` | Yes - first-class protocol event | +| **OpenCode** | `/session/{id}/command` | HTTP endpoint runs command, records result as `AssistantMessage` | Yes - HTTP API | +| **Amp** | N/A | Not supported | N/A | + +**Design implication for sandbox-agent**: The process system should support an optional `session_id` field when creating a process. If provided, the process output is associated with that session so the agent can see it. If not provided, the process runs independently (like a PTY). This unifies: +- User interactive terminals (no session association) +- User-initiated commands for AI context (session association) +- Agent-initiated background processes (session association) + +## Sources + +- [E2B Process Proto](https://github.com/e2b-dev/E2B) - `process.proto` gRPC service definition +- [E2B JS SDK](https://github.com/e2b-dev/E2B/tree/main/packages/js-sdk) - `commands/pty.ts`, `commands/index.ts` +- [Daytona SDK](https://www.daytona.io/docs/en/typescript-sdk/process/) - REST + WebSocket PTY API +- [Kubernetes RemoteCommand](https://github.com/kubernetes/apimachinery/blob/master/pkg/util/remotecommand/constants.go) - WebSocket subprotocol +- [Docker Engine API](https://docker-docs.uclv.cu/engine/api/v1.21/) - Exec API with stream multiplexing +- [Fly.io Machines API](https://fly.io/docs/machines/api/) - REST exec with 60s limit +- [Gitpod terminal.proto](https://codeberg.org/kanishka-reading-list/gitpod/src/branch/main/components/supervisor-api/terminal.proto) - gRPC terminal service +- [OpenCode OpenAPI Spec](https://github.com/opencode-ai/opencode) - PTY and session command endpoints diff --git a/research/wip-agent-support.md b/research/wip-agent-support.md new file mode 100644 index 0000000..d6347b6 --- /dev/null +++ b/research/wip-agent-support.md @@ -0,0 +1,442 @@ +# Universal Agent Configuration Support + +Work-in-progress research on configuration features across agents and what can be made universal. + +--- + +## TODO: Features Needed for Full Coverage + +### Currently Implemented (in `CreateSessionRequest`) + +- [x] `agent` - Agent selection (claude, codex, opencode, amp) +- [x] `agentMode` - Agent mode (plan, build, default) +- [x] `permissionMode` - Permission mode (default, plan, bypass) +- [x] `model` - Model selection +- [x] `variant` - Reasoning variant +- [x] `agentVersion` - Agent version selection +- [x] `mcp` - MCP server configuration (Claude/Codex/OpenCode/Amp) +- [x] `skills` - Skill path configuration (link or copy into agent skill roots) + +### Tier 1: Universal Features (High Priority) + +- [ ] `projectInstructions` - Inject CLAUDE.md / AGENTS.md content + - Write to appropriate file before agent spawn + - All agents support this natively +- [ ] `workingDirectory` - Set working directory for session + - Currently captures server `cwd` on session creation; not yet user-configurable +- [x] `mcp` - MCP server configuration + - Claude: Writes `.mcp.json` entries under `mcpServers` + - Codex: Updates `.codex/config.toml` with `mcp_servers` + - Amp: Calls `amp mcp add` for each server + - OpenCode: Uses `/mcp` API +- [x] `skills` - Skill path configuration + - Claude: Link to `./.claude/skills/<name>/` + - Codex: Link to `./.agents/skills/<name>/` + - OpenCode: Link to `./.opencode/skill/<name>/` + config `skills.paths` + - Amp: Link to Claude/Codex-style directories +- [ ] `credentials` - Pass credentials via API (not just env vars) + - Currently extracted from host env + - Need API-level credential injection + +### Filesystem API (Implemented) + +- [x] `/v1/fs` - Read/write/list/move/delete/stat files and upload batches + - Batch upload is tar-only (`application/x-tar`) with path output capped at 1024 + - Relative paths resolve from session working dir when `sessionId` is provided + - CLI `sandbox-agent api fs ...` covers all filesystem endpoints + +### Message Attachments (Implemented) + +- [x] `MessageRequest.attachments` - Attach uploaded files when sending prompts + - OpenCode receives file parts; other agents get attachment paths appended to the prompt + +### Tier 2: Partial Support (Medium Priority) + +- [ ] `appendSystemPrompt` - High-priority system prompt additions + - Claude: `--append-system-prompt` flag + - Codex: `developer_instructions` config + - OpenCode: Custom agent definition + - Amp: Not supported (fallback to projectInstructions) +- [ ] `resumeSession` / native session resume + - Claude: `--resume SESSION_ID` + - Codex: Thread persistence (automatic) + - OpenCode: `-c/--continue` + - Amp: `--continue SESSION_ID` + +### Tier 3: Agent-Specific Pass-through (Low Priority) + +- [ ] `agentSpecific.claude` - Raw Claude options +- [ ] `agentSpecific.codex` - Raw Codex options (e.g., `replaceSystemPrompt`) +- [ ] `agentSpecific.opencode` - Raw OpenCode options (e.g., `customAgent`) +- [ ] `agentSpecific.amp` - Raw Amp options (e.g., `permissionRules`) + +### Event/Feature Coverage Gaps (from compatibility matrix) + +| Feature | Claude | Codex | OpenCode | Amp | Status | +|---------|--------|-------|----------|-----|--------| +| Tool Calls | —* | ✓ | ✓ | ✓ | Claude coming soon | +| Tool Results | —* | ✓ | ✓ | ✓ | Claude coming soon | +| Questions (HITL) | —* | — | ✓ | — | Only OpenCode | +| Permissions (HITL) | —* | — | ✓ | — | Only OpenCode | +| Images | — | ✓ | ✓ | — | 2/4 agents | +| File Attachments | — | ✓ | ✓ | — | 2/4 agents | +| Session Lifecycle | — | ✓ | ✓ | — | 2/4 agents | +| Reasoning/Thinking | — | ✓ | — | — | Codex only | +| Command Execution | — | ✓ | — | — | Codex only | +| File Changes | — | ✓ | — | — | Codex only | +| MCP Tools | ✓ | ✓ | ✓ | ✓ | Supported via session MCP config injection | +| Streaming Deltas | — | ✓ | ✓ | — | 2/4 agents | + +\* Claude features marked as "coming imminently" + +### Implementation Order (Suggested) + +1. **mcp** - Done (session config injection + agent config writers) +2. **skills** - Done (session config injection + skill directory linking) +3. **projectInstructions** - Highest value, all agents support +4. **appendSystemPrompt** - High-priority instructions +5. **workingDirectory** - Basic session configuration +6. **resumeSession** - Session continuity +7. **credentials** - API-level auth injection +8. **agentSpecific** - Escape hatch for edge cases + +--- + +## Legend + +- ✅ Native support +- 🔄 Can be adapted/emulated +- ❌ Not supported +- ⚠️ Supported with caveats + +--- + +## 1. Instructions & System Prompt + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Project instructions file** | ✅ `CLAUDE.md` | ✅ `AGENTS.md` | 🔄 Config-based | ⚠️ Limited | ✅ Yes - write to agent's file | +| **Append to system prompt** | ✅ `--append-system-prompt` | ✅ `developer_instructions` | 🔄 Custom agent | ❌ | ⚠️ Partial - 3/4 agents | +| **Replace system prompt** | ❌ | ✅ `model_instructions_file` | 🔄 Custom agent | ❌ | ❌ No - Codex only | +| **Hierarchical discovery** | ✅ cwd → root | ✅ root → cwd | ❌ | ❌ | ❌ No - Claude/Codex only | + +### Priority Comparison + +| Agent | Priority Order (highest → lowest) | +|-------|-----------------------------------| +| Claude | `--append-system-prompt` > base prompt > `CLAUDE.md` | +| Codex | `AGENTS.md` > `developer_instructions` > base prompt | +| OpenCode | Custom agent prompt > base prompt | +| Amp | Server-controlled (opaque) | + +### Key Differences + +**Claude**: System prompt additions have highest priority. `CLAUDE.md` is injected as first user message (below system prompt). + +**Codex**: Project instructions (`AGENTS.md`) have highest priority and can override system prompt. This is the inverse of Claude's model. + +--- + +## 2. Permission Modes + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Read-only** | ✅ `plan` | ✅ `read-only` | 🔄 Rulesets | 🔄 Rules | ✅ Yes | +| **Write workspace** | ✅ `acceptEdits` | ✅ `workspace-write` | 🔄 Rulesets | 🔄 Rules | ✅ Yes | +| **Full bypass** | ✅ `--dangerously-skip-permissions` | ✅ `danger-full-access` | 🔄 Allow-all ruleset | ✅ `--dangerously-skip-permissions` | ✅ Yes | +| **Per-tool rules** | ❌ | ❌ | ✅ | ✅ | ❌ No - OpenCode/Amp only | + +### Universal Mapping + +```typescript +type PermissionMode = "readonly" | "write" | "bypass"; + +// Maps to: +// Claude: plan | acceptEdits | --dangerously-skip-permissions +// Codex: read-only | workspace-write | danger-full-access +// OpenCode: restrictive ruleset | permissive ruleset | allow-all +// Amp: reject rules | allow rules | dangerouslyAllowAll +``` + +--- + +## 3. Agent Modes + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Plan mode** | ✅ `--permission-mode plan` | 🔄 Prompt prefix | ✅ `--agent plan` | 🔄 Mode selection | ✅ Yes | +| **Build/execute mode** | ✅ Default | ✅ Default | ✅ `--agent build` | ✅ Default | ✅ Yes | +| **Chat mode** | ❌ | 🔄 Prompt prefix | ❌ | ❌ | ❌ No - Codex only | +| **Custom agents** | ❌ | ❌ | ✅ Config-defined | ❌ | ❌ No - OpenCode only | + +--- + +## 4. Model & Variant Selection + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Model selection** | ✅ `--model` | ✅ `-m/--model` | ✅ `-m provider/model` | ⚠️ `--mode` (abstracted) | ⚠️ Partial | +| **Model discovery API** | ✅ Anthropic API | ✅ `model/list` RPC | ✅ `GET /provider` | ❌ Server-side | ⚠️ Partial - 3/4 | +| **Reasoning variants** | ❌ | ✅ `model_reasoning_effort` | ✅ `--variant` | ✅ Deep mode levels | ⚠️ Partial | + +--- + +## 5. MCP & Tools + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **MCP servers** | ✅ `mcpServers` in settings | ✅ `mcp_servers` in config | ✅ `/mcp` API | ✅ `--toolbox` | ✅ Yes - inject config | +| **Tool restrictions** | ❌ | ❌ | ✅ Per-tool permissions | ✅ Permission rules | ⚠️ Partial | + +### MCP Config Mapping + +| Agent | Local Server | Remote Server | +|-------|--------------|---------------| +| Claude | `.mcp.json` or `.claude/settings.json` → `mcpServers` | Same, with `url` | +| Codex | `.codex/config.toml` → `mcp_servers` | Same schema | +| OpenCode | `/mcp` API with `McpLocalConfig` | `McpRemoteConfig` with `url`, `headers` | +| Amp | `amp mcp add` CLI | Supports remote with headers | + +Local MCP servers can be bundled (for example with `tsup`) and uploaded via the filesystem API, then referenced in the session `mcp` config to auto-start and serve custom tools. + +--- + +## 6. Skills & Extensions + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Skills/plugins** | ✅ `.claude/skills/` | ✅ `.agents/skills/` | ✅ `.opencode/skill/` | 🔄 Claude-style | ✅ Yes - link dirs | +| **Slash commands** | ✅ `.claude/commands/` | ✅ Custom prompts (deprecated) | ❌ | ❌ | ⚠️ Partial | + +### Skill Path Mapping + +| Agent | Project Skills | User Skills | +|-------|----------------|-------------| +| Claude | `.claude/skills/<name>/SKILL.md` | `~/.claude/skills/<name>/SKILL.md` | +| Codex | `.agents/skills/` | `~/.agents/skills/` | +| OpenCode | `.opencode/skill/`, `.claude/skills/`, `.agents/skills/` | `~/.config/opencode/skill/` | +| Amp | Uses Claude/Codex directories | — | + +--- + +## 7. Session Management + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Resume session** | ✅ `--resume` | ✅ Thread persistence | ✅ `-c/--continue` | ✅ `--continue` | ✅ Yes | +| **Session ID** | ✅ `session_id` | ✅ `thread_id` | ✅ `sessionID` | ✅ `session_id` | ✅ Yes | + +--- + +## 8. Human-in-the-Loop + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **Permission requests** | ✅ Events | ⚠️ Upfront only | ✅ SSE events | ❌ Pre-configured | ⚠️ Partial | +| **Questions** | ⚠️ Limited in headless | ❌ | ✅ Full support | ❌ | ❌ No - OpenCode best | + +--- + +## 9. Credentials + +| Feature | Claude | Codex | OpenCode | Amp | Universal? | +|---------|--------|-------|----------|-----|------------| +| **API key env var** | ✅ `ANTHROPIC_API_KEY` | ✅ `OPENAI_API_KEY` | ✅ Both | ✅ `ANTHROPIC_API_KEY` | ✅ Yes | +| **OAuth tokens** | ✅ | ✅ | ✅ | ✅ | ✅ Yes | +| **Config file auth** | ✅ `~/.claude.json` | ✅ `~/.codex/auth.json` | ✅ `~/.local/share/opencode/auth.json` | ✅ `~/.amp/config.json` | ✅ Yes - extract per agent | + +--- + +## Configuration Files Per Agent + +### Claude Code + +| File/Location | Purpose | +|---------------|---------| +| `CLAUDE.md` | Project instructions (hierarchical, cwd → root) | +| `~/.claude/CLAUDE.md` | Global user instructions | +| `~/.claude/settings.json` | User settings (permissions, MCP servers, env) | +| `.claude/settings.json` | Project-level settings | +| `.claude/settings.local.json` | Local overrides (gitignored) | +| `~/.claude/commands/` | Custom slash commands (user-level) | +| `.claude/commands/` | Project-level slash commands | +| `~/.claude/skills/` | Installed skills | +| `~/.claude/keybindings.json` | Custom keyboard shortcuts | +| `~/.claude/projects/<hash>/memory/MEMORY.md` | Auto-memory per project | +| `~/.claude.json` | Authentication/credentials | +| `~/.claude.json.api` | API key storage | + +### OpenAI Codex + +| File/Location | Purpose | +|---------------|---------| +| `AGENTS.md` | Project instructions (hierarchical, root → cwd) | +| `AGENTS.override.md` | Override file (takes precedence) | +| `~/.codex/AGENTS.md` | Global user instructions | +| `~/.codex/AGENTS.override.md` | Global override | +| `~/.codex/config.toml` | User configuration | +| `.codex/config.toml` | Project-level configuration | +| `~/.codex/auth.json` | Authentication/credentials | + +Key config.toml options: +- `model` - Default model +- `developer_instructions` - Appended to system prompt +- `model_instructions_file` - Replace entire system prompt +- `project_doc_max_bytes` - Max AGENTS.md size (default 32KB) +- `project_doc_fallback_filenames` - Alternative instruction files +- `mcp_servers` - MCP server configuration + +### OpenCode + +| File/Location | Purpose | +|---------------|---------| +| `~/.local/share/opencode/auth.json` | Authentication | +| `~/.config/opencode/config.toml` | User configuration | +| `.opencode/config.toml` | Project configuration | + +### Amp + +| File/Location | Purpose | +|---------------|---------| +| `~/.amp/config.json` | Main configuration | +| `~/.config/amp/settings.json` | Additional settings | +| `.amp/rules.json` | Project permission rules | + +--- + +## Summary: Universalization Tiers + +### Tier 1: Fully Universal (implement now) + +| Feature | API | Notes | +|---------|-----|-------| +| Project instructions | `projectInstructions: string` | Write to CLAUDE.md / AGENTS.md | +| Permission mode | `permissionMode: "readonly" \| "write" \| "bypass"` | Map to agent-specific flags | +| Agent mode | `agentMode: "plan" \| "build"` | Map to agent-specific mechanisms | +| Model selection | `model: string` | Pass through to agent | +| Resume session | `sessionId: string` | Map to agent's resume flag | +| Credentials | `credentials: { apiKey?, oauthToken? }` | Inject via env vars | +| MCP servers | `mcp: McpConfig` | Write to agent's config (docs drafted) | +| Skills | `skills: { paths: string[] }` | Link to agent's skill dirs (docs drafted) | + +### Tier 2: Partial Support (with fallbacks) + +| Feature | API | Notes | +|---------|-----|-------| +| Append system prompt | `appendSystemPrompt: string` | Falls back to projectInstructions for Amp | +| Reasoning variant | `variant: string` | Ignored for Claude | + +### Tier 3: Agent-Specific (pass-through) + +| Feature | Notes | +|---------|-------| +| Replace system prompt | Codex only (`model_instructions_file`) | +| Per-tool permissions | OpenCode/Amp only | +| Custom agents | OpenCode only | +| Hierarchical file discovery | Let agents handle natively | + +--- + +## Recommended Universal API + +```typescript +interface UniversalSessionConfig { + // Tier 1 - Universal + agent: "claude" | "codex" | "opencode" | "amp"; + model?: string; + permissionMode?: "readonly" | "write" | "bypass"; + agentMode?: "plan" | "build"; + projectInstructions?: string; + sessionId?: string; // For resume + workingDirectory?: string; + credentials?: { + apiKey?: string; + oauthToken?: string; + }; + + // MCP servers (docs drafted in docs/mcp.mdx) + mcp?: Record<string, McpServerConfig>; + + // Skills (docs drafted in docs/skills.mdx) + skills?: { + paths: string[]; + }; + + // Tier 2 - Partial (with fallbacks) + appendSystemPrompt?: string; + variant?: string; + + // Tier 3 - Pass-through + agentSpecific?: { + claude?: { /* raw Claude options */ }; + codex?: { replaceSystemPrompt?: string; /* etc */ }; + opencode?: { customAgent?: AgentDef; /* etc */ }; + amp?: { permissionRules?: Rule[]; /* etc */ }; + }; +} + +interface McpServerConfig { + type: "local" | "remote"; + // Local + command?: string; + args?: string[]; + env?: Record<string, string>; + timeoutMs?: number; + // Remote + url?: string; + headers?: Record<string, string>; +} +``` + +--- + +## Implementation Notes + +### Priority Inversion Warning + +Claude and Codex have inverted priority for project instructions vs system prompt: + +- **Claude**: `--append-system-prompt` > base prompt > `CLAUDE.md` +- **Codex**: `AGENTS.md` > `developer_instructions` > base prompt + +This means: +- In Claude, system prompt additions override project files +- In Codex, project files override system prompt additions + +When using both `appendSystemPrompt` and `projectInstructions`, document this behavior clearly or consider normalizing by only using one mechanism. + +### File Injection Strategy + +For `projectInstructions`, sandbox-agent should: + +1. Create a temp directory or use session working directory +2. Write instructions to the appropriate file: + - Claude: `.claude/CLAUDE.md` or `CLAUDE.md` in cwd + - Codex: `.codex/AGENTS.md` or `AGENTS.md` in cwd + - OpenCode: Config file or environment + - Amp: Limited - may only influence via context +3. Start agent in that directory +4. Agent discovers and loads instructions automatically + +### MCP Server Injection + +For `mcp`, sandbox-agent should: + +1. Write MCP config to agent's settings file: + - Claude: `.mcp.json` or `.claude/settings.json` → `mcpServers` key + - Codex: `.codex/config.toml` → `mcp_servers` + - OpenCode: Call `/mcp` API + - Amp: Run `amp mcp add` or pass via `--toolbox` +2. Ensure MCP server binaries are available in PATH +3. Handle cleanup on session end + +### Skill Linking + +For `skills.paths`, sandbox-agent should: + +1. For each skill path, symlink or copy to agent's skill directory: + - Claude: `.claude/skills/<name>/` + - Codex: `.agents/skills/<name>/` + - OpenCode: Update `skills.paths` in config +2. Skill directory must contain `SKILL.md` +3. Handle cleanup on session end diff --git a/resources/agent-schemas/artifacts/json-schema/amp.json b/resources/agent-schemas/artifacts/json-schema/amp.json index 78f0e84..97c5b16 100644 --- a/resources/agent-schemas/artifacts/json-schema/amp.json +++ b/resources/agent-schemas/artifacts/json-schema/amp.json @@ -9,6 +9,10 @@ "type": { "type": "string", "enum": [ + "system", + "user", + "assistant", + "result", "message", "tool_call", "tool_result", @@ -27,6 +31,45 @@ }, "error": { "type": "string" + }, + "subtype": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "session_id": { + "type": "string" + }, + "tools": { + "type": "array", + "items": { + "type": "string" + } + }, + "mcp_servers": { + "type": "array", + "items": { + "type": "object" + } + }, + "message": { + "type": "object" + }, + "parent_tool_use_id": { + "type": "string" + }, + "duration_ms": { + "type": "number" + }, + "is_error": { + "type": "boolean" + }, + "num_turns": { + "type": "number" + }, + "result": { + "type": "string" } }, "required": [ diff --git a/resources/agent-schemas/src/amp.ts b/resources/agent-schemas/src/amp.ts index cee5e78..f70fc59 100644 --- a/resources/agent-schemas/src/amp.ts +++ b/resources/agent-schemas/src/amp.ts @@ -204,12 +204,27 @@ function createFallbackSchema(): NormalizedSchema { properties: { type: { type: "string", - enum: ["message", "tool_call", "tool_result", "error", "done"], + enum: ["system", "user", "assistant", "result", "message", "tool_call", "tool_result", "error", "done"], }, + // Common fields id: { type: "string" }, content: { type: "string" }, tool_call: { $ref: "#/definitions/ToolCall" }, error: { type: "string" }, + // System message fields + subtype: { type: "string" }, + cwd: { type: "string" }, + session_id: { type: "string" }, + tools: { type: "array", items: { type: "string" } }, + mcp_servers: { type: "array", items: { type: "object" } }, + // User/Assistant message fields + message: { type: "object" }, + parent_tool_use_id: { type: "string" }, + // Result fields + duration_ms: { type: "number" }, + is_error: { type: "boolean" }, + num_turns: { type: "number" }, + result: { type: "string" }, }, required: ["type"], }, diff --git a/sdks/cli-shared/package.json b/sdks/cli-shared/package.json index d342db9..59638fc 100644 --- a/sdks/cli-shared/package.json +++ b/sdks/cli-shared/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-shared", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "Shared helpers for sandbox-agent CLI and SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/package.json b/sdks/cli/package.json index c617a76..255d76c 100644 --- a/sdks/cli/package.json +++ b/sdks/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "CLI for sandbox-agent - run AI coding agents in sandboxes", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/darwin-arm64/package.json b/sdks/cli/platforms/darwin-arm64/package.json index 9c07b51..554f5f3 100644 --- a/sdks/cli/platforms/darwin-arm64/package.json +++ b/sdks/cli/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-darwin-arm64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "sandbox-agent CLI binary for macOS ARM64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/darwin-x64/package.json b/sdks/cli/platforms/darwin-x64/package.json index dafe8e9..45b50b5 100644 --- a/sdks/cli/platforms/darwin-x64/package.json +++ b/sdks/cli/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-darwin-x64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "sandbox-agent CLI binary for macOS x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/linux-arm64/package.json b/sdks/cli/platforms/linux-arm64/package.json index 58cad6a..8061b9c 100644 --- a/sdks/cli/platforms/linux-arm64/package.json +++ b/sdks/cli/platforms/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-linux-arm64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "sandbox-agent CLI binary for Linux arm64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/linux-x64/package.json b/sdks/cli/platforms/linux-x64/package.json index 1f6c35b..e99876a 100644 --- a/sdks/cli/platforms/linux-x64/package.json +++ b/sdks/cli/platforms/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-linux-x64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "sandbox-agent CLI binary for Linux x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli/platforms/win32-x64/package.json b/sdks/cli/platforms/win32-x64/package.json index 726e4aa..b15488e 100644 --- a/sdks/cli/platforms/win32-x64/package.json +++ b/sdks/cli/platforms/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli-win32-x64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "sandbox-agent CLI binary for Windows x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/package.json b/sdks/gigacode/package.json index 9f4b0a7..af98a27 100644 --- a/sdks/gigacode/package.json +++ b/sdks/gigacode/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/darwin-arm64/package.json b/sdks/gigacode/platforms/darwin-arm64/package.json index 49ec4e9..f385ca1 100644 --- a/sdks/gigacode/platforms/darwin-arm64/package.json +++ b/sdks/gigacode/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-darwin-arm64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "gigacode CLI binary for macOS arm64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/darwin-x64/package.json b/sdks/gigacode/platforms/darwin-x64/package.json index 95104af..7c24a1c 100644 --- a/sdks/gigacode/platforms/darwin-x64/package.json +++ b/sdks/gigacode/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-darwin-x64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "gigacode CLI binary for macOS x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/linux-arm64/package.json b/sdks/gigacode/platforms/linux-arm64/package.json index 29d9acb..23454cc 100644 --- a/sdks/gigacode/platforms/linux-arm64/package.json +++ b/sdks/gigacode/platforms/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-linux-arm64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "gigacode CLI binary for Linux arm64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/linux-x64/package.json b/sdks/gigacode/platforms/linux-x64/package.json index b3b3298..ae645af 100644 --- a/sdks/gigacode/platforms/linux-x64/package.json +++ b/sdks/gigacode/platforms/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-linux-x64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "gigacode CLI binary for Linux x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/platforms/win32-x64/package.json b/sdks/gigacode/platforms/win32-x64/package.json index cec1c0c..f138d1d 100644 --- a/sdks/gigacode/platforms/win32-x64/package.json +++ b/sdks/gigacode/platforms/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/gigacode-win32-x64", - "version": "0.1.7", + "version": "0.1.12-rc.1", "description": "gigacode CLI binary for Windows x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index fe9845c..ca81140 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,7 +1,7 @@ { "name": "sandbox-agent", - "version": "0.1.7", - "description": "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp.", + "version": "0.1.12-rc.1", + "description": "Universal API for automatic coding agents in sandboxes. Supports Claude Code, Codex, OpenCode, and Amp.", "license": "Apache-2.0", "repository": { "type": "git", @@ -39,6 +39,6 @@ "vitest": "^3.0.0" }, "optionalDependencies": { - "@sandbox-agent/cli": "0.1.0" + "@sandbox-agent/cli": "workspace:*" } } diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index f290406..0d60b4a 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -8,6 +8,18 @@ import type { CreateSessionResponse, EventsQuery, EventsResponse, + FsActionResponse, + FsDeleteQuery, + FsEntriesQuery, + FsEntry, + FsMoveRequest, + FsMoveResponse, + FsPathQuery, + FsSessionQuery, + FsStat, + FsUploadBatchQuery, + FsUploadBatchResponse, + FsWriteResponse, HealthResponse, MessageRequest, PermissionReplyRequest, @@ -52,6 +64,8 @@ type QueryValue = string | number | boolean | null | undefined; type RequestOptions = { query?: Record<string, QueryValue>; body?: unknown; + rawBody?: BodyInit; + contentType?: string; headers?: HeadersInit; accept?: string; signal?: AbortSignal; @@ -216,6 +230,57 @@ export class SandboxAgent { await this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}/terminate`); } + async listFsEntries(query?: FsEntriesQuery): Promise<FsEntry[]> { + return this.requestJson("GET", `${API_PREFIX}/fs/entries`, { query }); + } + + async readFsFile(query: FsPathQuery): Promise<Uint8Array> { + const response = await this.requestRaw("GET", `${API_PREFIX}/fs/file`, { + query, + accept: "application/octet-stream", + }); + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + + async writeFsFile(query: FsPathQuery, body: BodyInit): Promise<FsWriteResponse> { + const response = await this.requestRaw("PUT", `${API_PREFIX}/fs/file`, { + query, + rawBody: body, + contentType: "application/octet-stream", + accept: "application/json", + }); + const text = await response.text(); + return text ? (JSON.parse(text) as FsWriteResponse) : { path: "", bytesWritten: 0 }; + } + + async deleteFsEntry(query: FsDeleteQuery): Promise<FsActionResponse> { + return this.requestJson("DELETE", `${API_PREFIX}/fs/entry`, { query }); + } + + async mkdirFs(query: FsPathQuery): Promise<FsActionResponse> { + return this.requestJson("POST", `${API_PREFIX}/fs/mkdir`, { query }); + } + + async moveFs(request: FsMoveRequest, query?: FsSessionQuery): Promise<FsMoveResponse> { + return this.requestJson("POST", `${API_PREFIX}/fs/move`, { query, body: request }); + } + + async statFs(query: FsPathQuery): Promise<FsStat> { + return this.requestJson("GET", `${API_PREFIX}/fs/stat`, { query }); + } + + async uploadFsBatch(body: BodyInit, query?: FsUploadBatchQuery): Promise<FsUploadBatchResponse> { + const response = await this.requestRaw("POST", `${API_PREFIX}/fs/upload-batch`, { + query, + rawBody: body, + contentType: "application/x-tar", + accept: "application/json", + }); + const text = await response.text(); + return text ? (JSON.parse(text) as FsUploadBatchResponse) : { paths: [], truncated: false }; + } + async dispose(): Promise<void> { if (this.spawnHandle) { await this.spawnHandle.dispose(); @@ -256,7 +321,15 @@ export class SandboxAgent { } const init: RequestInit = { method, headers, signal: options.signal }; - if (options.body !== undefined) { + if (options.rawBody !== undefined && options.body !== undefined) { + throw new Error("requestRaw received both rawBody and body"); + } + if (options.rawBody !== undefined) { + if (options.contentType) { + headers.set("Content-Type", options.contentType); + } + init.body = options.rawBody; + } else if (options.body !== undefined) { headers.set("Content-Type", "application/json"); init.body = JSON.stringify(options.body); } diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 1e3239e..ec9b076 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -6,48 +6,162 @@ export interface paths { "/v1/agents": { + /** + * List Agents + * @description Returns all available coding agents and their installation status. + */ get: operations["list_agents"]; }; "/v1/agents/{agent}/install": { + /** + * Install Agent + * @description Installs or updates a coding agent (e.g. claude, codex, opencode, amp). + */ post: operations["install_agent"]; }; "/v1/agents/{agent}/models": { + /** + * List Agent Models + * @description Returns the available LLM models for an agent. + */ get: operations["get_agent_models"]; }; "/v1/agents/{agent}/modes": { + /** + * List Agent Modes + * @description Returns the available interaction modes for an agent. + */ get: operations["get_agent_modes"]; }; + "/v1/fs/entries": { + /** + * List Directory + * @description Lists files and directories at the given path. + */ + get: operations["fs_entries"]; + }; + "/v1/fs/entry": { + /** + * Delete Entry + * @description Deletes a file or directory. + */ + delete: operations["fs_delete_entry"]; + }; + "/v1/fs/file": { + /** + * Read File + * @description Reads the raw bytes of a file. + */ + get: operations["fs_read_file"]; + /** + * Write File + * @description Writes raw bytes to a file, creating it if it doesn't exist. + */ + put: operations["fs_write_file"]; + }; + "/v1/fs/mkdir": { + /** + * Create Directory + * @description Creates a directory, including any missing parent directories. + */ + post: operations["fs_mkdir"]; + }; + "/v1/fs/move": { + /** + * Move Entry + * @description Moves or renames a file or directory. + */ + post: operations["fs_move"]; + }; + "/v1/fs/stat": { + /** + * Get File Info + * @description Returns metadata (size, timestamps, type) for a path. + */ + get: operations["fs_stat"]; + }; + "/v1/fs/upload-batch": { + /** + * Upload Files + * @description Uploads a tar.gz archive and extracts it to the destination directory. + */ + post: operations["fs_upload_batch"]; + }; "/v1/health": { + /** + * Health Check + * @description Returns the server health status. + */ get: operations["get_health"]; }; "/v1/sessions": { + /** + * List Sessions + * @description Returns all active sessions. + */ get: operations["list_sessions"]; }; "/v1/sessions/{session_id}": { + /** + * Create Session + * @description Creates a new agent session with the given configuration. + */ post: operations["create_session"]; }; "/v1/sessions/{session_id}/events": { + /** + * Get Events + * @description Returns session events with optional offset-based pagination. + */ get: operations["get_events"]; }; "/v1/sessions/{session_id}/events/sse": { + /** + * Subscribe to Events (SSE) + * @description Opens an SSE stream for real-time session events. + */ get: operations["get_events_sse"]; }; "/v1/sessions/{session_id}/messages": { + /** + * Send Message + * @description Sends a message to a session and returns immediately. + */ post: operations["post_message"]; }; "/v1/sessions/{session_id}/messages/stream": { + /** + * Send Message (Streaming) + * @description Sends a message and returns an SSE event stream of the agent's response. + */ post: operations["post_message_stream"]; }; "/v1/sessions/{session_id}/permissions/{permission_id}/reply": { + /** + * Reply to Permission + * @description Approves or denies a permission request from the agent. + */ post: operations["reply_permission"]; }; "/v1/sessions/{session_id}/questions/{question_id}/reject": { + /** + * Reject Question + * @description Rejects a human-in-the-loop question from the agent. + */ post: operations["reject_question"]; }; "/v1/sessions/{session_id}/questions/{question_id}/reply": { + /** + * Reply to Question + * @description Replies to a human-in-the-loop question from the agent. + */ post: operations["reply_question"]; }; "/v1/sessions/{session_id}/terminate": { + /** + * Terminate Session + * @description Terminates a running session and cleans up resources. + */ post: operations["terminate_session"]; }; } @@ -76,7 +190,6 @@ export interface components { textMessages: boolean; toolCalls: boolean; toolResults: boolean; - variants: boolean; }; AgentError: { agent?: string | null; @@ -87,6 +200,8 @@ export interface components { }; AgentInfo: { capabilities: components["schemas"]["AgentCapabilities"]; + /** @description Whether the agent's required provider credentials are available */ + credentialsAvailable: boolean; id: string; installed: boolean; path?: string | null; @@ -167,8 +282,14 @@ export interface components { agent: string; agentMode?: string | null; agentVersion?: string | null; + directory?: string | null; + mcp?: { + [key: string]: components["schemas"]["McpServerConfig"]; + } | null; model?: string | null; permissionMode?: string | null; + skills?: components["schemas"]["SkillsConfig"] | null; + title?: string | null; variant?: string | null; }; CreateSessionResponse: { @@ -198,6 +319,64 @@ export interface components { }; /** @enum {string} */ FileAction: "read" | "write" | "patch"; + FsActionResponse: { + path: string; + }; + FsDeleteQuery: { + path: string; + recursive?: boolean | null; + sessionId?: string | null; + }; + FsEntriesQuery: { + path?: string | null; + sessionId?: string | null; + }; + FsEntry: { + entryType: components["schemas"]["FsEntryType"]; + modified?: string | null; + name: string; + path: string; + /** Format: int64 */ + size: number; + }; + /** @enum {string} */ + FsEntryType: "file" | "directory"; + FsMoveRequest: { + from: string; + overwrite?: boolean | null; + to: string; + }; + FsMoveResponse: { + from: string; + to: string; + }; + FsPathQuery: { + path: string; + sessionId?: string | null; + }; + FsSessionQuery: { + sessionId?: string | null; + }; + FsStat: { + entryType: components["schemas"]["FsEntryType"]; + modified?: string | null; + path: string; + /** Format: int64 */ + size: number; + }; + FsUploadBatchQuery: { + path?: string | null; + sessionId?: string | null; + }; + FsUploadBatchResponse: { + paths: string[]; + truncated: boolean; + }; + FsWriteResponse: { + /** Format: int64 */ + bytesWritten: number; + path: string; + }; HealthResponse: { status: string; }; @@ -215,7 +394,51 @@ export interface components { ItemRole: "user" | "assistant" | "system" | "tool"; /** @enum {string} */ ItemStatus: "in_progress" | "completed" | "failed"; + McpCommand: string | string[]; + McpOAuthConfig: { + clientId?: string | null; + clientSecret?: string | null; + scope?: string | null; + }; + McpOAuthConfigOrDisabled: components["schemas"]["McpOAuthConfig"] | boolean; + /** @enum {string} */ + McpRemoteTransport: "http" | "sse"; + McpServerConfig: ({ + args?: string[]; + command: components["schemas"]["McpCommand"]; + cwd?: string | null; + enabled?: boolean | null; + env?: { + [key: string]: string; + } | null; + /** Format: int64 */ + timeoutMs?: number | null; + /** @enum {string} */ + type: "local"; + }) | ({ + bearerTokenEnvVar?: string | null; + enabled?: boolean | null; + envHeaders?: { + [key: string]: string; + } | null; + headers?: { + [key: string]: string; + } | null; + oauth?: components["schemas"]["McpOAuthConfigOrDisabled"] | null; + /** Format: int64 */ + timeoutMs?: number | null; + transport?: components["schemas"]["McpRemoteTransport"] | null; + /** @enum {string} */ + type: "remote"; + url: string; + }); + MessageAttachment: { + filename?: string | null; + mime?: string | null; + path: string; + }; MessageRequest: { + attachments?: components["schemas"]["MessageAttachment"][]; message: string; }; PermissionEventData: { @@ -230,7 +453,7 @@ export interface components { reply: components["schemas"]["PermissionReply"]; }; /** @enum {string} */ - PermissionStatus: "requested" | "approved" | "denied"; + PermissionStatus: "requested" | "accept" | "accept_for_session" | "reject"; ProblemDetails: { detail?: string | null; instance?: string | null; @@ -285,13 +508,23 @@ export interface components { SessionInfo: { agent: string; agentMode: string; + /** Format: int64 */ + createdAt: number; + directory?: string | null; ended: boolean; /** Format: int64 */ eventCount: number; + mcp?: { + [key: string]: components["schemas"]["McpServerConfig"]; + } | null; model?: string | null; nativeSessionId?: string | null; permissionMode: string; sessionId: string; + skills?: components["schemas"]["SkillsConfig"] | null; + title?: string | null; + /** Format: int64 */ + updatedAt: number; variant?: string | null; }; SessionListResponse: { @@ -300,6 +533,16 @@ export interface components { SessionStartedData: { metadata?: unknown; }; + SkillSource: { + ref?: string | null; + skills?: string[] | null; + source: string; + subpath?: string | null; + type: string; + }; + SkillsConfig: { + sources: components["schemas"]["SkillSource"][]; + }; StderrOutput: { /** @description First N lines of stderr (if truncated) or full stderr (if not truncated) */ head?: string | null; @@ -312,6 +555,13 @@ export interface components { }; /** @enum {string} */ TerminatedBy: "agent" | "daemon"; + TurnEventData: { + metadata?: unknown; + phase: components["schemas"]["TurnPhase"]; + turn_id?: string | null; + }; + /** @enum {string} */ + TurnPhase: "started" | "ended"; TurnStreamQuery: { includeRaw?: boolean | null; }; @@ -328,9 +578,9 @@ export interface components { time: string; type: components["schemas"]["UniversalEventType"]; }; - UniversalEventData: components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"]; + UniversalEventData: components["schemas"]["TurnEventData"] | components["schemas"]["SessionStartedData"] | components["schemas"]["SessionEndedData"] | components["schemas"]["ItemEventData"] | components["schemas"]["ItemDeltaData"] | components["schemas"]["ErrorData"] | components["schemas"]["PermissionEventData"] | components["schemas"]["QuestionEventData"] | components["schemas"]["AgentUnparsedData"]; /** @enum {string} */ - UniversalEventType: "session.started" | "session.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed"; + UniversalEventType: "session.started" | "session.ended" | "turn.started" | "turn.ended" | "item.started" | "item.delta" | "item.completed" | "error" | "permission.requested" | "permission.resolved" | "question.requested" | "question.resolved" | "agent.unparsed"; UniversalItem: { content: components["schemas"]["ContentPart"][]; item_id: string; @@ -354,8 +604,13 @@ export type external = Record<string, never>; export interface operations { + /** + * List Agents + * @description Returns all available coding agents and their installation status. + */ list_agents: { responses: { + /** @description List of available agents */ 200: { content: { "application/json": components["schemas"]["AgentListResponse"]; @@ -363,6 +618,10 @@ export interface operations { }; }; }; + /** + * Install Agent + * @description Installs or updates a coding agent (e.g. claude, codex, opencode, amp). + */ install_agent: { parameters: { path: { @@ -380,16 +639,19 @@ export interface operations { 204: { content: never; }; + /** @description Invalid request */ 400: { content: { "application/json": components["schemas"]["ProblemDetails"]; }; }; + /** @description Agent not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; }; }; + /** @description Installation failed */ 500: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -397,6 +659,10 @@ export interface operations { }; }; }; + /** + * List Agent Models + * @description Returns the available LLM models for an agent. + */ get_agent_models: { parameters: { path: { @@ -405,18 +671,24 @@ export interface operations { }; }; responses: { + /** @description Available models */ 200: { content: { "application/json": components["schemas"]["AgentModelsResponse"]; }; }; - 400: { + /** @description Agent not found */ + 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; }; }; }; }; + /** + * List Agent Modes + * @description Returns the available interaction modes for an agent. + */ get_agent_modes: { parameters: { path: { @@ -425,11 +697,13 @@ export interface operations { }; }; responses: { + /** @description Available modes */ 200: { content: { "application/json": components["schemas"]["AgentModesResponse"]; }; }; + /** @description Invalid request */ 400: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -437,8 +711,204 @@ export interface operations { }; }; }; + /** + * List Directory + * @description Lists files and directories at the given path. + */ + fs_entries: { + parameters: { + query?: { + /** @description Path to list (relative or absolute) */ + path?: string | null; + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + responses: { + /** @description Directory listing */ + 200: { + content: { + "application/json": components["schemas"]["FsEntry"][]; + }; + }; + }; + }; + /** + * Delete Entry + * @description Deletes a file or directory. + */ + fs_delete_entry: { + parameters: { + query: { + /** @description File or directory path */ + path: string; + /** @description Session id for relative paths */ + session_id?: string | null; + /** @description Delete directories recursively */ + recursive?: boolean | null; + }; + }; + responses: { + /** @description Delete result */ + 200: { + content: { + "application/json": components["schemas"]["FsActionResponse"]; + }; + }; + }; + }; + /** + * Read File + * @description Reads the raw bytes of a file. + */ + fs_read_file: { + parameters: { + query: { + /** @description File path (relative or absolute) */ + path: string; + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + responses: { + /** @description File content */ + 200: { + content: { + "application/octet-stream": string; + }; + }; + }; + }; + /** + * Write File + * @description Writes raw bytes to a file, creating it if it doesn't exist. + */ + fs_write_file: { + parameters: { + query: { + /** @description File path (relative or absolute) */ + path: string; + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + requestBody: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description Write result */ + 200: { + content: { + "application/json": components["schemas"]["FsWriteResponse"]; + }; + }; + }; + }; + /** + * Create Directory + * @description Creates a directory, including any missing parent directories. + */ + fs_mkdir: { + parameters: { + query: { + /** @description Directory path to create */ + path: string; + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + responses: { + /** @description Directory created */ + 200: { + content: { + "application/json": components["schemas"]["FsActionResponse"]; + }; + }; + }; + }; + /** + * Move Entry + * @description Moves or renames a file or directory. + */ + fs_move: { + parameters: { + query?: { + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["FsMoveRequest"]; + }; + }; + responses: { + /** @description Move result */ + 200: { + content: { + "application/json": components["schemas"]["FsMoveResponse"]; + }; + }; + }; + }; + /** + * Get File Info + * @description Returns metadata (size, timestamps, type) for a path. + */ + fs_stat: { + parameters: { + query: { + /** @description Path to stat */ + path: string; + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + responses: { + /** @description File metadata */ + 200: { + content: { + "application/json": components["schemas"]["FsStat"]; + }; + }; + }; + }; + /** + * Upload Files + * @description Uploads a tar.gz archive and extracts it to the destination directory. + */ + fs_upload_batch: { + parameters: { + query?: { + /** @description Destination directory for extraction */ + path?: string | null; + /** @description Session id for relative paths */ + session_id?: string | null; + }; + }; + requestBody: { + content: { + "application/octet-stream": string; + }; + }; + responses: { + /** @description Upload result */ + 200: { + content: { + "application/json": components["schemas"]["FsUploadBatchResponse"]; + }; + }; + }; + }; + /** + * Health Check + * @description Returns the server health status. + */ get_health: { responses: { + /** @description Server is healthy */ 200: { content: { "application/json": components["schemas"]["HealthResponse"]; @@ -446,8 +916,13 @@ export interface operations { }; }; }; + /** + * List Sessions + * @description Returns all active sessions. + */ list_sessions: { responses: { + /** @description List of active sessions */ 200: { content: { "application/json": components["schemas"]["SessionListResponse"]; @@ -455,6 +930,10 @@ export interface operations { }; }; }; + /** + * Create Session + * @description Creates a new agent session with the given configuration. + */ create_session: { parameters: { path: { @@ -468,16 +947,19 @@ export interface operations { }; }; responses: { + /** @description Session created */ 200: { content: { "application/json": components["schemas"]["CreateSessionResponse"]; }; }; + /** @description Invalid request */ 400: { content: { "application/json": components["schemas"]["ProblemDetails"]; }; }; + /** @description Session already exists */ 409: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -485,6 +967,10 @@ export interface operations { }; }; }; + /** + * Get Events + * @description Returns session events with optional offset-based pagination. + */ get_events: { parameters: { query?: { @@ -501,11 +987,13 @@ export interface operations { }; }; responses: { + /** @description Session events */ 200: { content: { "application/json": components["schemas"]["EventsResponse"]; }; }; + /** @description Session not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -513,6 +1001,10 @@ export interface operations { }; }; }; + /** + * Subscribe to Events (SSE) + * @description Opens an SSE stream for real-time session events. + */ get_events_sse: { parameters: { query?: { @@ -533,6 +1025,10 @@ export interface operations { }; }; }; + /** + * Send Message + * @description Sends a message to a session and returns immediately. + */ post_message: { parameters: { path: { @@ -550,6 +1046,7 @@ export interface operations { 204: { content: never; }; + /** @description Session not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -557,6 +1054,10 @@ export interface operations { }; }; }; + /** + * Send Message (Streaming) + * @description Sends a message and returns an SSE event stream of the agent's response. + */ post_message_stream: { parameters: { query?: { @@ -578,6 +1079,7 @@ export interface operations { 200: { content: never; }; + /** @description Session not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -585,6 +1087,10 @@ export interface operations { }; }; }; + /** + * Reply to Permission + * @description Approves or denies a permission request from the agent. + */ reply_permission: { parameters: { path: { @@ -604,6 +1110,7 @@ export interface operations { 204: { content: never; }; + /** @description Session or permission not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -611,6 +1118,10 @@ export interface operations { }; }; }; + /** + * Reject Question + * @description Rejects a human-in-the-loop question from the agent. + */ reject_question: { parameters: { path: { @@ -625,6 +1136,7 @@ export interface operations { 204: { content: never; }; + /** @description Session or question not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -632,6 +1144,10 @@ export interface operations { }; }; }; + /** + * Reply to Question + * @description Replies to a human-in-the-loop question from the agent. + */ reply_question: { parameters: { path: { @@ -651,6 +1167,7 @@ export interface operations { 204: { content: never; }; + /** @description Session or question not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; @@ -658,6 +1175,10 @@ export interface operations { }; }; }; + /** + * Terminate Session + * @description Terminates a running session and cleans up resources. + */ terminate_session: { parameters: { path: { @@ -670,6 +1191,7 @@ export interface operations { 204: { content: never; }; + /** @description Session not found */ 404: { content: { "application/json": components["schemas"]["ProblemDetails"]; diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index 1d5d349..1c1b65c 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -23,12 +23,26 @@ export type { EventsQuery, EventsResponse, FileAction, + FsActionResponse, + FsDeleteQuery, + FsEntriesQuery, + FsEntry, + FsEntryType, + FsMoveRequest, + FsMoveResponse, + FsPathQuery, + FsSessionQuery, + FsStat, + FsUploadBatchQuery, + FsUploadBatchResponse, + FsWriteResponse, HealthResponse, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, + MessageAttachment, MessageRequest, PermissionEventData, PermissionReply, @@ -50,6 +64,13 @@ export type { UniversalEventData, UniversalEventType, UniversalItem, + McpServerConfig, + McpCommand, + McpRemoteTransport, + McpOAuthConfig, + McpOAuthConfigOrDisabled, + SkillSource, + SkillsConfig, } from "./types.ts"; export type { components, paths } from "./generated/openapi.ts"; export type { SandboxAgentSpawnOptions, SandboxAgentSpawnLogMode } from "./spawn.ts"; diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index 350df6b..65b12b9 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -19,6 +19,19 @@ export type EventSource = S["EventSource"]; export type EventsQuery = S["EventsQuery"]; export type EventsResponse = S["EventsResponse"]; export type FileAction = S["FileAction"]; +export type FsActionResponse = S["FsActionResponse"]; +export type FsDeleteQuery = S["FsDeleteQuery"]; +export type FsEntriesQuery = S["FsEntriesQuery"]; +export type FsEntry = S["FsEntry"]; +export type FsEntryType = S["FsEntryType"]; +export type FsMoveRequest = S["FsMoveRequest"]; +export type FsMoveResponse = S["FsMoveResponse"]; +export type FsPathQuery = S["FsPathQuery"]; +export type FsSessionQuery = S["FsSessionQuery"]; +export type FsStat = S["FsStat"]; +export type FsUploadBatchQuery = S["FsUploadBatchQuery"]; +export type FsUploadBatchResponse = S["FsUploadBatchResponse"]; +export type FsWriteResponse = S["FsWriteResponse"]; export type HealthResponse = S["HealthResponse"]; export type ItemDeltaData = S["ItemDeltaData"]; export type ItemEventData = S["ItemEventData"]; @@ -26,6 +39,7 @@ export type ItemKind = S["ItemKind"]; export type ItemRole = S["ItemRole"]; export type ItemStatus = S["ItemStatus"]; export type MessageRequest = S["MessageRequest"]; +export type MessageAttachment = S["MessageAttachment"]; export type PermissionEventData = S["PermissionEventData"]; export type PermissionReply = S["PermissionReply"]; export type PermissionReplyRequest = S["PermissionReplyRequest"]; @@ -46,3 +60,11 @@ export type UniversalEvent = S["UniversalEvent"]; export type UniversalEventData = S["UniversalEventData"]; export type UniversalEventType = S["UniversalEventType"]; export type UniversalItem = S["UniversalItem"]; + +export type McpServerConfig = S["McpServerConfig"]; +export type McpCommand = S["McpCommand"]; +export type McpRemoteTransport = S["McpRemoteTransport"]; +export type McpOAuthConfig = S["McpOAuthConfig"]; +export type McpOAuthConfigOrDisabled = S["McpOAuthConfigOrDisabled"]; +export type SkillSource = S["SkillSource"]; +export type SkillsConfig = S["SkillsConfig"]; diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 1275eb2..cf220b8 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -2,6 +2,10 @@ See [ARCHITECTURE.md](./ARCHITECTURE.md) for detailed architecture documentation covering the daemon, agent schema pipeline, session management, agent execution patterns, and SDK modes. +## Skill Source Installation + +Skills are installed via `skills.sources` in the session create request. The [vercel-labs/skills](https://github.com/vercel-labs/skills) repo (`~/misc/skills`) provides reference for skill installation patterns and source parsing logic. The server handles fetching GitHub repos (via zip download) and git repos (via clone) to `~/.sandbox-agent/skills-cache/`, discovering `SKILL.md` files, and symlinking into agent skill roots. + # Server Testing ## Test placement diff --git a/server/packages/agent-credentials/src/lib.rs b/server/packages/agent-credentials/src/lib.rs index b456a2b..b2c2225 100644 --- a/server/packages/agent-credentials/src/lib.rs +++ b/server/packages/agent-credentials/src/lib.rs @@ -63,7 +63,9 @@ pub fn extract_claude_credentials( ]; for path in config_paths { - let data = read_json_file(&path)?; + let Some(data) = read_json_file(&path) else { + continue; + }; for key_path in &key_paths { if let Some(key) = read_string_field(&data, key_path) { if key.starts_with("sk-ant-") { diff --git a/server/packages/agent-management/src/agents.rs b/server/packages/agent-management/src/agents.rs index 8d5afa5..cc7be62 100644 --- a/server/packages/agent-management/src/agents.rs +++ b/server/packages/agent-management/src/agents.rs @@ -21,6 +21,7 @@ pub enum AgentId { Opencode, Amp, Pi, + Cursor, Mock, } @@ -32,54 +33,20 @@ impl AgentId { AgentId::Opencode => "opencode", AgentId::Amp => "amp", AgentId::Pi => "pi", + AgentId::Cursor => "cursor", AgentId::Mock => "mock", } } pub fn binary_name(self) -> &'static str { match self { - AgentId::Claude => { - if cfg!(windows) { - "claude.exe" - } else { - "claude" - } - } - AgentId::Codex => { - if cfg!(windows) { - "codex.exe" - } else { - "codex" - } - } - AgentId::Opencode => { - if cfg!(windows) { - "opencode.exe" - } else { - "opencode" - } - } - AgentId::Amp => { - if cfg!(windows) { - "amp.exe" - } else { - "amp" - } - } - AgentId::Pi => { - if cfg!(windows) { - "pi.exe" - } else { - "pi" - } - } - AgentId::Mock => { - if cfg!(windows) { - "mock.exe" - } else { - "mock" - } - } + AgentId::Claude => "claude", + AgentId::Codex => "codex", + AgentId::Opencode => "opencode", + AgentId::Amp => "amp", + AgentId::Pi => "pi", + AgentId::Cursor => "cursor-agent", + AgentId::Mock => "mock", } } @@ -90,6 +57,7 @@ impl AgentId { "opencode" => Some(AgentId::Opencode), "amp" => Some(AgentId::Amp), "pi" => Some(AgentId::Pi), + "cursor" => Some(AgentId::Cursor), "mock" => Some(AgentId::Mock), _ => None, } @@ -191,6 +159,7 @@ impl AgentManager { } AgentId::Amp => install_amp(&install_path, self.platform, options.version.as_deref())?, AgentId::Pi => install_pi(&install_path, self.platform, options.version.as_deref())?, + AgentId::Cursor => install_cursor(&install_path, self.platform, options.version.as_deref())?, AgentId::Mock => { if !install_path.exists() { fs::write(&install_path, b"mock")?; @@ -208,9 +177,7 @@ impl AgentManager { if agent == AgentId::Mock { return true; } - self.binary_path(agent).exists() - || find_in_path(agent.binary_name()).is_some() - || default_install_dir().join(agent.binary_name()).exists() + self.binary_path(agent).exists() || find_in_path(agent.binary_name()).is_some() } pub fn binary_path(&self, agent: AgentId) -> PathBuf { @@ -305,6 +272,21 @@ impl AgentManager { if let Some(variant) = options.variant.as_deref() { command.arg("--variant").arg(variant); } + if options.permission_mode.as_deref() == Some("bypass") { + command.arg("--dangerously-skip-permissions"); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("-s").arg(session_id); + } + command.arg(&options.prompt); + } + AgentId::Cursor => { + // cursor-agent typically runs as HTTP server on localhost:32123 + // For CLI usage similar to opencode + command.arg("run").arg("--format").arg("json"); + if let Some(model) = options.model.as_deref() { + command.arg("-m").arg(model); + } if let Some(session_id) = options.session_id.as_deref() { command.arg("-s").arg(session_id); } @@ -386,6 +368,12 @@ impl AgentManager { options.streaming_input = true; } let mut command = self.build_command(agent, &options)?; + + // Pass environment variables to the agent process (e.g., ANTHROPIC_API_KEY) + for (key, value) in &options.env { + command.env(key, value); + } + if matches!(agent, AgentId::Codex | AgentId::Claude) { command.stdin(Stdio::piped()); } @@ -687,6 +675,9 @@ impl AgentManager { if let Some(variant) = options.variant.as_deref() { command.arg("--variant").arg(variant); } + if options.permission_mode.as_deref() == Some("bypass") { + command.arg("--dangerously-skip-permissions"); + } if let Some(session_id) = options.session_id.as_deref() { command.arg("-s").arg(session_id); } @@ -695,6 +686,16 @@ impl AgentManager { AgentId::Amp => { return Ok(build_amp_command(&path, &working_dir, options)); } + AgentId::Cursor => { + command.arg("run").arg("--format").arg("json"); + if let Some(model) = options.model.as_deref() { + command.arg("-m").arg(model); + } + if let Some(session_id) = options.session_id.as_deref() { + command.arg("-s").arg(session_id); + } + command.arg(&options.prompt); + } AgentId::Pi => { unreachable!("Pi is handled by router RPC runtime"); } @@ -718,10 +719,6 @@ impl AgentManager { if let Some(path) = find_in_path(agent.binary_name()) { return Ok(path); } - let fallback = default_install_dir().join(agent.binary_name()); - if fallback.exists() { - return Ok(fallback); - } Err(AgentError::BinaryNotFound { agent }) } } @@ -832,7 +829,13 @@ fn parse_version_output(output: &std::process::Output) -> Option<String> { .lines() .map(str::trim) .find(|line| !line.is_empty()) - .map(|line| line.to_string()) + .map(|line| { + // Strip trailing metadata like " (released ...)" from version strings + match line.find(" (") { + Some(pos) => line[..pos].to_string(), + None => line.to_string(), + } + }) } fn parse_jsonl(text: &str) -> Vec<Value> { @@ -1042,6 +1045,14 @@ fn extract_session_id(agent: AgentId, events: &[Value]) -> Option<String> { return Some(id); } } + AgentId::Cursor => { + if let Some(id) = event.get("session_id").and_then(Value::as_str) { + return Some(id.to_string()); + } + if let Some(id) = event.get("sessionId").and_then(Value::as_str) { + return Some(id.to_string()); + } + } AgentId::Mock => {} } } @@ -1125,6 +1136,7 @@ fn extract_result_text(agent: AgentId, events: &[Value]) -> Option<String> { } } AgentId::Pi => extract_pi_result_text(events), + AgentId::Cursor => None, AgentId::Mock => None, } } @@ -1251,26 +1263,21 @@ fn spawn_amp( let mut args: Vec<&str> = Vec::new(); if flags.execute { args.push("--execute"); - } else if flags.print { - args.push("--print"); + args.push(&options.prompt); } if flags.output_format { - args.push("--output-format"); - args.push("stream-json"); + args.push("--stream-json"); } if flags.dangerously_skip_permissions && options.permission_mode.as_deref() == Some("bypass") { - args.push("--dangerously-skip-permissions"); + args.push("--dangerously-allow-all"); } let mut command = Command::new(path); command.current_dir(working_dir); - if let Some(model) = options.model.as_deref() { - command.arg("--model").arg(model); - } if let Some(session_id) = options.session_id.as_deref() { command.arg("--continue").arg(session_id); } - command.args(&args).arg(&options.prompt); + command.args(&args); for (key, value) in &options.env { command.env(key, value); } @@ -1294,24 +1301,19 @@ fn build_amp_command(path: &Path, working_dir: &Path, options: &SpawnOptions) -> let flags = detect_amp_flags(path, working_dir).unwrap_or_default(); let mut command = Command::new(path); command.current_dir(working_dir); - if let Some(model) = options.model.as_deref() { - command.arg("--model").arg(model); - } if let Some(session_id) = options.session_id.as_deref() { command.arg("--continue").arg(session_id); } if flags.execute { command.arg("--execute"); - } else if flags.print { - command.arg("--print"); + command.arg(&options.prompt); } if flags.output_format { - command.arg("--output-format").arg("stream-json"); + command.arg("--stream-json"); } if flags.dangerously_skip_permissions && options.permission_mode.as_deref() == Some("bypass") { - command.arg("--dangerously-skip-permissions"); + command.arg("--dangerously-allow-all"); } - command.arg(&options.prompt); for (key, value) in &options.env { command.env(key, value); } @@ -1321,7 +1323,6 @@ fn build_amp_command(path: &Path, working_dir: &Path, options: &SpawnOptions) -> #[derive(Debug, Default, Clone, Copy)] struct AmpFlags { execute: bool, - print: bool, output_format: bool, dangerously_skip_permissions: bool, } @@ -1339,9 +1340,8 @@ fn detect_amp_flags(path: &Path, working_dir: &Path) -> Option<AmpFlags> { ); Some(AmpFlags { execute: text.contains("--execute"), - print: text.contains("--print"), - output_format: text.contains("--output-format"), - dangerously_skip_permissions: text.contains("--dangerously-skip-permissions"), + output_format: text.contains("--stream-json"), + dangerously_skip_permissions: text.contains("--dangerously-allow-all"), }) } @@ -1350,23 +1350,19 @@ fn spawn_amp_fallback( working_dir: &Path, options: &SpawnOptions, ) -> Result<std::process::Output, AgentError> { - let mut attempts = vec![ + let mut attempts: Vec<Vec<&str>> = vec![ vec!["--execute"], - vec!["--print", "--output-format", "stream-json"], - vec!["--output-format", "stream-json"], - vec!["--dangerously-skip-permissions"], + vec!["stream-json"], + vec!["--dangerously-allow-all"], vec![], ]; if options.permission_mode.as_deref() != Some("bypass") { - attempts.retain(|args| !args.contains(&"--dangerously-skip-permissions")); + attempts.retain(|args| !args.contains(&"--dangerously-allow-all")); } for args in attempts { let mut command = Command::new(path); command.current_dir(working_dir); - if let Some(model) = options.model.as_deref() { - command.arg("--model").arg(model); - } if let Some(session_id) = options.session_id.as_deref() { command.arg("--continue").arg(session_id); } @@ -1385,9 +1381,6 @@ fn spawn_amp_fallback( let mut command = Command::new(path); command.current_dir(working_dir); - if let Some(model) = options.model.as_deref() { - command.arg("--model").arg(model); - } if let Some(session_id) = options.session_id.as_deref() { command.arg("--continue").arg(session_id); } @@ -1409,10 +1402,28 @@ fn find_in_path(binary_name: &str) -> Option<PathBuf> { None } -fn default_install_dir() -> PathBuf { - dirs::data_dir() - .map(|dir| dir.join("sandbox-agent").join("bin")) - .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("bin")) +fn install_cursor(path: &Path, platform: Platform, _version: Option<&str>) -> Result<(), AgentError> { + // Note: cursor-agent binary URL needs to be verified + // Cursor Pro includes cursor-agent, typically installed via: curl -fsS https://cursor.com/install | bash + // For sandbox-agent, we need standalone cursor-agent binary + // TODO: Determine correct download URL for cursor-agent releases + + let platform_segment = match platform { + Platform::LinuxX64 | Platform::LinuxX64Musl => "linux-x64", + Platform::LinuxArm64 => "linux-arm64", + Platform::MacosArm64 => "darwin-arm64", + Platform::MacosX64 => "darwin-x64", + }; + + // Placeholder URL - needs to be updated with actual cursor-agent release URL + let url = Url::parse(&format!( + "https://cursor.com/api/v1/releases/latest/download/cursor-agent-{platform_segment}", + platform_segment = platform_segment + ))?; + + let bytes = download_bytes(&url)?; + write_executable(path, &bytes)?; + Ok(()) } fn download_bytes(url: &Url) -> Result<Vec<u8>, AgentError> { diff --git a/server/packages/agent-management/src/testing.rs b/server/packages/agent-management/src/testing.rs index 34d872d..e790ca7 100644 --- a/server/packages/agent-management/src/testing.rs +++ b/server/packages/agent-management/src/testing.rs @@ -159,6 +159,7 @@ pub fn test_agents_from_env() -> Result<Vec<TestAgentConfig>, TestAgentConfigErr } credentials_with(anthropic_cred.clone(), openai_cred.clone()) } + AgentId::Cursor => credentials_with(None, None), AgentId::Mock => credentials_with(None, None), }; configs.push(TestAgentConfig { agent, credentials }); diff --git a/server/packages/extracted-agent-schemas/tests/schema_roundtrip.rs b/server/packages/extracted-agent-schemas/tests/schema_roundtrip.rs index db7003b..8b9148a 100644 --- a/server/packages/extracted-agent-schemas/tests/schema_roundtrip.rs +++ b/server/packages/extracted-agent-schemas/tests/schema_roundtrip.rs @@ -73,3 +73,32 @@ fn test_amp_message() { assert!(json.contains("user")); assert!(json.contains("Hello")); } + +#[test] +fn test_amp_stream_json_message_types() { + // Test that all new message types can be parsed + let system_msg = r#"{"type":"system","subtype":"init","cwd":"/tmp","session_id":"sess-1","tools":["Bash"],"mcp_servers":[]}"#; + let parsed: amp::StreamJsonMessage = serde_json::from_str(system_msg).unwrap(); + assert!(matches!(parsed.type_, amp::StreamJsonMessageType::System)); + + let user_msg = r#"{"type":"user","message":{"role":"user","content":"Hello"},"session_id":"sess-1"}"#; + let parsed: amp::StreamJsonMessage = serde_json::from_str(user_msg).unwrap(); + assert!(matches!(parsed.type_, amp::StreamJsonMessageType::User)); + + let assistant_msg = r#"{"type":"assistant","message":{"role":"assistant","content":"Hi there"},"session_id":"sess-1"}"#; + let parsed: amp::StreamJsonMessage = serde_json::from_str(assistant_msg).unwrap(); + assert!(matches!(parsed.type_, amp::StreamJsonMessageType::Assistant)); + + let result_msg = r#"{"type":"result","subtype":"success","duration_ms":1000,"is_error":false,"num_turns":1,"result":"Done","session_id":"sess-1"}"#; + let parsed: amp::StreamJsonMessage = serde_json::from_str(result_msg).unwrap(); + assert!(matches!(parsed.type_, amp::StreamJsonMessageType::Result)); + + // Test legacy types still work + let message_msg = r#"{"type":"message","id":"msg-1","content":"Hello"}"#; + let parsed: amp::StreamJsonMessage = serde_json::from_str(message_msg).unwrap(); + assert!(matches!(parsed.type_, amp::StreamJsonMessageType::Message)); + + let done_msg = r#"{"type":"done"}"#; + let parsed: amp::StreamJsonMessage = serde_json::from_str(done_msg).unwrap(); + assert!(matches!(parsed.type_, amp::StreamJsonMessageType::Done)); +} diff --git a/server/packages/sandbox-agent/Cargo.toml b/server/packages/sandbox-agent/Cargo.toml index 850f4b6..cf0a6ad 100644 --- a/server/packages/sandbox-agent/Cargo.toml +++ b/server/packages/sandbox-agent/Cargo.toml @@ -36,6 +36,9 @@ tracing-logfmt.workspace = true tracing-subscriber.workspace = true include_dir.workspace = true base64.workspace = true +toml_edit.workspace = true +tar.workspace = true +zip.workspace = true tempfile = { workspace = true, optional = true } [target.'cfg(unix)'.dependencies] diff --git a/server/packages/sandbox-agent/build.rs b/server/packages/sandbox-agent/build.rs index 515a20c..9162e4d 100644 --- a/server/packages/sandbox-agent/build.rs +++ b/server/packages/sandbox-agent/build.rs @@ -17,7 +17,15 @@ fn main() { println!("cargo:rerun-if-env-changed=SANDBOX_AGENT_SKIP_INSPECTOR"); println!("cargo:rerun-if-env-changed=SANDBOX_AGENT_VERSION"); - println!("cargo:rerun-if-changed={}", dist_dir.display()); + let dist_exists = dist_dir.exists(); + if dist_exists { + println!("cargo:rerun-if-changed={}", dist_dir.display()); + } else { + println!( + "cargo:warning=Inspector frontend missing at {}. Embedding disabled; set SANDBOX_AGENT_SKIP_INSPECTOR=1 to silence or build the inspector to embed it.", + dist_dir.display() + ); + } // Rebuild when the git HEAD changes so BUILD_ID stays current. let git_head = manifest_dir.join(".git/HEAD"); @@ -36,7 +44,7 @@ fn main() { generate_version(&out_dir); generate_build_id(&out_dir); - let skip = env::var("SANDBOX_AGENT_SKIP_INSPECTOR").is_ok(); + let skip = env::var("SANDBOX_AGENT_SKIP_INSPECTOR").is_ok() || !dist_exists; let out_file = out_dir.join("inspector_assets.rs"); if skip { @@ -44,13 +52,6 @@ fn main() { return; } - if !dist_dir.exists() { - panic!( - "Inspector frontend missing at {}. Run `pnpm --filter @sandbox-agent/inspector build` (or `pnpm -C frontend/packages/inspector build`) or set SANDBOX_AGENT_SKIP_INSPECTOR=1 to skip embedding.", - dist_dir.display() - ); - } - let dist_literal = quote_path(&dist_dir); let contents = format!( "pub const INSPECTOR_ENABLED: bool = true;\n\ @@ -98,26 +99,23 @@ fn generate_version(out_dir: &Path) { fn generate_build_id(out_dir: &Path) { use std::process::Command; - let build_id = Command::new("git") + let source_id = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .ok() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) - .unwrap_or_else(|| { - // Fallback: use the package version + compile-time timestamp - let version = env::var("CARGO_PKG_VERSION").unwrap_or_default(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs().to_string()) - .unwrap_or_default(); - format!("{version}-{timestamp}") - }); + .unwrap_or_else(|| env::var("CARGO_PKG_VERSION").unwrap_or_default()); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos().to_string()) + .unwrap_or_else(|_| "0".to_string()); + let build_id = format!("{source_id}-{timestamp}"); let out_file = out_dir.join("build_id.rs"); let contents = format!( - "/// Unique identifier for this build (git short hash or version-timestamp fallback).\n\ + "/// Unique identifier for this build (source id + build timestamp).\n\ pub const BUILD_ID: &str = \"{}\";\n", build_id ); diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index a838a65..7849bb0 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fs::File; use std::io::Write; use std::path::PathBuf; use std::process::{Command as ProcessCommand, Stdio}; @@ -13,12 +14,14 @@ mod build_version { } use crate::router::{build_router_with_state, shutdown_servers}; use crate::router::{ - AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, MessageRequest, - PermissionReply, PermissionReplyRequest, QuestionReplyRequest, + AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, McpServerConfig, + MessageRequest, PermissionReply, PermissionReplyRequest, QuestionReplyRequest, SkillSource, + SkillsConfig, }; use crate::router::{ AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse, - EventsResponse, SessionListResponse, + EventsResponse, FsActionResponse, FsEntry, FsMoveRequest, FsMoveResponse, FsStat, + FsUploadBatchResponse, FsWriteResponse, SessionListResponse, }; use crate::server_logs::ServerLogs; use crate::telemetry; @@ -68,6 +71,10 @@ pub struct GigacodeCli { #[arg(long, short = 'n', global = true)] pub no_token: bool, + + /// Bypass all permission checks (auto-approve tool calls). + #[arg(long, global = true)] + pub yolo: bool, } #[derive(Subcommand, Debug)] @@ -127,8 +134,9 @@ pub struct OpencodeArgs { #[arg(long)] session_title: Option<String>, + /// Bypass all permission checks (auto-approve tool calls). #[arg(long)] - opencode_bin: Option<PathBuf>, + pub yolo: bool, } impl Default for OpencodeArgs { @@ -137,7 +145,7 @@ impl Default for OpencodeArgs { host: DEFAULT_HOST.to_string(), port: DEFAULT_PORT, session_title: None, - opencode_bin: None, + yolo: false, } } } @@ -171,6 +179,10 @@ pub struct DaemonStartArgs { #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] port: u16, + + /// If the daemon is already running but outdated, stop and restart it. + #[arg(long, default_value_t = false)] + upgrade: bool, } #[derive(Args, Debug)] @@ -197,6 +209,8 @@ pub enum ApiCommand { Agents(AgentsArgs), /// Create sessions and interact with session events. Sessions(SessionsArgs), + /// Manage filesystem entries. + Fs(FsArgs), } #[derive(Subcommand, Debug)] @@ -220,6 +234,12 @@ pub struct SessionsArgs { command: SessionsCommand, } +#[derive(Args, Debug)] +pub struct FsArgs { + #[command(subcommand)] + command: FsCommand, +} + #[derive(Subcommand, Debug)] pub enum AgentsCommand { /// List all agents and install status. @@ -267,6 +287,27 @@ pub enum SessionsCommand { ReplyPermission(PermissionReplyArgs), } +#[derive(Subcommand, Debug)] +pub enum FsCommand { + /// List directory entries. + Entries(FsEntriesArgs), + /// Read a file. + Read(FsReadArgs), + /// Write a file. + Write(FsWriteArgs), + /// Delete a file or directory. + Delete(FsDeleteArgs), + /// Create a directory. + Mkdir(FsMkdirArgs), + /// Move a file or directory. + Move(FsMoveArgs), + /// Stat a file or directory. + Stat(FsStatArgs), + /// Upload a tar archive and extract it. + #[command(name = "upload-batch")] + UploadBatch(FsUploadBatchArgs), +} + #[derive(Args, Debug, Clone)] pub struct ClientArgs { #[arg(long, short = 'e')] @@ -318,6 +359,10 @@ pub struct CreateSessionArgs { variant: Option<String>, #[arg(long, short = 'A')] agent_version: Option<String>, + #[arg(long)] + mcp_config: Option<PathBuf>, + #[arg(long)] + skill: Vec<PathBuf>, #[command(flatten)] client: ClientArgs, } @@ -401,6 +446,91 @@ pub struct PermissionReplyArgs { client: ClientArgs, } +#[derive(Args, Debug)] +pub struct FsEntriesArgs { + #[arg(long)] + path: Option<String>, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsReadArgs { + path: String, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsWriteArgs { + path: String, + #[arg(long)] + content: Option<String>, + #[arg(long = "from-file")] + from_file: Option<PathBuf>, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsDeleteArgs { + path: String, + #[arg(long)] + recursive: bool, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsMkdirArgs { + path: String, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsMoveArgs { + from: String, + to: String, + #[arg(long)] + overwrite: bool, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsStatArgs { + path: String, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct FsUploadBatchArgs { + #[arg(long = "tar")] + tar_path: PathBuf, + #[arg(long)] + path: Option<String>, + #[arg(long)] + session_id: Option<String>, + #[command(flatten)] + client: ClientArgs, +} + #[derive(Args, Debug)] pub struct CredentialsExtractArgs { #[arg(long, short = 'a', value_enum)] @@ -428,6 +558,8 @@ pub struct CredentialsExtractEnvArgs { #[derive(Debug, Error)] pub enum CliError { + #[error("missing --token or --no-token for server mode")] + MissingToken, #[error("invalid cors origin: {0}")] InvalidCorsOrigin(String), #[error("invalid cors method: {0}")] @@ -585,6 +717,7 @@ fn run_api(command: &ApiCommand, cli: &CliConfig) -> Result<(), CliError> { match command { ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli), ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli), + ApiCommand::Fs(subcommand) => run_fs(&subcommand.command, cli), } } @@ -596,26 +729,57 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> { }; write_stderr_line(&format!("\nEXPERIMENTAL: Please report bugs to:\n- GitHub: https://github.com/rivet-dev/sandbox-agent/issues\n- Discord: https://rivet.dev/discord\n\n{name} is powered by:\n- OpenCode (TUI): https://opencode.ai/\n- Sandbox Agent SDK (multi-agent compatibility): https://sandboxagent.dev/\n\n"))?; + let yolo = args.yolo; let token = cli.token.clone(); let base_url = format!("http://{}:{}", args.host, args.port); + let has_proxy_env = std::env::var_os("HTTP_PROXY").is_some() + || std::env::var_os("http_proxy").is_some() + || std::env::var_os("HTTPS_PROXY").is_some() + || std::env::var_os("https_proxy").is_some(); + let has_no_proxy_env = + std::env::var_os("NO_PROXY").is_some() || std::env::var_os("no_proxy").is_some(); + write_stderr_line(&format!( + "gigacode startup: ensuring daemon at {base_url} (token: {}, proxy env: {}, no_proxy env: {})", + if token.is_some() { "set" } else { "unset" }, + if has_proxy_env { "set" } else { "unset" }, + if has_no_proxy_env { "set" } else { "unset" } + ))?; crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?; + write_stderr_line("gigacode startup: daemon is healthy")?; - let session_id = - create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?; - write_stdout_line(&format!("OpenCode session: {session_id}"))?; + let attach_session_id = if args.session_title.is_some() || yolo { + write_stderr_line("gigacode startup: creating OpenCode session via /opencode/session")?; + let session_id = create_opencode_session( + &base_url, + token.as_deref(), + args.session_title.as_deref(), + yolo, + )?; + write_stdout_line(&format!("OpenCode session: {session_id}"))?; + Some(session_id) + } else { + write_stderr_line("gigacode startup: attaching OpenCode without precreating a session")?; + None + }; let attach_url = format!("{base_url}/opencode"); - let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref())?; + write_stderr_line("gigacode startup: resolving OpenCode binary (installing if needed)")?; + let opencode_bin = resolve_opencode_bin()?; + write_stderr_line(&format!( + "gigacode startup: launching OpenCode attach using {}", + opencode_bin.display() + ))?; let mut opencode_cmd = ProcessCommand::new(opencode_bin); opencode_cmd .arg("attach") .arg(&attach_url) - .arg("--session") - .arg(&session_id) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); + if let Some(session_id) = attach_session_id.as_deref() { + opencode_cmd.arg("--session").arg(session_id); + } if let Some(token) = token.as_deref() { opencode_cmd.arg("--password").arg(token); } @@ -636,6 +800,9 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> { fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> { let token = cli.token.as_deref(); match command { + DaemonCommand::Start(args) if args.upgrade => { + crate::daemon::ensure_running(cli, &args.host, args.port, token) + } DaemonCommand::Start(args) => crate::daemon::start(cli, &args.host, args.port, token), DaemonCommand::Stop(args) => crate::daemon::stop(&args.host, args.port), DaemonCommand::Status(args) => { @@ -686,6 +853,32 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr } SessionsCommand::Create(args) => { let ctx = ClientContext::new(cli, &args.client)?; + let mcp = if let Some(path) = &args.mcp_config { + let text = std::fs::read_to_string(path)?; + let parsed = serde_json::from_str::< + std::collections::BTreeMap<String, McpServerConfig>, + >(&text)?; + Some(parsed) + } else { + None + }; + let skills = if args.skill.is_empty() { + None + } else { + Some(SkillsConfig { + sources: args + .skill + .iter() + .map(|path| SkillSource { + source_type: "local".to_string(), + source: path.to_string_lossy().to_string(), + skills: None, + git_ref: None, + subpath: None, + }) + .collect(), + }) + }; let body = CreateSessionRequest { agent: args.agent.clone(), agent_mode: args.agent_mode.clone(), @@ -693,6 +886,10 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr model: args.model.clone(), variant: args.variant.clone(), agent_version: args.agent_version.clone(), + directory: None, + title: None, + mcp, + skills, }; let path = format!("{API_PREFIX}/sessions/{}", args.session_id); let response = ctx.post(&path, &body)?; @@ -702,6 +899,7 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr let ctx = ClientContext::new(cli, &args.client)?; let body = MessageRequest { message: args.message.clone(), + attachments: Vec::new(), }; let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id); let response = ctx.post(&path, &body)?; @@ -711,6 +909,7 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr let ctx = ClientContext::new(cli, &args.client)?; let body = MessageRequest { message: args.message.clone(), + attachments: Vec::new(), }; let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id); let response = ctx.post_with_query( @@ -807,18 +1006,145 @@ fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliErr } } +fn run_fs(command: &FsCommand, cli: &CliConfig) -> Result<(), CliError> { + match command { + FsCommand::Entries(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let response = ctx.get_with_query( + &format!("{API_PREFIX}/fs/entries"), + &[ + ("path", args.path.clone()), + ("session_id", args.session_id.clone()), + ], + )?; + print_json_response::<Vec<FsEntry>>(response) + } + FsCommand::Read(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let response = ctx.get_with_query( + &format!("{API_PREFIX}/fs/file"), + &[ + ("path", Some(args.path.clone())), + ("session_id", args.session_id.clone()), + ], + )?; + print_binary_response(response) + } + FsCommand::Write(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = match (&args.content, &args.from_file) { + (Some(_), Some(_)) => { + return Err(CliError::Server( + "use --content or --from-file, not both".to_string(), + )) + } + (None, None) => { + return Err(CliError::Server( + "write requires --content or --from-file".to_string(), + )) + } + (Some(content), None) => content.clone().into_bytes(), + (None, Some(path)) => std::fs::read(path)?, + }; + let response = ctx.put_raw_with_query( + &format!("{API_PREFIX}/fs/file"), + body, + "application/octet-stream", + &[ + ("path", Some(args.path.clone())), + ("session_id", args.session_id.clone()), + ], + )?; + print_json_response::<FsWriteResponse>(response) + } + FsCommand::Delete(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let response = ctx.delete_with_query( + &format!("{API_PREFIX}/fs/entry"), + &[ + ("path", Some(args.path.clone())), + ("session_id", args.session_id.clone()), + ( + "recursive", + if args.recursive { + Some("true".to_string()) + } else { + None + }, + ), + ], + )?; + print_json_response::<FsActionResponse>(response) + } + FsCommand::Mkdir(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let response = ctx.post_empty_with_query( + &format!("{API_PREFIX}/fs/mkdir"), + &[ + ("path", Some(args.path.clone())), + ("session_id", args.session_id.clone()), + ], + )?; + print_json_response::<FsActionResponse>(response) + } + FsCommand::Move(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = FsMoveRequest { + from: args.from.clone(), + to: args.to.clone(), + overwrite: if args.overwrite { Some(true) } else { None }, + }; + let response = ctx.post_with_query( + &format!("{API_PREFIX}/fs/move"), + &body, + &[("session_id", args.session_id.clone())], + )?; + print_json_response::<FsMoveResponse>(response) + } + FsCommand::Stat(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let response = ctx.get_with_query( + &format!("{API_PREFIX}/fs/stat"), + &[ + ("path", Some(args.path.clone())), + ("session_id", args.session_id.clone()), + ], + )?; + print_json_response::<FsStat>(response) + } + FsCommand::UploadBatch(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let file = File::open(&args.tar_path)?; + let response = ctx.post_raw_with_query( + &format!("{API_PREFIX}/fs/upload-batch"), + file, + "application/x-tar", + &[ + ("path", args.path.clone()), + ("session_id", args.session_id.clone()), + ], + )?; + print_json_response::<FsUploadBatchResponse>(response) + } + } +} + fn create_opencode_session( base_url: &str, token: Option<&str>, title: Option<&str>, + yolo: bool, ) -> Result<String, CliError> { let client = HttpClient::builder().build()?; let url = format!("{base_url}/opencode/session"); - let body = if let Some(title) = title { + let mut body = if let Some(title) = title { json!({ "title": title }) } else { json!({}) }; + if yolo { + body["permissionMode"] = json!("bypass"); + } let mut request = client.post(&url).json(&body); if let Ok(directory) = std::env::current_dir() { request = request.header( @@ -844,52 +1170,21 @@ fn create_opencode_session( Ok(session_id.to_string()) } -fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> Result<PathBuf, CliError> { - if let Some(path) = explicit { - return Ok(path.clone()); - } - if let Ok(path) = std::env::var("OPENCODE_BIN") { - return Ok(PathBuf::from(path)); - } - if let Some(path) = find_in_path("opencode") { - write_stderr_line(&format!( - "using opencode binary from PATH: {}", - path.display() - ))?; - return Ok(path); - } - +fn resolve_opencode_bin() -> Result<PathBuf, CliError> { let manager = AgentManager::new(default_install_dir()) .map_err(|err| CliError::Server(err.to_string()))?; - match manager.resolve_binary(AgentId::Opencode) { - Ok(path) => Ok(path), - Err(_) => { - write_stderr_line("opencode not found; installing...")?; - let result = manager - .install( - AgentId::Opencode, - InstallOptions { - reinstall: false, - version: None, - }, - ) - .map_err(|err| CliError::Server(err.to_string()))?; - Ok(result.path) - } + match manager.install( + AgentId::Opencode, + InstallOptions { + reinstall: false, + version: None, + }, + ) { + Ok(result) => Ok(result.path), + Err(err) => Err(CliError::Server(err.to_string())), } } -fn find_in_path(binary_name: &str) -> Option<PathBuf> { - let path_var = std::env::var_os("PATH")?; - for path in std::env::split_paths(&path_var) { - let candidate = path.join(binary_name); - if candidate.exists() { - return Some(candidate); - } - } - None -} - fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> { match command { CredentialsCommand::Extract(args) => { @@ -1290,9 +1585,75 @@ impl ClientContext { Ok(request.send()?) } + fn put_raw_with_query<B: Into<reqwest::blocking::Body>>( + &self, + path: &str, + body: B, + content_type: &str, + query: &[(&str, Option<String>)], + ) -> Result<reqwest::blocking::Response, CliError> { + let mut request = self + .request(Method::PUT, path) + .header(reqwest::header::CONTENT_TYPE, content_type) + .header(reqwest::header::ACCEPT, "application/json"); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.body(body).send()?) + } + fn post_empty(&self, path: &str) -> Result<reqwest::blocking::Response, CliError> { Ok(self.request(Method::POST, path).send()?) } + + fn post_empty_with_query( + &self, + path: &str, + query: &[(&str, Option<String>)], + ) -> Result<reqwest::blocking::Response, CliError> { + let mut request = self.request(Method::POST, path); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.send()?) + } + + fn delete_with_query( + &self, + path: &str, + query: &[(&str, Option<String>)], + ) -> Result<reqwest::blocking::Response, CliError> { + let mut request = self.request(Method::DELETE, path); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.send()?) + } + + fn post_raw_with_query<B: Into<reqwest::blocking::Body>>( + &self, + path: &str, + body: B, + content_type: &str, + query: &[(&str, Option<String>)], + ) -> Result<reqwest::blocking::Response, CliError> { + let mut request = self + .request(Method::POST, path) + .header(reqwest::header::CONTENT_TYPE, content_type) + .header(reqwest::header::ACCEPT, "application/json"); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.body(body).send()?) + } } fn print_json_response<T: serde::de::DeserializeOwned + Serialize>( @@ -1325,6 +1686,25 @@ fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliE Ok(()) } +fn print_binary_response(response: reqwest::blocking::Response) -> Result<(), CliError> { + let status = response.status(); + let bytes = response.bytes()?; + + if !status.is_success() { + if let Ok(text) = std::str::from_utf8(&bytes) { + print_error_body(text)?; + } else { + write_stderr_line("Request failed with non-text response body")?; + } + return Err(CliError::HttpStatus(status)); + } + + let mut out = std::io::stdout(); + out.write_all(&bytes)?; + out.flush()?; + Ok(()) +} + fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> { let status = response.status(); if status.is_success() { diff --git a/server/packages/sandbox-agent/src/daemon.rs b/server/packages/sandbox-agent/src/daemon.rs index 58bf6b4..3f0cdaf 100644 --- a/server/packages/sandbox-agent/src/daemon.rs +++ b/server/packages/sandbox-agent/src/daemon.rs @@ -10,10 +10,11 @@ use crate::cli::{CliConfig, CliError}; mod build_id { include!(concat!(env!("OUT_DIR"), "/build_id.rs")); } - pub use build_id::BUILD_ID; const DAEMON_HEALTH_TIMEOUT: Duration = Duration::from_secs(30); +const HEALTH_CHECK_CONNECT_TIMEOUT: Duration = Duration::from_secs(2); +const HEALTH_CHECK_REQUEST_TIMEOUT: Duration = Duration::from_secs(5); // --------------------------------------------------------------------------- // Paths @@ -144,16 +145,40 @@ pub fn is_process_running(pid: u32) -> bool { // --------------------------------------------------------------------------- pub fn check_health(base_url: &str, token: Option<&str>) -> Result<bool, CliError> { - let client = HttpClient::builder().build()?; let url = format!("{base_url}/v1/health"); + let started_at = Instant::now(); + let client = HttpClient::builder() + .connect_timeout(HEALTH_CHECK_CONNECT_TIMEOUT) + .timeout(HEALTH_CHECK_REQUEST_TIMEOUT) + .build()?; let mut request = client.get(url); if let Some(token) = token { request = request.bearer_auth(token); } match request.send() { - Ok(response) if response.status().is_success() => Ok(true), - Ok(_) => Ok(false), - Err(_) => Ok(false), + Ok(response) if response.status().is_success() => { + tracing::info!( + elapsed_ms = started_at.elapsed().as_millis(), + "daemon health check succeeded" + ); + Ok(true) + } + Ok(response) => { + tracing::warn!( + status = %response.status(), + elapsed_ms = started_at.elapsed().as_millis(), + "daemon health check returned non-success status" + ); + Ok(false) + } + Err(err) => { + tracing::warn!( + error = %err, + elapsed_ms = started_at.elapsed().as_millis(), + "daemon health check request failed" + ); + Ok(false) + } } } @@ -163,10 +188,15 @@ pub fn wait_for_health( token: Option<&str>, timeout: Duration, ) -> Result<(), CliError> { - let client = HttpClient::builder().build()?; + let client = HttpClient::builder() + .connect_timeout(HEALTH_CHECK_CONNECT_TIMEOUT) + .timeout(HEALTH_CHECK_REQUEST_TIMEOUT) + .build()?; let deadline = Instant::now() + timeout; + let mut attempts: u32 = 0; while Instant::now() < deadline { + attempts += 1; if let Some(child) = server_child.as_mut() { if let Some(status) = child.try_wait()? { return Err(CliError::Server(format!( @@ -181,13 +211,43 @@ pub fn wait_for_health( request = request.bearer_auth(token); } match request.send() { - Ok(response) if response.status().is_success() => return Ok(()), - _ => { + Ok(response) if response.status().is_success() => { + tracing::info!( + attempts, + elapsed_ms = + (timeout - deadline.saturating_duration_since(Instant::now())).as_millis(), + "daemon became healthy while waiting" + ); + return Ok(()); + } + Ok(response) => { + if attempts % 10 == 0 { + tracing::info!( + attempts, + status = %response.status(), + "daemon still not healthy; waiting" + ); + } + std::thread::sleep(Duration::from_millis(200)); + } + Err(err) => { + if attempts % 10 == 0 { + tracing::warn!( + attempts, + error = %err, + "daemon health poll request failed; still waiting" + ); + } std::thread::sleep(Duration::from_millis(200)); } } } + tracing::error!( + attempts, + timeout_ms = timeout.as_millis(), + "timed out waiting for daemon health" + ); Err(CliError::Server( "timed out waiting for sandbox-agent health".to_string(), )) @@ -198,7 +258,7 @@ pub fn wait_for_health( // --------------------------------------------------------------------------- pub fn spawn_sandbox_agent_daemon( - cli: &CliConfig, + _cli: &CliConfig, host: &str, port: u16, token: Option<&str>, @@ -350,25 +410,26 @@ pub fn start(cli: &CliConfig, host: &str, port: u16, token: Option<&str>) -> Res Ok(()) } +/// Find the PID of a process listening on the given port using lsof. #[cfg(unix)] -pub fn stop(host: &str, port: u16) -> Result<(), CliError> { - let pid_path = daemon_pid_path(host, port); +fn find_process_on_port(port: u16) -> Option<u32> { + let output = std::process::Command::new("lsof") + .args(["-i", &format!(":{port}"), "-t", "-sTCP:LISTEN"]) + .output() + .ok()?; - let pid = match read_pid(&pid_path) { - Some(pid) => pid, - None => { - eprintln!("daemon is not running (no PID file)"); - return Ok(()); - } - }; - - if !is_process_running(pid) { - eprintln!("daemon is not running (stale PID file)"); - let _ = remove_pid(&pid_path); - let _ = remove_version_file(host, port); - return Ok(()); + if !output.status.success() { + return None; } + let stdout = String::from_utf8_lossy(&output.stdout); + // lsof -t returns just the PID(s), one per line + stdout.lines().next()?.trim().parse::<u32>().ok() +} + +/// Stop a process by PID with SIGTERM then SIGKILL if needed. +#[cfg(unix)] +fn stop_process(pid: u32, host: &str, port: u16, pid_path: &Path) -> Result<(), CliError> { eprintln!("stopping daemon (PID {pid})..."); // SIGTERM @@ -380,7 +441,7 @@ pub fn stop(host: &str, port: u16) -> Result<(), CliError> { for _ in 0..50 { std::thread::sleep(Duration::from_millis(100)); if !is_process_running(pid) { - let _ = remove_pid(&pid_path); + let _ = remove_pid(pid_path); let _ = remove_version_file(host, port); eprintln!("daemon stopped"); return Ok(()); @@ -393,12 +454,50 @@ pub fn stop(host: &str, port: u16) -> Result<(), CliError> { libc::kill(pid as i32, libc::SIGKILL); } std::thread::sleep(Duration::from_millis(100)); - let _ = remove_pid(&pid_path); + let _ = remove_pid(pid_path); let _ = remove_version_file(host, port); eprintln!("daemon killed"); Ok(()) } +#[cfg(unix)] +pub fn stop(host: &str, port: u16) -> Result<(), CliError> { + let base_url = format!("http://{host}:{port}"); + let pid_path = daemon_pid_path(host, port); + + let pid = match read_pid(&pid_path) { + Some(pid) => pid, + None => { + // No PID file - but check if daemon is actually running via health check + // This can happen if PID file was deleted but daemon is still running + if check_health(&base_url, None)? { + eprintln!( + "daemon is running but PID file missing; finding process on port {port}..." + ); + if let Some(pid) = find_process_on_port(port) { + eprintln!("found daemon process {pid}"); + return stop_process(pid, host, port, &pid_path); + } else { + return Err(CliError::Server(format!( + "daemon is running on port {port} but cannot find PID" + ))); + } + } + eprintln!("daemon is not running (no PID file)"); + return Ok(()); + } + }; + + if !is_process_running(pid) { + eprintln!("daemon is not running (stale PID file)"); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + return Ok(()); + } + + stop_process(pid, host, port, &pid_path) +} + #[cfg(windows)] pub fn stop(host: &str, port: u16) -> Result<(), CliError> { let pid_path = daemon_pid_path(host, port); @@ -440,13 +539,20 @@ pub fn ensure_running( ) -> Result<(), CliError> { let base_url = format!("http://{host}:{port}"); let pid_path = daemon_pid_path(host, port); + eprintln!( + "checking daemon health at {base_url} (token: {})...", + if token.is_some() { "set" } else { "unset" } + ); // Check if daemon is already healthy if check_health(&base_url, token)? { // Check build version if !is_version_current(host, port) { let old = read_daemon_version(host, port).unwrap_or_else(|| "unknown".to_string()); - eprintln!("daemon outdated (build {old} -> {BUILD_ID}), restarting..."); + eprintln!( + "daemon outdated (build {old} -> {}), restarting...", + BUILD_ID + ); stop(host, port)?; return start(cli, host, port, token); } diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs index 4169fca..78e55a1 100644 --- a/server/packages/sandbox-agent/src/main.rs +++ b/server/packages/sandbox-agent/src/main.rs @@ -1,5 +1,7 @@ +use sandbox_agent::cli::run_sandbox_agent; + fn main() { - if let Err(err) = sandbox_agent::cli::run_sandbox_agent() { + if let Err(err) = run_sandbox_agent() { tracing::error!(error = %err, "sandbox-agent failed"); std::process::exit(1); } diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 357c81f..ff07af6 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -4,28 +4,38 @@ //! stubbed responses with deterministic helpers for snapshot testing. A minimal //! in-memory state tracks sessions/messages/ptys to keep behavior coherent. -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::convert::Infallible; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::sync::Mutex as StdMutex; use std::time::{Duration, Instant}; +use axum::body::Body; use axum::extract::{Path, Query, State}; -use axum::http::{HeaderMap, StatusCode}; +use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::sse::{Event, KeepAlive}; -use axum::response::{IntoResponse, Sse}; +use axum::response::{IntoResponse, Response, Sse}; use axum::routing::{get, patch, post, put}; use axum::{Json, Router}; use futures::stream; +use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::sync::{broadcast, Mutex}; use tokio::time::interval; +use tracing::{info, warn}; use utoipa::{IntoParams, OpenApi, ToSchema}; -use crate::router::{AgentModelInfo, AppState, CreateSessionRequest, PermissionReply}; +use crate::router::{ + is_question_tool_action, AgentModelInfo, AppState, CreateSessionRequest, PermissionReply, + SessionInfo, +}; use sandbox_agent_agent_management::agents::AgentId; +use sandbox_agent_agent_management::credentials::{ + extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials, +}; use sandbox_agent_error::SandboxError; use sandbox_agent_universal_agent_schema::{ ContentPart, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, @@ -38,10 +48,19 @@ static MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); static PART_COUNTER: AtomicU64 = AtomicU64::new(1); static PTY_COUNTER: AtomicU64 = AtomicU64::new(1); static PROJECT_COUNTER: AtomicU64 = AtomicU64::new(1); +const OPENCODE_EVENT_CHANNEL_SIZE: usize = 2048; +const OPENCODE_EVENT_LOG_SIZE: usize = 4096; const OPENCODE_DEFAULT_MODEL_ID: &str = "mock"; const OPENCODE_DEFAULT_PROVIDER_ID: &str = "mock"; const OPENCODE_DEFAULT_AGENT_MODE: &str = "build"; const OPENCODE_MODEL_CACHE_TTL: Duration = Duration::from_secs(30); +const OPENCODE_MODEL_CHANGE_AFTER_SESSION_CREATE_ERROR: &str = "OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session."; + +#[derive(Clone, Debug)] +struct OpenCodeStreamEvent { + id: u64, + payload: Value, +} #[derive(Clone, Debug)] struct OpenCodeCompatConfig { @@ -52,6 +71,7 @@ struct OpenCodeCompatConfig { fixed_state: Option<String>, fixed_config: Option<String>, fixed_branch: Option<String>, + proxy_base_url: Option<String>, } impl OpenCodeCompatConfig { @@ -66,6 +86,9 @@ impl OpenCodeCompatConfig { fixed_state: std::env::var("OPENCODE_COMPAT_STATE").ok(), fixed_config: std::env::var("OPENCODE_COMPAT_CONFIG").ok(), fixed_branch: std::env::var("OPENCODE_COMPAT_BRANCH").ok(), + proxy_base_url: std::env::var("OPENCODE_COMPAT_PROXY_URL") + .ok() + .and_then(normalize_proxy_base_url), } } @@ -80,6 +103,19 @@ impl OpenCodeCompatConfig { } } +fn normalize_proxy_base_url(value: String) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let normalized = trimmed.trim_end_matches('/').to_string(); + if normalized.starts_with("http://") || normalized.starts_with("https://") { + Some(normalized) + } else { + None + } +} + #[derive(Clone, Debug)] struct OpenCodeSessionRecord { id: String, @@ -92,6 +128,7 @@ struct OpenCodeSessionRecord { created_at: i64, updated_at: i64, share_url: Option<String>, + permission_mode: Option<String>, } impl OpenCodeSessionRecord { @@ -120,6 +157,27 @@ impl OpenCodeSessionRecord { } } +/// Convert a v1 `SessionInfo` to the OpenCode session JSON format. +fn session_info_to_opencode_value(info: &SessionInfo, default_project_id: &str) -> Value { + let title = info + .title + .clone() + .unwrap_or_else(|| format!("Session {}", info.session_id)); + let directory = info.directory.clone().unwrap_or_default(); + json!({ + "id": info.session_id, + "slug": format!("session-{}", info.session_id), + "projectID": default_project_id, + "directory": directory, + "title": title, + "version": "0", + "time": { + "created": info.created_at, + "updated": info.updated_at, + } + }) +} + #[derive(Clone, Debug)] struct OpenCodeMessageRecord { info: Value, @@ -201,7 +259,9 @@ impl OpenCodeQuestionRecord { #[derive(Default, Clone)] struct OpenCodeSessionRuntime { + turn_in_progress: bool, last_user_message_id: Option<String>, + active_assistant_message_id: Option<String>, last_agent: Option<String>, last_model_provider: Option<String>, last_model_id: Option<String>, @@ -217,6 +277,14 @@ struct OpenCodeSessionRuntime { tool_name_by_call: HashMap<String, String>, /// Tool arguments by call_id, persisted from ToolCall for use in ToolResult events tool_args_by_call: HashMap<String, String>, + /// Tool calls that have been requested but not yet resolved. + open_tool_calls: HashSet<String>, + /// Assistant messages that have streamed text deltas. + messages_with_text_deltas: HashSet<String>, + /// Item IDs (native and normalized) known to be user messages. + user_item_ids: HashSet<String>, + /// Item IDs (native and normalized) that should not emit text deltas. + non_text_item_ids: HashSet<String>, } #[derive(Clone, Debug)] @@ -237,6 +305,8 @@ struct OpenCodeModelCache { default_model: String, cached_at: Instant, had_discovery_errors: bool, + /// Group IDs that have valid credentials available + connected: Vec<String>, } pub struct OpenCodeState { @@ -249,13 +319,15 @@ pub struct OpenCodeState { questions: Mutex<HashMap<String, OpenCodeQuestionRecord>>, session_runtime: Mutex<HashMap<String, OpenCodeSessionRuntime>>, session_streams: Mutex<HashMap<String, bool>>, - event_broadcaster: broadcast::Sender<Value>, + event_broadcaster: broadcast::Sender<OpenCodeStreamEvent>, + event_log: StdMutex<VecDeque<OpenCodeStreamEvent>>, + next_event_id: AtomicU64, model_cache: Mutex<Option<OpenCodeModelCache>>, } impl OpenCodeState { pub fn new() -> Self { - let (event_broadcaster, _) = broadcast::channel(256); + let (event_broadcaster, _) = broadcast::channel(OPENCODE_EVENT_CHANNEL_SIZE); let project_id = format!("proj_{}", PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)); Self { config: OpenCodeCompatConfig::from_env(), @@ -268,16 +340,44 @@ impl OpenCodeState { session_runtime: Mutex::new(HashMap::new()), session_streams: Mutex::new(HashMap::new()), event_broadcaster, + event_log: StdMutex::new(VecDeque::new()), + next_event_id: AtomicU64::new(1), model_cache: Mutex::new(None), } } - pub fn subscribe(&self) -> broadcast::Receiver<Value> { + fn subscribe(&self) -> broadcast::Receiver<OpenCodeStreamEvent> { self.event_broadcaster.subscribe() } pub fn emit_event(&self, event: Value) { - let _ = self.event_broadcaster.send(event); + let stream_event = OpenCodeStreamEvent { + id: self.next_event_id.fetch_add(1, Ordering::Relaxed), + payload: event, + }; + if let Ok(mut log) = self.event_log.lock() { + log.push_back(stream_event.clone()); + if log.len() > OPENCODE_EVENT_LOG_SIZE { + let overflow = log.len() - OPENCODE_EVENT_LOG_SIZE; + for _ in 0..overflow { + let _ = log.pop_front(); + } + } + } + let _ = self.event_broadcaster.send(stream_event); + } + + fn buffered_events_after(&self, last_event_id: Option<u64>) -> Vec<OpenCodeStreamEvent> { + let Some(last_event_id) = last_event_id else { + return Vec::new(); + }; + let Ok(log) = self.event_log.lock() else { + return Vec::new(); + }; + log.iter() + .filter(|event| event.id > last_event_id) + .cloned() + .collect() } fn now_ms(&self) -> i64 { @@ -342,6 +442,7 @@ impl OpenCodeState { created_at: now, updated_at: now, share_url: None, + permission_mode: None, }; let value = record.to_value(); sessions.insert(session_id.to_string(), record); @@ -365,6 +466,10 @@ impl OpenCodeState { .unwrap_or_else(|| "main".to_string()) } + fn proxy_base_url(&self) -> Option<&str> { + self.config.proxy_base_url.as_deref() + } + async fn update_runtime( &self, session_id: &str, @@ -383,6 +488,7 @@ impl OpenCodeState { pub struct OpenCodeAppState { pub inner: Arc<AppState>, pub opencode: Arc<OpenCodeState>, + proxy_http_client: Client, } impl OpenCodeAppState { @@ -390,6 +496,7 @@ impl OpenCodeAppState { Arc::new(Self { inner, opencode: Arc::new(OpenCodeState::new()), + proxy_http_client: Client::new(), }) } } @@ -400,33 +507,100 @@ async fn ensure_backing_session( agent: &str, model: Option<String>, variant: Option<String>, + permission_mode: Option<String>, ) -> Result<(), SandboxError> { let model = model.filter(|value| !value.trim().is_empty()); let variant = variant.filter(|value| !value.trim().is_empty()); + // Pull directory and title from the OpenCode session record if available. + let (directory, title) = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(session_id) + .map(|s| (Some(s.directory.clone()), Some(s.title.clone()))) + .unwrap_or((None, None)) + }; let request = CreateSessionRequest { agent: agent.to_string(), agent_mode: None, - permission_mode: None, + permission_mode: permission_mode.clone(), model: model.clone(), variant: variant.clone(), agent_version: None, + directory, + title, + mcp: None, + skills: None, }; - match state - .inner - .session_manager() - .create_session(session_id.to_string(), request) + let manager = state.inner.session_manager(); + match manager + .create_session(session_id.to_string(), request.clone()) .await { Ok(_) => Ok(()), - Err(SandboxError::SessionAlreadyExists { .. }) => state - .inner - .session_manager() - .set_session_overrides(session_id, model, variant) - .await - .or_else(|err| match err { - SandboxError::SessionNotFound { .. } => Ok(()), - other => Err(other), - }), + Err(SandboxError::SessionAlreadyExists { .. }) => { + let should_recreate = manager + .get_session_info(session_id) + .await + .map(|info| info.agent != agent && info.event_count <= 1) + .unwrap_or(false); + if should_recreate { + manager.delete_session(session_id).await?; + match manager + .create_session(session_id.to_string(), request.clone()) + .await + { + Ok(_) => Ok(()), + Err(SandboxError::SessionAlreadyExists { .. }) => { + match manager + .set_session_overrides(session_id, model.clone(), variant.clone()) + .await + { + Ok(()) => Ok(()), + Err(SandboxError::SessionNotFound { .. }) => { + tracing::warn!( + target = "sandbox_agent::opencode", + session_id, + "backing session vanished while applying overrides; retrying create_session" + ); + match manager + .create_session(session_id.to_string(), request.clone()) + .await + { + Ok(_) | Err(SandboxError::SessionAlreadyExists { .. }) => { + Ok(()) + } + Err(err) => Err(err), + } + } + Err(other) => Err(other), + } + } + Err(err) => Err(err), + } + } else { + match manager + .set_session_overrides(session_id, model.clone(), variant.clone()) + .await + { + Ok(()) => Ok(()), + Err(SandboxError::SessionNotFound { .. }) => { + tracing::warn!( + target = "sandbox_agent::opencode", + session_id, + "backing session missing while setting overrides; retrying create_session" + ); + match manager + .create_session(session_id.to_string(), request.clone()) + .await + { + Ok(_) | Err(SandboxError::SessionAlreadyExists { .. }) => Ok(()), + Err(err) => Err(err), + } + } + Err(other) => Err(other), + } + } + } Err(err) => Err(err), } } @@ -486,12 +660,31 @@ struct OpenCodeCreateSessionRequest { parent_id: Option<String>, #[schema(value_type = String)] permission: Option<Value>, + #[serde(alias = "permission_mode")] + permission_mode: Option<String>, + #[schema(value_type = String)] + model: Option<Value>, + #[serde(rename = "providerID")] + provider_id: Option<String>, + #[serde(rename = "modelID")] + model_id: Option<String>, + variant: Option<String>, } #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] struct OpenCodeUpdateSessionRequest { title: Option<String>, + #[schema(value_type = String)] + model: Option<Value>, + #[serde(rename = "providerID", alias = "provider_id", alias = "providerId")] + provider_id: Option<String>, + #[serde(rename = "modelID", alias = "model_id", alias = "modelId")] + model_id: Option<String>, +} + +fn update_requests_model_change(update: &OpenCodeUpdateSessionRequest) -> bool { + update.model.is_some() || update.provider_id.is_some() || update.model_id.is_some() } #[derive(Debug, Deserialize, IntoParams)] @@ -577,6 +770,17 @@ struct SessionSummarizeRequest { auto: Option<bool>, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct SessionInitRequest { + #[serde(rename = "providerID")] + provider_id: Option<String>, + #[serde(rename = "modelID")] + model_id: Option<String>, + #[serde(rename = "messageID")] + message_id: Option<String>, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] struct PermissionReplyRequest { response: Option<String>, @@ -615,6 +819,7 @@ fn available_agent_ids() -> Vec<AgentId> { AgentId::Opencode, AgentId::Amp, AgentId::Pi, + AgentId::Cursor, AgentId::Mock, ] } @@ -628,18 +833,25 @@ fn default_agent_mode() -> &'static str { } async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache { - let previous_cache = { - let cache = state.opencode.model_cache.lock().await; - if let Some(cache) = cache.as_ref() { - if cache.cached_at.elapsed() < OPENCODE_MODEL_CACHE_TTL { - return cache.clone(); - } - Some(cache.clone()) - } else { - None + // Keep this lock for the full build to enforce singleflight behavior. + // Concurrent requests wait for the same in-flight build instead of + // spawning duplicate provider/model fetches. + let mut slot = state.opencode.model_cache.lock().await; + if let Some(cache) = slot.as_ref() { + if cache.cached_at.elapsed() < OPENCODE_MODEL_CACHE_TTL { + info!( + entries = cache.entries.len(), + groups = cache.group_names.len(), + connected = cache.connected.len(), + "opencode model cache hit" + ); + return cache.clone(); } - }; + } + let previous_cache = slot.clone(); + let started = std::time::Instant::now(); + info!("opencode model cache miss; building cache"); let mut cache = build_opencode_model_cache(state).await; if let Some(previous_cache) = previous_cache { if cache.had_discovery_errors @@ -650,12 +862,39 @@ async fn opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache { cache.cached_at = Instant::now(); } } - let mut slot = state.opencode.model_cache.lock().await; + info!( + elapsed_ms = started.elapsed().as_millis() as u64, + entries = cache.entries.len(), + groups = cache.group_names.len(), + connected = cache.connected.len(), + "opencode model cache built" + ); *slot = Some(cache.clone()); cache } async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache { + let started = std::time::Instant::now(); + // Check credentials upfront + let creds_started = std::time::Instant::now(); + let credentials = match tokio::task::spawn_blocking(|| { + extract_all_credentials(&CredentialExtractionOptions::new()) + }) + .await + { + Ok(creds) => creds, + Err(err) => { + warn!("Failed to extract credentials for model cache: {err}"); + ExtractedCredentials::default() + } + }; + let has_anthropic = credentials.anthropic.is_some(); + let has_openai = credentials.openai.is_some(); + info!( + elapsed_ms = creds_started.elapsed().as_millis() as u64, + has_anthropic, has_openai, "opencode model cache credential scan complete" + ); + let mut entries = Vec::new(); let mut model_lookup = HashMap::new(); let mut ambiguous_models = HashSet::new(); @@ -665,23 +904,42 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa let mut default_model: Option<String> = None; let mut had_discovery_errors = false; - for agent in available_agent_ids() { - let response = match state.inner.session_manager().agent_models(agent).await { + let agents = available_agent_ids(); + let manager = state.inner.session_manager(); + let fetches = agents.iter().copied().map(|agent| { + let manager = manager.clone(); + async move { + let agent_started = std::time::Instant::now(); + let response = manager.agent_models(agent).await; + (agent, agent_started.elapsed(), response) + } + }); + let fetch_results = futures::future::join_all(fetches).await; + + for (agent, elapsed, response) in fetch_results { + let response = match response { Ok(response) => response, Err(err) => { had_discovery_errors = true; let (group_id, group_name) = fallback_group_for_agent(agent); group_agents.entry(group_id.clone()).or_insert(agent); group_names.entry(group_id).or_insert(group_name); - tracing::warn!( - target = "sandbox_agent::opencode", - ?agent, + warn!( + agent = agent.as_str(), + elapsed_ms = elapsed.as_millis() as u64, ?err, - "failed to discover models for OpenCode provider" + "opencode model cache failed fetching agent models" ); continue; } }; + info!( + agent = agent.as_str(), + elapsed_ms = elapsed.as_millis() as u64, + model_count = response.models.len(), + has_default = response.default_model.is_some(), + "opencode model cache fetched agent models" + ); if response.models.is_empty() { let (group_id, group_name) = fallback_group_for_agent(agent); @@ -773,7 +1031,31 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa } } - OpenCodeModelCache { + // Build connected list based on credential availability + let mut connected = Vec::new(); + for group_id in group_names.keys() { + let is_connected = match group_agents.get(group_id) { + Some(AgentId::Claude) | Some(AgentId::Amp) => has_anthropic, + Some(AgentId::Codex) => has_openai, + Some(AgentId::Opencode) => { + // Check the specific provider for opencode groups (e.g., "opencode:anthropic") + match opencode_group_provider(group_id) { + Some("anthropic") => has_anthropic, + Some("openai") => has_openai, + _ => has_anthropic || has_openai, + } + } + Some(AgentId::Pi) => true, + Some(AgentId::Cursor) => true, + Some(AgentId::Mock) => true, + None => false, + }; + if is_connected { + connected.push(group_id.clone()); + } + } + + let cache = OpenCodeModelCache { entries, model_lookup, group_defaults, @@ -783,7 +1065,18 @@ async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCa default_model, cached_at: Instant::now(), had_discovery_errors, - } + connected, + }; + info!( + elapsed_ms = started.elapsed().as_millis() as u64, + entries = cache.entries.len(), + groups = cache.group_names.len(), + connected = cache.connected.len(), + default_group = cache.default_group.as_str(), + default_model = cache.default_model.as_str(), + "opencode model cache build complete" + ); + cache } fn fallback_group_for_agent(agent: AgentId) -> (String, String) { @@ -845,13 +1138,16 @@ async fn resolve_session_agent( ) -> (String, String, String) { let cache = opencode_model_cache(state).await; let default_model_id = cache.default_model.clone(); - let mut provider_id = requested_provider + let requested_provider = requested_provider .filter(|value| !value.is_empty()) .filter(|value| *value != "sandbox-agent") .map(|value| value.to_string()); - let model_id = requested_model + let requested_model = requested_model .filter(|value| !value.is_empty()) .map(|value| value.to_string()); + let explicit_selection = requested_provider.is_some() || requested_model.is_some(); + let mut provider_id = requested_provider.clone(); + let model_id = requested_model.clone(); if provider_id.is_none() { if let Some(model_value) = model_id.as_deref() { if let Some(entry) = cache @@ -884,7 +1180,7 @@ async fn resolve_session_agent( state .opencode .update_runtime(session_id, |runtime| { - if runtime.session_agent_id.is_none() { + if runtime.session_agent_id.is_none() || explicit_selection { let agent = resolved_agent.unwrap_or_else(default_agent_id); runtime.session_agent_id = Some(agent.as_str().to_string()); runtime.session_provider_id = Some(provider_id.clone()); @@ -909,8 +1205,9 @@ fn agent_display_name(agent: AgentId) -> &'static str { AgentId::Codex => "Codex", AgentId::Opencode => "OpenCode", AgentId::Amp => "Amp", - AgentId::Mock => "Mock", AgentId::Pi => "Pi", + AgentId::Cursor => "Cursor", + AgentId::Mock => "Mock", } } @@ -1099,6 +1396,99 @@ fn bool_ok(value: bool) -> (StatusCode, Json<Value>) { (StatusCode::OK, Json(json!(value))) } +async fn proxy_native_opencode( + state: &Arc<OpenCodeAppState>, + method: reqwest::Method, + path: &str, + headers: &HeaderMap, + body: Option<Value>, +) -> Option<Response> { + let base_url = if let Some(base_url) = state.opencode.proxy_base_url() { + base_url.to_string() + } else { + match state.inner.ensure_opencode_server().await { + Ok(base_url) => base_url, + Err(err) => { + warn!(path, ?err, "failed to lazily start native opencode server"); + return None; + } + } + }; + + let mut request = state + .proxy_http_client + .request(method, format!("{base_url}{path}")); + + for header_name in [ + header::AUTHORIZATION, + header::ACCEPT, + HeaderName::from_static("x-opencode-directory"), + ] { + if let Some(value) = headers.get(&header_name) { + request = request.header(header_name.as_str(), value.as_bytes()); + } + } + + if let Some(body) = body { + request = request.json(&body); + } + + let response = match request.send().await { + Ok(response) => response, + Err(err) => { + warn!(path, ?err, "failed proxy request to native opencode"); + return Some( + ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "data": {}, + "errors": [{"message": format!("failed to proxy to native opencode: {err}")}], + "success": false, + })), + ) + .into_response(), + ); + } + }; + + let status = + StatusCode::from_u16(response.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + let body_bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(err) => { + warn!(path, ?err, "failed to read proxied response body"); + return Some( + ( + StatusCode::BAD_GATEWAY, + Json(json!({ + "data": {}, + "errors": [{"message": format!("failed to read proxied response: {err}")}], + "success": false, + })), + ) + .into_response(), + ); + } + }; + + let mut proxied = Response::new(Body::from(body_bytes)); + *proxied.status_mut() = status; + if let Some(content_type) = content_type { + if let Ok(header_value) = HeaderValue::from_str(&content_type) { + proxied + .headers_mut() + .insert(header::CONTENT_TYPE, header_value); + } + } + + Some(proxied) +} + fn build_user_message( session_id: &str, message_id: &str, @@ -1278,6 +1668,61 @@ fn unique_assistant_message_id( } } +fn set_item_text_delta_capability( + runtime: &mut OpenCodeSessionRuntime, + item_id: Option<&str>, + native_item_id: Option<&str>, + supports_text_deltas: bool, +) { + for key in [item_id, native_item_id].into_iter().flatten() { + if supports_text_deltas { + runtime.non_text_item_ids.remove(key); + } else { + runtime.non_text_item_ids.insert(key.to_string()); + } + } +} + +fn item_delta_is_non_text( + runtime: &OpenCodeSessionRuntime, + item_id: Option<&str>, + native_item_id: Option<&str>, +) -> bool { + [item_id, native_item_id] + .into_iter() + .flatten() + .any(|key| runtime.non_text_item_ids.contains(key)) +} + +fn item_supports_text_deltas(item: &UniversalItem) -> bool { + if item.kind != ItemKind::Message { + return false; + } + if !matches!(item.role.as_ref(), Some(ItemRole::Assistant)) { + return false; + } + if item.content.is_empty() { + return true; + } + item.content + .iter() + .any(|part| matches!(part, ContentPart::Text { .. })) +} + +fn extract_message_text_from_content(parts: &[ContentPart]) -> Option<String> { + let mut text = String::new(); + for part in parts { + if let ContentPart::Text { text: chunk } = part { + text.push_str(chunk); + } + } + if text.is_empty() { + None + } else { + Some(text) + } +} + fn extract_text_from_content(parts: &[ContentPart]) -> Option<String> { let mut text = String::new(); for part in parts { @@ -1446,6 +1891,38 @@ fn emit_file_edited(state: &OpenCodeState, path: &str) { })); } +fn emit_session_idle(state: &OpenCodeState, session_id: &str) { + state.emit_event(json!({ + "type": "session.status", + "properties": {"sessionID": session_id, "status": {"type": "idle"}} + })); + state.emit_event(json!({ + "type": "session.idle", + "properties": {"sessionID": session_id} + })); +} + +fn emit_session_error( + state: &OpenCodeState, + session_id: &str, + message: &str, + code: Option<&str>, + details: Option<Value>, +) { + let mut error = serde_json::Map::new(); + error.insert("data".to_string(), json!({"message": message})); + if let Some(code) = code { + error.insert("code".to_string(), json!(code)); + } + if let Some(details) = details { + error.insert("details".to_string(), details); + } + state.emit_event(json!({ + "type": "session.error", + "properties": {"sessionID": session_id, "error": Value::Object(error)} + })); +} + fn permission_event(event_type: &str, permission: &Value) -> Value { json!({ "type": event_type, @@ -1521,11 +1998,8 @@ async fn upsert_message_part( } else { record.parts.push(part); } - record.parts.sort_by(|a, b| { - let a_id = a.get("id").and_then(|v| v.as_str()).unwrap_or(""); - let b_id = b.get("id").and_then(|v| v.as_str()).unwrap_or(""); - a_id.cmp(b_id) - }); + // Preserve insertion order so UI rendering matches stream chronology. + // Sorting by synthetic part IDs can reorder text/tool parts unexpectedly. } async fn session_directory(state: &OpenCodeState, session_id: &str) -> String { @@ -1612,32 +2086,62 @@ fn patterns_from_metadata(metadata: &Option<Value>) -> Vec<String> { patterns } +fn turn_error_from_metadata(metadata: &Option<Value>) -> Option<(String, Option<Value>)> { + let error = metadata.as_ref()?.get("error")?; + let message = error + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Turn failed") + .to_string(); + Some((message, Some(error.clone()))) +} + async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEvent) { match event.event_type { UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => { if let UniversalEventData::Item(ItemEventData { item }) = &event.data { - // turn.completed or session.idle status → emit session.idle - if event.event_type == UniversalEventType::ItemCompleted - && item.kind == ItemKind::Status - { - if let Some(ContentPart::Status { label, .. }) = item.content.first() { - if label == "turn.completed" || label == "session.idle" { - let session_id = event.session_id.clone(); - state.opencode.emit_event(json!({ - "type": "session.status", - "properties": {"sessionID": session_id, "status": {"type": "idle"}} - })); - state.opencode.emit_event(json!({ - "type": "session.idle", - "properties": {"sessionID": session_id} - })); - return; - } - } - } apply_item_event(state, event.clone(), item.clone()).await; } } + UniversalEventType::TurnStarted => { + state + .opencode + .update_runtime(&event.session_id, |runtime| { + runtime.turn_in_progress = true; + }) + .await; + let session_id = event.session_id.clone(); + state.opencode.emit_event(json!({ + "type": "session.status", + "properties": {"sessionID": session_id, "status": {"type": "busy"}} + })); + } + UniversalEventType::TurnEnded => { + let turn_data = match &event.data { + UniversalEventData::Turn(data) => Some(data.clone()), + _ => None, + }; + let mut should_emit_idle = false; + state + .opencode + .update_runtime(&event.session_id, |runtime| { + let was_turn_in_progress = runtime.turn_in_progress; + runtime.active_assistant_message_id = None; + runtime.turn_in_progress = false; + runtime.open_tool_calls.clear(); + should_emit_idle = was_turn_in_progress; + }) + .await; + if let Some(turn_data) = turn_data { + if let Some((message, details)) = turn_error_from_metadata(&turn_data.metadata) { + emit_session_error(&state.opencode, &event.session_id, &message, None, details); + } + } + if !should_emit_idle { + return; + } + emit_session_idle(&state.opencode, &event.session_id); + } UniversalEventType::ItemDelta => { if let UniversalEventData::ItemDelta(ItemDeltaData { item_id, @@ -1656,15 +2160,19 @@ async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEve } } UniversalEventType::SessionEnded => { - let session_id = event.session_id.clone(); - state.opencode.emit_event(json!({ - "type": "session.status", - "properties": {"sessionID": session_id, "status": {"type": "idle"}} - })); - state.opencode.emit_event(json!({ - "type": "session.idle", - "properties": {"sessionID": event.session_id} - })); + let mut should_emit_idle = false; + state + .opencode + .update_runtime(&event.session_id, |runtime| { + should_emit_idle = runtime.turn_in_progress; + runtime.turn_in_progress = false; + runtime.active_assistant_message_id = None; + runtime.open_tool_calls.clear(); + }) + .await; + if should_emit_idle { + emit_session_idle(&state.opencode, &event.session_id); + } } UniversalEventType::PermissionRequested | UniversalEventType::PermissionResolved => { if let UniversalEventData::Permission(permission) = &event.data { @@ -1678,17 +2186,27 @@ async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEve } UniversalEventType::Error => { if let UniversalEventData::Error(error) = &event.data { - state.opencode.emit_event(json!({ - "type": "session.error", - "properties": { - "sessionID": event.session_id, - "error": { - "data": {"message": error.message}, - "code": error.code, - "details": error.details, - } - } - })); + let session_id = event.session_id.clone(); + let mut should_emit_idle = false; + state + .opencode + .update_runtime(&session_id, |runtime| { + let was_turn_in_progress = runtime.turn_in_progress; + runtime.turn_in_progress = false; + runtime.active_assistant_message_id = None; + should_emit_idle = was_turn_in_progress; + }) + .await; + emit_session_error( + &state.opencode, + &session_id, + &error.message, + error.code.as_deref(), + error.details.clone(), + ); + if should_emit_idle { + emit_session_idle(&state.opencode, &session_id); + } } } _ => {} @@ -1700,6 +2218,11 @@ async fn apply_permission_event( event: UniversalEvent, permission: PermissionEventData, ) { + // Suppress question-tool permissions (AskUserQuestion/ExitPlanMode) — these are + // handled internally via reply_question/reject_question, not exposed as permissions. + if is_question_tool_action(&permission.action) { + return; + } let session_id = event.session_id.clone(); match permission.status { PermissionStatus::Requested => { @@ -1720,10 +2243,13 @@ async fn apply_permission_event( .opencode .emit_event(permission_event("permission.asked", &value)); } - PermissionStatus::Approved | PermissionStatus::Denied => { + PermissionStatus::Accept + | PermissionStatus::AcceptForSession + | PermissionStatus::Reject => { let reply = match permission.status { - PermissionStatus::Approved => "once", - PermissionStatus::Denied => "reject", + PermissionStatus::Accept => "once", + PermissionStatus::AcceptForSession => "always", + PermissionStatus::Reject => "reject", PermissionStatus::Requested => "once", }; let event_value = json!({ @@ -1816,16 +2342,6 @@ async fn apply_item_event( event: UniversalEvent, item: UniversalItem, ) { - if matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) { - apply_tool_item_event(state, event, item).await; - return; - } - if item.kind != ItemKind::Message { - return; - } - if matches!(item.role, Some(ItemRole::User)) { - return; - } let session_id = event.session_id.clone(); let item_id_key = if item.item_id.is_empty() { None @@ -1833,6 +2349,38 @@ async fn apply_item_event( Some(item.item_id.clone()) }; let native_id_key = item.native_item_id.clone(); + let supports_text_deltas = item_supports_text_deltas(&item); + let is_user_item = matches!(item.role.as_ref(), Some(ItemRole::User)); + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + set_item_text_delta_capability( + runtime, + item_id_key.as_deref(), + native_id_key.as_deref(), + supports_text_deltas, + ); + if is_user_item { + if let Some(item_key) = item_id_key.as_ref() { + runtime.user_item_ids.insert(item_key.clone()); + } + if let Some(native_key) = native_id_key.as_ref() { + runtime.user_item_ids.insert(native_key.clone()); + } + } + }) + .await; + + if matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) { + apply_tool_item_event(state, event, item).await; + return; + } + if item.kind != ItemKind::Message { + return; + } + if is_user_item { + return; + } let mut message_id: Option<String> = None; let mut parent_id: Option<String> = None; let runtime = state @@ -1851,6 +2399,7 @@ async fn apply_item_event( .clone() .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) }) + .or_else(|| runtime.active_assistant_message_id.clone()) { message_id = Some(existing); } else { @@ -1917,56 +2466,54 @@ async fn apply_item_event( if runtime.last_user_message_id.is_none() { runtime.last_user_message_id = parent_id.clone(); } + runtime.active_assistant_message_id = Some(message_id.clone()); }) .await; - if let Some(text) = extract_text_from_content(&item.content) { - let part_id = runtime - .part_id_by_message - .entry(message_id.clone()) - .or_insert_with(|| format!("{}_text", message_id)) - .clone(); + if let Some(text) = extract_message_text_from_content(&item.content) { if event.event_type == UniversalEventType::ItemStarted { - // For ItemStarted, only store the text in runtime as the initial value - // without emitting a part event. Deltas will handle streaming, and - // ItemCompleted will emit the final text part. + // Reset streaming text state for a new assistant item. let _ = state .opencode .update_runtime(&session_id, |runtime| { - runtime - .text_by_message - .insert(message_id.clone(), String::new()); - runtime - .part_id_by_message - .insert(message_id.clone(), part_id.clone()); + runtime.text_by_message.remove(&message_id); + runtime.part_id_by_message.remove(&message_id); + runtime.messages_with_text_deltas.remove(&message_id); }) .await; } else { - // For ItemCompleted, emit the final text part with the complete text. - // Use the accumulated text from deltas if available, otherwise use - // the text from the completed event. - let final_text = runtime - .text_by_message - .get(&message_id) - .filter(|t| !t.is_empty()) - .cloned() - .unwrap_or_else(|| text.clone()); - let part = build_text_part_with_id(&session_id, &message_id, &part_id, &final_text); - upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; - state - .opencode - .emit_event(part_event("message.part.updated", &part)); - let _ = state - .opencode - .update_runtime(&session_id, |runtime| { - runtime - .text_by_message - .insert(message_id.clone(), final_text.clone()); - runtime - .part_id_by_message - .insert(message_id.clone(), part_id.clone()); - }) - .await; + // If text was streamed via deltas, keep segment ordering as emitted and + // avoid replacing the latest segment with full completed text. + let has_streamed_text = runtime.messages_with_text_deltas.contains(&message_id); + if !has_streamed_text { + let part_id = runtime + .part_id_by_message + .get(&message_id) + .cloned() + .unwrap_or_else(|| next_id("part_", &PART_COUNTER)); + let final_text = runtime + .text_by_message + .get(&message_id) + .filter(|t| !t.is_empty()) + .cloned() + .unwrap_or_else(|| text.clone()); + let part = build_text_part_with_id(&session_id, &message_id, &part_id, &final_text); + upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; + state + .opencode + .emit_event(part_event("message.part.updated", &part)); + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + runtime + .text_by_message + .insert(message_id.clone(), final_text.clone()); + runtime + .part_id_by_message + .insert(message_id.clone(), part_id.clone()); + }) + .await; + } } } @@ -2031,6 +2578,10 @@ async fn apply_item_event( runtime .tool_args_by_call .insert(call_id.clone(), arguments.clone()); + runtime.open_tool_calls.insert(call_id.clone()); + // Start a new text segment after tool activity. + runtime.part_id_by_message.remove(&message_id); + runtime.text_by_message.remove(&message_id); }) .await; } @@ -2088,6 +2639,10 @@ async fn apply_item_event( runtime .tool_message_by_call .insert(call_id.clone(), message_id.clone()); + runtime.open_tool_calls.remove(call_id); + // Start a new text segment after tool activity. + runtime.part_id_by_message.remove(&message_id); + runtime.text_by_message.remove(&message_id); }) .await; } @@ -2161,6 +2716,7 @@ async fn apply_tool_item_event( .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) }) .or_else(|| runtime.tool_message_by_call.get(&call_id).cloned()) + .or_else(|| runtime.active_assistant_message_id.clone()) { message_id = Some(existing); } else { @@ -2202,7 +2758,7 @@ async fn apply_tool_item_event( let worktree = state.opencode.worktree_for(&directory); let now = state.opencode.now_ms(); - let mut info = build_assistant_message( + let info = build_assistant_message( &session_id, &message_id, parent_id.as_deref().unwrap_or(""), @@ -2213,13 +2769,6 @@ async fn apply_tool_item_event( &provider_id, &model_id, ); - if event.event_type == UniversalEventType::ItemCompleted { - if let Some(obj) = info.as_object_mut() { - if let Some(time) = obj.get_mut("time").and_then(|v| v.as_object_mut()) { - time.insert("completed".to_string(), json!(now)); - } - } - } upsert_message_info(&state.opencode, &session_id, info.clone()).await; state .opencode @@ -2353,6 +2902,17 @@ async fn apply_tool_item_event( .tool_args_by_call .insert(call_id.clone(), args.clone()); } + if item.kind == ItemKind::ToolCall { + runtime.open_tool_calls.insert(call_id.clone()); + } + if item.kind == ItemKind::ToolResult + && event.event_type == UniversalEventType::ItemCompleted + { + runtime.open_tool_calls.remove(&call_id); + } + // Start a new text segment after tool activity. + runtime.part_id_by_message.remove(&message_id); + runtime.text_by_message.remove(&message_id); }) .await; } @@ -2371,22 +2931,35 @@ async fn apply_item_delta( Some(item_id) }; let native_id_key = native_item_id; - let is_user_delta = item_id_key - .as_ref() - .map(|value| value.starts_with("user_")) - .unwrap_or(false) - || native_id_key - .as_ref() - .map(|value| value.starts_with("user_")) - .unwrap_or(false); - if is_user_delta { - return; - } let mut message_id: Option<String> = None; let mut parent_id: Option<String> = None; + let mut is_user_delta = false; + let mut suppress_non_text_delta = false; let runtime = state .opencode .update_runtime(&session_id, |runtime| { + if item_delta_is_non_text(runtime, item_id_key.as_deref(), native_id_key.as_deref()) { + suppress_non_text_delta = true; + return; + } + let is_user_from_runtime = item_id_key + .as_ref() + .is_some_and(|value| runtime.user_item_ids.contains(value)) + || native_id_key + .as_ref() + .is_some_and(|value| runtime.user_item_ids.contains(value)); + let is_user_from_prefix = item_id_key + .as_ref() + .map(|value| value.starts_with("user_")) + .unwrap_or(false) + || native_id_key + .as_ref() + .map(|value| value.starts_with("user_")) + .unwrap_or(false); + if is_user_from_runtime || is_user_from_prefix { + is_user_delta = true; + return; + } parent_id = runtime.last_user_message_id.clone(); if let Some(existing) = item_id_key .clone() @@ -2396,6 +2969,7 @@ async fn apply_item_delta( .clone() .and_then(|key| runtime.message_id_for_item.get(&key).cloned()) }) + .or_else(|| runtime.active_assistant_message_id.clone()) { message_id = Some(existing); } else { @@ -2413,6 +2987,9 @@ async fn apply_item_delta( } }) .await; + if is_user_delta || suppress_non_text_delta { + return; + } let message_id = message_id.unwrap_or_else(|| { unique_assistant_message_id(&runtime, parent_id.as_ref(), event.sequence) }); @@ -2457,7 +3034,7 @@ async fn apply_item_delta( .part_id_by_message .get(&message_id) .cloned() - .unwrap_or_else(|| format!("{}_text", message_id)); + .unwrap_or_else(|| next_id("part_", &PART_COUNTER)); let part = build_text_part_with_id(&session_id, &message_id, &part_id, &text); upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await; state.opencode.emit_event(part_event_with_delta( @@ -2472,6 +3049,7 @@ async fn apply_item_delta( runtime .part_id_by_message .insert(message_id.clone(), part_id.clone()); + runtime.messages_with_text_deltas.insert(message_id.clone()); }) .await; } @@ -2649,8 +3227,16 @@ async fn oc_agent_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoR responses((status = 200)), tag = "opencode" )] -async fn oc_command_list() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_command_list( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, +) -> Response { + if let Some(response) = + proxy_native_opencode(&state, reqwest::Method::GET, "/command", &headers, None).await + { + return response; + } + (StatusCode::OK, Json(json!([]))).into_response() } #[utoipa::path( @@ -2659,8 +3245,13 @@ async fn oc_command_list() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_config_get() -> impl IntoResponse { - (StatusCode::OK, Json(json!({}))) +async fn oc_config_get(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> Response { + if let Some(response) = + proxy_native_opencode(&state, reqwest::Method::GET, "/config", &headers, None).await + { + return response; + } + (StatusCode::OK, Json(json!({}))).into_response() } #[utoipa::path( @@ -2670,8 +3261,23 @@ async fn oc_config_get() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_config_patch(Json(body): Json<Value>) -> impl IntoResponse { - (StatusCode::OK, Json(body)) +async fn oc_config_patch( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + Json(body): Json<Value>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::PATCH, + "/config", + &headers, + Some(body.clone()), + ) + .await + { + return response; + } + (StatusCode::OK, Json(body)).into_response() } #[utoipa::path( @@ -2724,6 +3330,13 @@ async fn oc_config_providers(State(state): State<Arc<OpenCodeAppState>>) -> impl (StatusCode::OK, Json(providers)) } +fn parse_last_event_id(headers: &HeaderMap) -> Option<u64> { + headers + .get("last-event-id") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::<u64>().ok()) +} + #[utoipa::path( get, path = "/event", @@ -2735,6 +3348,7 @@ async fn oc_event_subscribe( headers: HeaderMap, Query(query): Query<DirectoryQuery>, ) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> { + let last_event_id = parse_last_event_id(&headers); let receiver = state.opencode.subscribe(); let directory = state .opencode @@ -2751,35 +3365,61 @@ async fn oc_event_subscribe( "branch": branch, } })); + let replay_events = state.opencode.buffered_events_after(last_event_id); + let replay_cursor = replay_events + .last() + .map(|event| event.id) + .or(last_event_id) + .unwrap_or(0); let heartbeat_payload = json!({ "type": "server.heartbeat", "properties": {} }); let stream = stream::unfold( - (receiver, interval(std::time::Duration::from_secs(30))), - move |(mut rx, mut ticker)| { + ( + receiver, + interval(std::time::Duration::from_secs(30)), + VecDeque::from(replay_events), + replay_cursor, + ), + move |(mut rx, mut ticker, mut replay, replay_cursor)| { let heartbeat = heartbeat_payload.clone(); async move { - tokio::select! { - _ = ticker.tick() => { - let sse_event = Event::default() - .json_data(&heartbeat) - .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) - } - event = rx.recv() => { - match event { - Ok(event) => { - let sse_event = Event::default() - .json_data(&event) - .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) + if let Some(event) = replay.pop_front() { + let sse_event = Event::default() + .id(event.id.to_string()) + .json_data(&event.payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, ticker, replay, replay_cursor))); + } + + loop { + tokio::select! { + _ = ticker.tick() => { + let sse_event = Event::default() + .json_data(&heartbeat) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, ticker, replay, replay_cursor))); + } + event = rx.recv() => { + match event { + Ok(event) => { + if event.id <= replay_cursor { + continue; + } + let sse_event = Event::default() + .id(event.id.to_string()) + .json_data(&event.payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, ticker, replay, replay_cursor))); + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!(skipped, "opencode event stream lagged"); + return Some((Ok(Event::default().comment("lagged")), (rx, ticker, replay, replay_cursor))); + } + Err(broadcast::error::RecvError::Closed) => return None, } - Err(broadcast::error::RecvError::Lagged(_)) => { - Some((Ok(Event::default().comment("lagged")), (rx, ticker))) - } - Err(broadcast::error::RecvError::Closed) => None, } } } @@ -2801,6 +3441,7 @@ async fn oc_global_event( headers: HeaderMap, Query(query): Query<DirectoryQuery>, ) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> { + let last_event_id = parse_last_event_id(&headers); let receiver = state.opencode.subscribe(); let directory = state .opencode @@ -2817,6 +3458,12 @@ async fn oc_global_event( "branch": branch, } })); + let replay_events = state.opencode.buffered_events_after(last_event_id); + let replay_cursor = replay_events + .last() + .map(|event| event.id) + .or(last_event_id) + .unwrap_or(0); let heartbeat_payload = json!({ "payload": { @@ -2825,31 +3472,52 @@ async fn oc_global_event( } }); let stream = stream::unfold( - (receiver, interval(std::time::Duration::from_secs(30))), - move |(mut rx, mut ticker)| { + ( + receiver, + interval(std::time::Duration::from_secs(30)), + VecDeque::from(replay_events), + replay_cursor, + ), + move |(mut rx, mut ticker, mut replay, replay_cursor)| { let directory = directory.clone(); let heartbeat = heartbeat_payload.clone(); async move { - tokio::select! { - _ = ticker.tick() => { - let sse_event = Event::default() - .json_data(&heartbeat) - .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) - } - event = rx.recv() => { - match event { - Ok(event) => { - let payload = json!({"directory": directory, "payload": event}); - let sse_event = Event::default() - .json_data(&payload) - .unwrap_or_else(|_| Event::default().data("{}")); - Some((Ok(sse_event), (rx, ticker))) + if let Some(event) = replay.pop_front() { + let payload = json!({"directory": directory, "payload": event.payload}); + let sse_event = Event::default() + .id(event.id.to_string()) + .json_data(&payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, ticker, replay, replay_cursor))); + } + + loop { + tokio::select! { + _ = ticker.tick() => { + let sse_event = Event::default() + .json_data(&heartbeat) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, ticker, replay, replay_cursor))); + } + event = rx.recv() => { + match event { + Ok(event) => { + if event.id <= replay_cursor { + continue; + } + let payload = json!({"directory": directory, "payload": event.payload}); + let sse_event = Event::default() + .id(event.id.to_string()) + .json_data(&payload) + .unwrap_or_else(|_| Event::default().data("{}")); + return Some((Ok(sse_event), (rx, ticker, replay, replay_cursor))); + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + warn!(skipped, "opencode global event stream lagged"); + return Some((Ok(Event::default().comment("lagged")), (rx, ticker, replay, replay_cursor))); + } + Err(broadcast::error::RecvError::Closed) => return None, } - Err(broadcast::error::RecvError::Lagged(_)) => { - Some((Ok(Event::default().comment("lagged")), (rx, ticker))) - } - Err(broadcast::error::RecvError::Closed) => None, } } } @@ -2882,8 +3550,22 @@ async fn oc_global_health() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_global_config_get() -> impl IntoResponse { - (StatusCode::OK, Json(json!({}))) +async fn oc_global_config_get( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::GET, + "/global/config", + &headers, + None, + ) + .await + { + return response; + } + (StatusCode::OK, Json(json!({}))).into_response() } #[utoipa::path( @@ -2893,8 +3575,23 @@ async fn oc_global_config_get() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_global_config_patch(Json(body): Json<Value>) -> impl IntoResponse { - (StatusCode::OK, Json(body)) +async fn oc_global_config_patch( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + Json(body): Json<Value>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::PATCH, + "/global/config", + &headers, + Some(body.clone()), + ) + .await + { + return response; + } + (StatusCode::OK, Json(body)).into_response() } #[utoipa::path( @@ -3069,6 +3766,11 @@ async fn oc_session_create( title: None, parent_id: None, permission: None, + permission_mode: None, + model: None, + provider_id: None, + model_id: None, + variant: None, }); let directory = state .opencode @@ -3077,6 +3779,19 @@ async fn oc_session_create( let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); let title = body.title.unwrap_or_else(|| format!("Session {}", id)); + let permission_mode = body.permission_mode.clone(); + let requested_provider = body + .model + .as_ref() + .and_then(|v| v.get("providerID")) + .and_then(|v| v.as_str()) + .or(body.provider_id.as_deref()); + let requested_model = body + .model + .as_ref() + .and_then(|v| v.get("modelID")) + .and_then(|v| v.as_str()) + .or(body.model_id.as_deref()); let record = OpenCodeSessionRecord { id: id.clone(), slug, @@ -3088,6 +3803,7 @@ async fn oc_session_create( created_at: now, updated_at: now, share_url: None, + permission_mode: permission_mode.clone(), }; let session_value = record.to_value(); @@ -3096,11 +3812,32 @@ async fn oc_session_create( sessions.insert(id.clone(), record); drop(sessions); + let (session_agent, provider_id, model_id) = + resolve_session_agent(&state, &id, requested_provider, requested_model).await; + let session_agent_id = AgentId::parse(&session_agent).unwrap_or_else(default_agent_id); + let backing_model = backing_model_for_agent(session_agent_id, &provider_id, &model_id); + let backing_variant = body.variant.clone(); + if let Err(err) = ensure_backing_session( + &state, + &id, + &session_agent, + backing_model, + backing_variant, + permission_mode, + ) + .await + { + let mut sessions = state.opencode.sessions.lock().await; + sessions.remove(&id); + drop(sessions); + return sandbox_error_response(err).into_response(); + } + state .opencode .emit_event(session_event("session.created", &session_value)); - (StatusCode::OK, Json(session_value)) + (StatusCode::OK, Json(session_value)).into_response() } #[utoipa::path( @@ -3110,8 +3847,22 @@ async fn oc_session_create( tag = "opencode" )] async fn oc_session_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { - let sessions = state.opencode.sessions.lock().await; - let values: Vec<Value> = sessions.values().map(|s| s.to_value()).collect(); + let sessions = state.inner.session_manager().list_sessions().await; + let project_id = &state.opencode.default_project_id; + let mut values: Vec<Value> = sessions + .iter() + .map(|s| session_info_to_opencode_value(s, project_id)) + .collect(); + let mut seen_session_ids: HashSet<String> = sessions + .iter() + .map(|session| session.session_id.clone()) + .collect(); + let compat_sessions = state.opencode.sessions.lock().await; + for (session_id, session) in compat_sessions.iter() { + if seen_session_ids.insert(session_id.clone()) { + values.push(session.to_value()); + } + } (StatusCode::OK, Json(json!(values))) } @@ -3128,6 +3879,19 @@ async fn oc_session_get( _headers: HeaderMap, _query: Query<DirectoryQuery>, ) -> impl IntoResponse { + let project_id = &state.opencode.default_project_id; + if let Some(info) = state + .inner + .session_manager() + .get_session_info(&session_id) + .await + { + return ( + StatusCode::OK, + Json(session_info_to_opencode_value(&info, project_id)), + ) + .into_response(); + } let sessions = state.opencode.sessions.lock().await; if let Some(session) = sessions.get(&session_id) { return (StatusCode::OK, Json(session.to_value())).into_response(); @@ -3150,7 +3914,18 @@ async fn oc_session_update( ) -> impl IntoResponse { let mut sessions = state.opencode.sessions.lock().await; if let Some(session) = sessions.get_mut(&session_id) { + if update_requests_model_change(&body) { + return bad_request(OPENCODE_MODEL_CHANGE_AFTER_SESSION_CREATE_ERROR).into_response(); + } if let Some(title) = body.title { + if let Err(err) = state + .inner + .session_manager() + .set_session_title(&session_id, title.clone()) + .await + { + return sandbox_error_response(err).into_response(); + } session.title = title; session.updated_at = state.opencode.now_ms(); } @@ -3176,6 +3951,15 @@ async fn oc_session_delete( ) -> impl IntoResponse { let mut sessions = state.opencode.sessions.lock().await; if let Some(session) = sessions.remove(&session_id) { + drop(sessions); + if let Err(err) = state + .inner + .session_manager() + .delete_session(&session_id) + .await + { + return sandbox_error_response(err).into_response(); + } state .opencode .emit_event(session_event("session.deleted", &session.to_value())); @@ -3191,10 +3975,20 @@ async fn oc_session_delete( tag = "opencode" )] async fn oc_session_status(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse { - let sessions = state.opencode.sessions.lock().await; + let sessions = state.inner.session_manager().list_sessions().await; + let runtimes = state.opencode.session_runtime.lock().await; let mut status_map = serde_json::Map::new(); - for id in sessions.keys() { - status_map.insert(id.clone(), json!({"type": "idle"})); + for s in &sessions { + let status = if runtimes + .get(&s.session_id) + .map(|runtime| runtime.turn_in_progress) + .unwrap_or(false) + { + "busy" + } else { + "idle" + }; + status_map.insert(s.session_id.clone(), json!({"type": status})); } (StatusCode::OK, Json(Value::Object(status_map))) } @@ -3228,11 +4022,61 @@ async fn oc_session_children() -> impl IntoResponse { post, path = "/session/{sessionID}/init", params(("sessionID" = String, Path, description = "Session ID")), + request_body = SessionInitRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_session_init() -> impl IntoResponse { - bool_ok(true) +async fn oc_session_init( + State(state): State<Arc<OpenCodeAppState>>, + Path(session_id): Path<String>, + headers: HeaderMap, + Query(query): Query<DirectoryQuery>, + body: Option<Json<SessionInitRequest>>, +) -> impl IntoResponse { + let directory = state + .opencode + .directory_for(&headers, query.directory.as_ref()); + let _ = state.opencode.ensure_session(&session_id, directory).await; + let body = body.map(|json| json.0).unwrap_or(SessionInitRequest { + provider_id: None, + model_id: None, + message_id: None, + }); + let requested_provider = body + .provider_id + .as_deref() + .filter(|value| !value.is_empty()); + let requested_model = body.model_id.as_deref().filter(|value| !value.is_empty()); + if requested_provider.is_none() && requested_model.is_none() { + return bool_ok(true).into_response(); + } + if requested_provider.is_none() || requested_model.is_none() { + return bad_request("providerID and modelID are required when selecting a model") + .into_response(); + } + let (session_agent, provider_id, model_id) = + resolve_session_agent(&state, &session_id, requested_provider, requested_model).await; + let session_agent_id = AgentId::parse(&session_agent).unwrap_or_else(default_agent_id); + let backing_model = backing_model_for_agent(session_agent_id, &provider_id, &model_id); + let session_permission_mode = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(&session_id) + .and_then(|s| s.permission_mode.clone()) + }; + if let Err(err) = ensure_backing_session( + &state, + &session_id, + &session_agent, + backing_model, + None, + session_permission_mode, + ) + .await + { + return sandbox_error_response(err).into_response(); + } + bool_ok(true).into_response() } #[utoipa::path( @@ -3256,6 +4100,12 @@ async fn oc_session_fork( let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); let title = format!("Fork of {}", session_id); + let parent_permission_mode = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(&session_id) + .and_then(|s| s.permission_mode.clone()) + }; let record = OpenCodeSessionRecord { id: id.clone(), slug, @@ -3267,6 +4117,7 @@ async fn oc_session_fork( created_at: now, updated_at: now, share_url: None, + permission_mode: parent_permission_mode, }; let value = record.to_value(); @@ -3385,14 +4236,6 @@ async fn oc_session_message_create( .clone() .unwrap_or_else(|| next_id("msg_", &MESSAGE_COUNTER)); - state.opencode.emit_event(json!({ - "type": "session.status", - "properties": { - "sessionID": session_id, - "status": {"type": "busy"} - } - })); - let mut user_message = build_user_message( &session_id, &user_message_id, @@ -3430,18 +4273,27 @@ async fn oc_session_message_create( .opencode .update_runtime(&session_id, |runtime| { runtime.last_user_message_id = Some(user_message_id.clone()); + runtime.active_assistant_message_id = None; runtime.last_agent = Some(agent_mode.clone()); runtime.last_model_provider = Some(provider_id.clone()); runtime.last_model_id = Some(model_id.clone()); }) .await; + let session_permission_mode = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(&session_id) + .and_then(|s| s.permission_mode.clone()) + }; + if let Err(err) = ensure_backing_session( &state, &session_id, &session_agent, backing_model, backing_variant, + session_permission_mode, ) .await { @@ -3450,6 +4302,8 @@ async fn oc_session_message_create( ?err, "failed to ensure backing session" ); + emit_session_error(&state.opencode, &session_id, &err.to_string(), None, None); + return sandbox_error_response(err).into_response(); } else { ensure_session_stream(state.clone(), session_id.clone()).await; } @@ -3463,14 +4317,29 @@ async fn oc_session_message_create( if let Err(err) = state .inner .session_manager() - .send_message(session_id.clone(), prompt_text) + .send_message(session_id.clone(), prompt_text, Vec::new()) .await { + let mut should_emit_idle = false; + let _ = state + .opencode + .update_runtime(&session_id, |runtime| { + should_emit_idle = runtime.turn_in_progress; + runtime.turn_in_progress = false; + runtime.active_assistant_message_id = None; + runtime.open_tool_calls.clear(); + }) + .await; tracing::warn!( target = "sandbox_agent::opencode", ?err, "failed to send message to backing agent" ); + emit_session_error(&state.opencode, &session_id, &err.to_string(), None, None); + if should_emit_idle { + emit_session_idle(&state.opencode, &session_id); + } + return sandbox_error_response(err).into_response(); } } @@ -4022,7 +4891,6 @@ async fn oc_provider_list(State(state): State<Arc<OpenCodeAppState>>) -> impl In } let mut providers = Vec::new(); let mut defaults = serde_json::Map::new(); - let mut connected = Vec::new(); for (group_id, entries) in grouped { let mut models = serde_json::Map::new(); for entry in entries { @@ -4042,12 +4910,12 @@ async fn oc_provider_list(State(state): State<Arc<OpenCodeAppState>>) -> impl In if let Some(default_model) = cache.group_defaults.get(&group_id) { defaults.insert(group_id.clone(), Value::String(default_model.clone())); } - connected.push(group_id); } + // Use the connected list from cache (based on credential availability) let providers = json!({ "all": providers, "default": Value::Object(defaults), - "connected": connected + "connected": cache.connected }); (StatusCode::OK, Json(providers)) } @@ -4537,8 +5405,19 @@ async fn oc_skill_list() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_next() -> impl IntoResponse { - (StatusCode::OK, Json(json!({"path": "", "body": {}}))) +async fn oc_tui_next(State(state): State<Arc<OpenCodeAppState>>, headers: HeaderMap) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::GET, + "/tui/control/next", + &headers, + None, + ) + .await + { + return response; + } + (StatusCode::OK, Json(json!({"path": "", "body": {}}))).into_response() } #[utoipa::path( @@ -4548,8 +5427,23 @@ async fn oc_tui_next() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_response() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_response( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/control/response", + &headers, + body.map(|json| json.0), + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4559,8 +5453,23 @@ async fn oc_tui_response() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_append_prompt() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_append_prompt( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/append-prompt", + &headers, + body.map(|json| json.0), + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4569,8 +5478,22 @@ async fn oc_tui_append_prompt() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_help() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_open_help( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/open-help", + &headers, + None, + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4579,8 +5502,11 @@ async fn oc_tui_open_help() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_sessions() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_open_sessions( + State(_state): State<Arc<OpenCodeAppState>>, + _headers: HeaderMap, +) -> Response { + bool_ok(true).into_response() } #[utoipa::path( @@ -4589,8 +5515,22 @@ async fn oc_tui_open_sessions() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_themes() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_open_themes( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/open-themes", + &headers, + None, + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4599,8 +5539,22 @@ async fn oc_tui_open_themes() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_models() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_open_models( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/open-models", + &headers, + None, + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4610,8 +5564,23 @@ async fn oc_tui_open_models() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_submit_prompt() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_submit_prompt( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/submit-prompt", + &headers, + body.map(|json| json.0), + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4620,8 +5589,22 @@ async fn oc_tui_submit_prompt() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_clear_prompt() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_clear_prompt( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/clear-prompt", + &headers, + None, + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4631,8 +5614,23 @@ async fn oc_tui_clear_prompt() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_execute_command() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_execute_command( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/execute-command", + &headers, + body.map(|json| json.0), + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4642,8 +5640,23 @@ async fn oc_tui_execute_command() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_show_toast() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_show_toast( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/show-toast", + &headers, + body.map(|json| json.0), + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4653,8 +5666,23 @@ async fn oc_tui_show_toast() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_publish() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_publish( + State(state): State<Arc<OpenCodeAppState>>, + headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(response) = proxy_native_opencode( + &state, + reqwest::Method::POST, + "/tui/publish", + &headers, + body.map(|json| json.0), + ) + .await + { + return response; + } + bool_ok(true).into_response() } #[utoipa::path( @@ -4664,8 +5692,25 @@ async fn oc_tui_publish() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_select_session() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_select_session( + State(state): State<Arc<OpenCodeAppState>>, + _headers: HeaderMap, + body: Option<Json<Value>>, +) -> Response { + if let Some(Json(body)) = body { + // Emit a tui.session.select event so the TUI navigates to the session. + let session_id = body + .get("sessionID") + .and_then(Value::as_str) + .unwrap_or_default(); + state.opencode.emit_event(json!({ + "type": "tui.session.select", + "properties": { + "sessionID": session_id + } + })); + } + bool_ok(true).into_response() } #[derive(OpenApi)] @@ -4785,3 +5830,107 @@ async fn oc_tui_select_session() -> impl IntoResponse { tags((name = "opencode", description = "OpenCode compatibility API")) )] pub struct OpenCodeApiDoc; + +#[cfg(test)] +mod tests { + use super::*; + use sandbox_agent_universal_agent_schema::ReasoningVisibility; + + fn assistant_item(content: Vec<ContentPart>) -> UniversalItem { + UniversalItem { + item_id: "itm_assistant".to_string(), + native_item_id: Some("native_assistant".to_string()), + parent_id: None, + kind: ItemKind::Message, + role: Some(ItemRole::Assistant), + content, + status: ItemStatus::InProgress, + } + } + + #[test] + fn extract_message_text_ignores_non_text_parts() { + let parts = vec![ + ContentPart::Status { + label: "Thinking".to_string(), + detail: Some("Preparing friendly brief response".to_string()), + }, + ContentPart::Reasoning { + text: "Preparing friendly brief response".to_string(), + visibility: ReasoningVisibility::Public, + }, + ContentPart::Text { + text: "Hey! How can I help?".to_string(), + }, + ContentPart::Json { + json: serde_json::json!({"ignored": true}), + }, + ]; + + assert_eq!( + extract_message_text_from_content(&parts), + Some("Hey! How can I help?".to_string()) + ); + } + + #[test] + fn item_supports_text_deltas_only_for_assistant_text_messages() { + assert!(item_supports_text_deltas(&assistant_item(Vec::new()))); + assert!(item_supports_text_deltas(&assistant_item(vec![ + ContentPart::Text { + text: "hello".to_string(), + } + ]))); + assert!(!item_supports_text_deltas(&assistant_item(vec![ + ContentPart::Reasoning { + text: "internal".to_string(), + visibility: ReasoningVisibility::Private, + } + ]))); + + let user = UniversalItem { + item_id: "itm_user".to_string(), + native_item_id: Some("native_user".to_string()), + parent_id: None, + kind: ItemKind::Message, + role: Some(ItemRole::User), + content: vec![ContentPart::Text { + text: "hello".to_string(), + }], + status: ItemStatus::InProgress, + }; + assert!(!item_supports_text_deltas(&user)); + + let status = UniversalItem { + item_id: "itm_status".to_string(), + native_item_id: Some("native_status".to_string()), + parent_id: None, + kind: ItemKind::Status, + role: Some(ItemRole::Assistant), + content: vec![ContentPart::Status { + label: "thinking".to_string(), + detail: None, + }], + status: ItemStatus::InProgress, + }; + assert!(!item_supports_text_deltas(&status)); + } + + #[test] + fn text_delta_capability_blocks_non_text_item_ids() { + let mut runtime = OpenCodeSessionRuntime::default(); + set_item_text_delta_capability(&mut runtime, Some("itm_1"), Some("native_1"), false); + assert!(item_delta_is_non_text( + &runtime, + Some("itm_1"), + Some("native_1") + )); + + set_item_text_delta_capability(&mut runtime, Some("itm_1"), Some("native_1"), true); + assert!(!item_delta_is_non_text( + &runtime, + Some("itm_1"), + Some("native_1") + )); + } +} diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 78c83f4..8b94554 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -1,19 +1,21 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::convert::Infallible; -use std::io::{BufRead, BufReader, Write}; +use std::fs; +use std::io::{BufRead, BufReader, Cursor, Write}; use std::net::TcpListener; -use std::path::PathBuf; -use std::process::Stdio; +use std::path::{Path as StdPath, PathBuf}; +use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; +use axum::body::Bytes; use axum::extract::{Path, Query, State}; -use axum::http::{HeaderMap, HeaderValue, Request, StatusCode}; +use axum::http::{header, HeaderMap, HeaderValue, Request, StatusCode}; use axum::middleware::Next; use axum::response::sse::Event; use axum::response::{IntoResponse, Response, Sse}; -use axum::routing::{get, post}; +use axum::routing::{delete, get, post}; use axum::Json; use axum::Router; use base64::Engine; @@ -22,18 +24,23 @@ use reqwest::Client; use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError}; use sandbox_agent_universal_agent_schema::{ codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode, - convert_pi, pi as pi_schema, turn_completed_event, AgentUnparsedData, ContentPart, ErrorData, + convert_pi, opencode as opencode_schema, pi as pi_schema, turn_ended_event, + turn_started_event, AgentUnparsedData, ContentPart, ErrorData, EventConversion, EventSource, FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData, StderrOutput, - TerminatedBy, UniversalEvent, UniversalEventData, UniversalEventType, UniversalItem, + TerminatedBy, TurnEventData, TurnPhase, UniversalEvent, UniversalEventData, + UniversalEventType, UniversalItem, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use tokio::sync::{broadcast, mpsc, oneshot, Mutex}; +use serde_json::{json, Map, Value}; +use tar::Archive; +use tokio::sync::futures::OwnedNotified; +use tokio::sync::{broadcast, mpsc, oneshot, Mutex, Notify}; use tokio::time::sleep; use tokio_stream::wrappers::BroadcastStream; +use toml_edit::{value, Array, DocumentMut, Item, Table}; use tower_http::trace::TraceLayer; use tracing::Span; use utoipa::{Modify, OpenApi, ToSchema}; @@ -41,7 +48,6 @@ use utoipa::{Modify, OpenApi, ToSchema}; use crate::agent_server_logs::AgentServerLogs; use crate::http_client; use crate::opencode_compat::{build_opencode_router, OpenCodeAppState}; -use crate::telemetry; use crate::ui; use sandbox_agent_agent_management::agents::{ AgentError as ManagerError, AgentId, AgentManager, InstallOptions, SpawnOptions, StreamingSpawn, @@ -55,8 +61,12 @@ const MOCK_EVENT_DELAY_MS: u64 = 200; static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models?beta=true"; const ANTHROPIC_VERSION: &str = "2023-06-01"; +const CODEX_MODEL_LIST_TIMEOUT_SECS: u64 = 10; +const SKILL_ROOTS: [&str; 3] = [".agents/skills", ".claude/skills", ".opencode/skill"]; -fn claude_oauth_fallback_models() -> AgentModelsResponse { +fn claude_fallback_models() -> AgentModelsResponse { + // Claude Code accepts model aliases: default, sonnet, opus, haiku + // These work for both API key and OAuth users AgentModelsResponse { models: vec![ AgentModelInfo { @@ -65,6 +75,12 @@ fn claude_oauth_fallback_models() -> AgentModelsResponse { variants: None, default_variant: None, }, + AgentModelInfo { + id: "sonnet".to_string(), + name: Some("Sonnet".to_string()), + variants: None, + default_variant: None, + }, AgentModelInfo { id: "opus".to_string(), name: Some("Opus".to_string()), @@ -110,12 +126,12 @@ pub struct AppState { auth: AuthConfig, agent_manager: Arc<AgentManager>, session_manager: Arc<SessionManager>, - pub branding: BrandingMode, + pub(crate) branding: BrandingMode, } impl AppState { pub fn new(auth: AuthConfig, agent_manager: AgentManager) -> Self { - Self::with_branding(auth, agent_manager, BrandingMode::default()) + Self::with_branding(auth, agent_manager, BrandingMode::SandboxAgent) } pub fn with_branding( @@ -139,6 +155,10 @@ impl AppState { pub(crate) fn session_manager(&self) -> Arc<SessionManager> { self.session_manager.clone() } + + pub(crate) async fn ensure_opencode_server(&self) -> Result<String, SandboxError> { + self.session_manager.ensure_opencode_server().await + } } #[derive(Debug, Clone)] @@ -189,6 +209,13 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) "/sessions/:session_id/permissions/:permission_id/reply", post(reply_permission), ) + .route("/fs/entries", get(fs_entries)) + .route("/fs/file", get(fs_read_file).put(fs_write_file)) + .route("/fs/entry", delete(fs_delete_entry)) + .route("/fs/mkdir", post(fs_mkdir)) + .route("/fs/move", post(fs_move)) + .route("/fs/stat", get(fs_stat)) + .route("/fs/upload-batch", post(fs_upload_batch)) .with_state(shared.clone()); if shared.auth.token.is_some() { @@ -212,19 +239,14 @@ pub fn build_router_with_state(shared: Arc<AppState>) -> (Router, Arc<AppState>) )); } - let root_router = Router::new() + let mut router = Router::new() .route("/", get(get_root)) - .fallback(not_found) - .with_state(shared.clone()); - - let mut router = root_router .nest("/v1", v1_router) .nest("/opencode", opencode_router) - .merge(opencode_root_router); + .merge(opencode_root_router) + .fallback(not_found); - if ui::is_enabled() { - router = router.merge(ui::router()); - } + router = router.merge(ui::router()); let http_logging = match std::env::var("SANDBOX_AGENT_LOG_HTTP") { Ok(value) if value == "0" || value.eq_ignore_ascii_case("false") => false, @@ -296,7 +318,15 @@ pub async fn shutdown_servers(state: &Arc<AppState>) { get_events_sse, reply_question, reject_question, - reply_permission + reply_permission, + fs_entries, + fs_read_file, + fs_write_file, + fs_delete_entry, + fs_mkdir, + fs_move, + fs_stat, + fs_upload_batch ), components( schemas( @@ -314,8 +344,29 @@ pub async fn shutdown_servers(state: &Arc<AppState>) { SessionListResponse, HealthResponse, CreateSessionRequest, + SkillsConfig, + SkillSource, + McpCommand, + McpRemoteTransport, + McpOAuthConfig, + McpOAuthConfigOrDisabled, + McpServerConfig, CreateSessionResponse, + FsPathQuery, + FsEntriesQuery, + FsSessionQuery, + FsDeleteQuery, + FsUploadBatchQuery, + FsEntryType, + FsEntry, + FsStat, + FsWriteResponse, + FsMoveRequest, + FsMoveResponse, + FsActionResponse, + FsUploadBatchResponse, MessageRequest, + MessageAttachment, EventsQuery, TurnStreamQuery, EventsResponse, @@ -325,6 +376,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) { EventSource, SessionStartedData, SessionEndedData, + TurnEventData, + TurnPhase, SessionEndReason, TerminatedBy, StderrOutput, @@ -354,7 +407,8 @@ pub async fn shutdown_servers(state: &Arc<AppState>) { tags( (name = "meta", description = "Service metadata"), (name = "agents", description = "Agent management"), - (name = "sessions", description = "Session management") + (name = "sessions", description = "Session management"), + (name = "fs", description = "Filesystem operations") ), modifiers(&ServerAddon) )] @@ -393,6 +447,7 @@ struct SessionState { permission_mode: String, model: Option<String>, variant: Option<String>, + working_dir: PathBuf, native_session_id: Option<String>, pi_runtime: Option<Arc<PiSessionRuntime>>, ended: bool, @@ -405,6 +460,7 @@ struct SessionState { events: Vec<UniversalEvent>, pending_questions: HashMap<String, PendingQuestion>, pending_permissions: HashMap<String, PendingPermission>, + always_allow_actions: HashSet<String>, item_started: HashSet<String>, item_delta_seen: HashSet<String>, item_map: HashMap<String, String>, @@ -418,6 +474,12 @@ struct SessionState { claude_message_counter: u64, pending_assistant_native_ids: VecDeque<String>, pending_assistant_counter: u64, + created_at: i64, + updated_at: i64, + directory: Option<String>, + title: Option<String>, + mcp: Option<BTreeMap<String, McpServerConfig>>, + skills: Option<SkillsConfig>, } #[derive(Debug, Clone)] @@ -444,6 +506,13 @@ impl SessionState { request.permission_mode.as_deref(), )?; let (broadcaster, _rx) = broadcast::channel(256); + let working_dir = std::env::current_dir().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); Ok(Self { session_id, @@ -452,6 +521,7 @@ impl SessionState { permission_mode, model: request.model.clone(), variant: request.variant.clone(), + working_dir, native_session_id: None, pi_runtime: None, ended: false, @@ -464,6 +534,7 @@ impl SessionState { events: Vec::new(), pending_questions: HashMap::new(), pending_permissions: HashMap::new(), + always_allow_actions: HashSet::new(), item_started: HashSet::new(), item_delta_seen: HashSet::new(), item_map: HashMap::new(), @@ -477,6 +548,12 @@ impl SessionState { claude_message_counter: 0, pending_assistant_native_ids: VecDeque::new(), pending_assistant_counter: 0, + created_at: now, + updated_at: now, + directory: request.directory.clone(), + title: request.title.clone(), + mcp: request.mcp.clone(), + skills: request.skills.clone(), }) } @@ -518,6 +595,12 @@ impl SessionState { } } } + if !events.is_empty() { + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(self.updated_at); + } events } @@ -619,6 +702,7 @@ impl SessionState { } if conversion.event_type == UniversalEventType::ItemCompleted && data.item.kind == ItemKind::Message + && !matches!(data.item.role, Some(ItemRole::User)) && !self.item_delta_seen.contains(&data.item.item_id) { if let Some(delta) = text_delta_from_parts(&data.item.content) { @@ -693,6 +777,30 @@ impl SessionState { self.update_pending(&event); self.update_item_tracking(&event); + + // Suppress question-tool permissions (AskUserQuestion/ExitPlanMode) from frontends. + // The permission is still stored in pending_permissions (via update_pending above) + // so reply_question/reject_question can find and resolve it internally. + if matches!( + event.event_type, + UniversalEventType::PermissionRequested | UniversalEventType::PermissionResolved + ) { + if let UniversalEventData::Permission(ref data) = event.data { + if is_question_tool_action(&data.action) { + return None; + } + } + } + if event.event_type == UniversalEventType::PermissionRequested + && self.permission_mode == "acceptEdits" + { + if let UniversalEventData::Permission(ref data) = event.data { + if is_file_change_action(&data.action) { + return None; + } + } + } + self.events.push(event.clone()); let _ = self.broadcaster.send(event.clone()); if self.native_session_id.is_none() { @@ -770,6 +878,29 @@ impl SessionState { self.pending_permissions.remove(permission_id) } + fn remember_permission_allow_for_session(&mut self, action: &str, metadata: &Option<Value>) { + for key in permission_cache_keys(action, metadata) { + self.always_allow_actions.insert(key); + } + } + + fn should_auto_approve_permission(&self, action: &str, metadata: &Option<Value>) -> bool { + permission_cache_keys(action, metadata) + .iter() + .any(|key| self.always_allow_actions.contains(key)) + } + + /// Find and remove a pending permission whose action matches a question tool + /// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission). + fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> { + let key = self + .pending_permissions + .iter() + .find(|(_, p)| is_question_tool_action(&p.action)) + .map(|(k, _)| k.clone()); + key.and_then(|k| self.pending_permissions.remove(&k).map(|p| (k, p))) + } + fn mark_ended( &mut self, exit_code: Option<i32>, @@ -891,6 +1022,13 @@ pub(crate) struct SessionManager { sessions: Mutex<Vec<SessionState>>, server_manager: Arc<AgentServerManager>, http_client: Client, + model_catalog: Mutex<ModelCatalogState>, +} + +#[derive(Debug, Default)] +struct ModelCatalogState { + models: HashMap<AgentId, AgentModelsResponse>, + in_flight: HashMap<AgentId, Arc<Notify>>, } /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. @@ -900,11 +1038,15 @@ struct CodexServer { /// Sender for writing to the process stdin stdin_sender: mpsc::UnboundedSender<String>, /// Pending JSON-RPC requests awaiting responses, keyed by request ID - pending_requests: std::sync::Mutex<HashMap<i64, oneshot::Sender<Value>>>, + pending_requests: std::sync::Mutex<HashMap<i64, oneshot::Sender<CodexRequestResult>>>, + /// Optional mapping from request ID to session ID for routing request-scoped errors + request_sessions: std::sync::Mutex<HashMap<i64, String>>, /// Next request ID for JSON-RPC next_id: AtomicI64, /// Whether initialize/initialized handshake has completed initialized: std::sync::Mutex<bool>, + /// Serializes initialize handshakes so only one request is in flight at a time. + initialize_lock: Mutex<()>, /// Mapping from thread_id to session_id for routing notifications thread_sessions: std::sync::Mutex<HashMap<String, String>>, } @@ -922,8 +1064,10 @@ impl CodexServer { Self { stdin_sender, pending_requests: std::sync::Mutex::new(HashMap::new()), + request_sessions: std::sync::Mutex::new(HashMap::new()), next_id: AtomicI64::new(1), initialized: std::sync::Mutex::new(false), + initialize_lock: Mutex::new(()), thread_sessions: std::sync::Mutex::new(HashMap::new()), } } @@ -932,14 +1076,37 @@ impl CodexServer { self.next_id.fetch_add(1, Ordering::SeqCst) } - fn send_request(&self, id: i64, request: &impl Serialize) -> Option<oneshot::Receiver<Value>> { + fn send_request( + &self, + id: i64, + request: &impl Serialize, + ) -> Option<oneshot::Receiver<CodexRequestResult>> { + self.send_request_with_session(id, request, None) + } + + fn send_request_with_session( + &self, + id: i64, + request: &impl Serialize, + session_id: Option<String>, + ) -> Option<oneshot::Receiver<CodexRequestResult>> { let (tx, rx) = oneshot::channel(); { let mut pending = self.pending_requests.lock().unwrap(); pending.insert(id, tx); } + if let Some(session_id) = session_id { + let mut sessions = self.request_sessions.lock().unwrap(); + sessions.insert(id, session_id); + } let line = serde_json::to_string(request).ok()?; - self.stdin_sender.send(line).ok()?; + if self.stdin_sender.send(line).is_err() { + let mut pending = self.pending_requests.lock().unwrap(); + pending.remove(&id); + let mut sessions = self.request_sessions.lock().unwrap(); + sessions.remove(&id); + return None; + } Some(rx) } @@ -950,7 +1117,7 @@ impl CodexServer { self.stdin_sender.send(line).is_ok() } - fn complete_request(&self, id: i64, result: Value) { + fn complete_request(&self, id: i64, result: CodexRequestResult) { let tx = { let mut pending = self.pending_requests.lock().unwrap(); pending.remove(&id) @@ -960,6 +1127,11 @@ impl CodexServer { } } + fn take_request_session(&self, id: i64) -> Option<String> { + let mut sessions = self.request_sessions.lock().unwrap(); + sessions.remove(&id) + } + fn register_thread(&self, thread_id: String, session_id: String) { let mut sessions = self.thread_sessions.lock().unwrap(); sessions.insert(thread_id, session_id); @@ -981,6 +1153,8 @@ impl CodexServer { fn clear_pending(&self) { let mut pending = self.pending_requests.lock().unwrap(); pending.clear(); + let mut sessions = self.request_sessions.lock().unwrap(); + sessions.clear(); } fn clear_threads(&self) { @@ -1092,6 +1266,12 @@ struct PiSessionBootstrap { _session_file: Option<String>, } +#[derive(Debug, Clone)] +enum CodexRequestResult { + Response(Value), + Error(codex_schema::JsonrpcErrorError), +} + pub(crate) struct SessionSubscription { pub(crate) initial_events: Vec<UniversalEvent>, pub(crate) receiver: broadcast::Receiver<UniversalEvent>, @@ -1717,6 +1897,7 @@ impl SessionManager { sessions: Mutex::new(Vec::new()), server_manager, http_client, + model_catalog: Mutex::new(ModelCatalogState::default()), } } @@ -1735,6 +1916,33 @@ impl SessionManager { .find(|session| session.session_id == session_id) } + async fn session_working_dir(&self, session_id: &str) -> Result<PathBuf, SandboxError> { + let sessions = self.sessions.lock().await; + let session = Self::session_ref(&sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + Ok(session.working_dir.clone()) + } + + pub(crate) async fn set_session_overrides( + &self, + session_id: &str, + model: Option<String>, + variant: Option<String>, + ) -> Result<(), SandboxError> { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + session.model = model; + session.variant = variant; + Ok(()) + } + /// Read agent stderr for error diagnostics fn read_agent_stderr(&self, agent: AgentId) -> Option<StderrOutput> { let logs = AgentServerLogs::new(self.server_manager.log_base_dir.clone(), agent.as_str()); @@ -1792,6 +2000,40 @@ impl SessionManager { install_result.map_err(|err| map_install_error(agent_id, err))?; } + let skill_dirs = if let Some(skills) = &request.skills { + let sources = skills.sources.clone(); + Some( + tokio::task::spawn_blocking(move || install_skill_sources(&sources)) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })??, + ) + } else { + None + }; + + if let Some(mcp) = &request.mcp { + self.apply_mcp_config(agent_id, mcp).await?; + } + + if agent_id == AgentId::Opencode { + if let Some(skill_dirs) = skill_dirs.as_ref() { + self.apply_opencode_skills(skill_dirs).await?; + } + } + + // Resolve default model if none was explicitly provided + let request = if request.model.is_none() { + let mut request = request; + if let Ok(models_response) = self.agent_models(agent_id).await { + request.model = models_response.default_model; + } + request + } else { + request + }; + let mut session = SessionState::new(session_id.clone(), agent_id, &request)?; if agent_id == AgentId::Opencode { let opencode_session_id = self.create_opencode_session().await?; @@ -1830,9 +2072,6 @@ impl SessionManager { session.native_session_id = Some(format!("mock-{session_id}")); } - let telemetry_agent = request.agent.clone(); - let telemetry_model = request.model.clone(); - let telemetry_variant = request.variant.clone(); let metadata = json!({ "agent": request.agent, "agentMode": session.agent_mode, @@ -1862,8 +2101,6 @@ impl SessionManager { } let native_session_id = session.native_session_id.clone(); - let telemetry_agent_mode = session.agent_mode.clone(); - let telemetry_permission_mode = session.permission_mode.clone(); let mut sessions = self.sessions.lock().await; sessions.push(session); drop(sessions); @@ -1877,14 +2114,6 @@ impl SessionManager { self.ensure_opencode_stream(session_id).await?; } - telemetry::log_session_created(telemetry::SessionConfig { - agent: telemetry_agent, - agent_mode: Some(telemetry_agent_mode), - permission_mode: Some(telemetry_permission_mode), - model: telemetry_model, - variant: telemetry_variant, - }); - Ok(CreateSessionResponse { healthy: true, error: None, @@ -1892,11 +2121,102 @@ impl SessionManager { }) } - pub(crate) async fn set_session_overrides( + async fn apply_mcp_config( + self: &Arc<Self>, + agent_id: AgentId, + mcp: &BTreeMap<String, McpServerConfig>, + ) -> Result<(), SandboxError> { + if mcp.is_empty() { + return Ok(()); + } + match agent_id { + AgentId::Claude => { + let mcp = mcp.clone(); + tokio::task::spawn_blocking(move || write_claude_mcp_config(&mcp)) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })??; + Ok(()) + } + AgentId::Codex => { + let mcp = mcp.clone(); + tokio::task::spawn_blocking(move || write_codex_mcp_config(&mcp)) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })??; + let server = self.ensure_codex_server().await?; + self.reload_codex_mcp(&server).await + } + AgentId::Opencode => self.apply_opencode_mcp(mcp).await, + AgentId::Amp => { + let agent_manager = self.agent_manager.clone(); + let mcp = mcp.clone(); + tokio::task::spawn_blocking(move || apply_amp_mcp_config(&agent_manager, &mcp)) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })??; + Ok(()) + } + AgentId::Pi => Ok(()), + AgentId::Cursor => Ok(()), + AgentId::Mock => Ok(()), + } + } + + async fn apply_opencode_skills(&self, skill_dirs: &[PathBuf]) -> Result<(), SandboxError> { + if skill_dirs.is_empty() { + return Ok(()); + } + let base_url = self.ensure_opencode_server().await?; + let url = format!("{base_url}/config"); + let response = self.http_client.get(&url).send().await; + let mut existing_paths = Vec::<String>::new(); + if let Ok(response) = response { + if response.status().is_success() { + if let Ok(value) = response.json::<Value>().await { + if let Some(paths) = value + .get("skills") + .and_then(|skills| skills.get("paths")) + .and_then(Value::as_array) + { + existing_paths.extend( + paths + .iter() + .filter_map(Value::as_str) + .map(|path| path.to_string()), + ); + } + } + } + } + let mut merged = existing_paths; + for dir in skill_dirs { + let path = dir.to_string_lossy().to_string(); + if !merged.contains(&path) { + merged.push(path); + } + } + let body = json!({ "skills": { "paths": merged } }); + let response = self.http_client.patch(&url).json(&body).send().await; + let response = response.map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + if response.status().is_success() { + Ok(()) + } else { + Err(SandboxError::StreamError { + message: format!("OpenCode config update failed: {}", response.status()), + }) + } + } + + pub(crate) async fn set_session_title( &self, session_id: &str, - model: Option<String>, - variant: Option<String>, + title: String, ) -> Result<(), SandboxError> { let mut sessions = self.sessions.lock().await; let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else { @@ -1904,12 +2224,101 @@ impl SessionManager { session_id: session_id.to_string(), }); }; - if let Some(model) = model { - session.model = Some(model); + session.title = Some(title); + session.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(session.updated_at); + Ok(()) + } + + async fn clear_codex_session_model_if_unavailable( + &self, + session_id: &str, + model_id: &str, + ) -> bool { + let mut sessions = self.sessions.lock().await; + let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else { + return false; + }; + if session.agent == AgentId::Codex && session.model.as_deref() == Some(model_id) { + session.model = None; + session.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(session.updated_at); + return true; } - if let Some(variant) = variant { - session.variant = Some(variant); + false + } + + async fn invalidate_codex_model_cache(&self) { + let mut catalog = self.model_catalog.lock().await; + catalog.models.remove(&AgentId::Codex); + } + + async fn codex_native_session_id(&self, session_id: &str) -> Option<String> { + let sessions = self.sessions.lock().await; + let session = SessionManager::session_ref(&sessions, session_id)?; + if session.agent != AgentId::Codex { + return None; } + session.native_session_id.clone() + } + + async fn handle_codex_model_unavailable( + &self, + session_id: &str, + model_id: &str, + native_session_id: Option<String>, + ) { + tracing::warn!( + model_id = %model_id, + "codex model rejected at runtime; clearing session model and refreshing model cache" + ); + self.invalidate_codex_model_cache().await; + if !self + .clear_codex_session_model_if_unavailable(session_id, model_id) + .await + { + return; + } + let native_session_id = match native_session_id { + Some(native_session_id) => Some(native_session_id), + None => self.codex_native_session_id(session_id).await, + }; + let _ = self + .record_conversions( + session_id, + vec![codex_model_unavailable_status_event( + native_session_id, + model_id, + )], + ) + .await; + } + + pub(crate) async fn delete_session(&self, session_id: &str) -> Result<(), SandboxError> { + let (agent, native_session_id) = { + let mut sessions = self.sessions.lock().await; + let Some(index) = sessions + .iter() + .position(|session| session.session_id == session_id) + else { + return Err(SandboxError::SessionNotFound { + session_id: session_id.to_string(), + }); + }; + let session = sessions.remove(index); + (session.agent, session.native_session_id) + }; + + if agent == AgentId::Opencode || agent == AgentId::Codex { + self.server_manager + .unregister_session(agent, session_id, native_session_id.as_deref()) + .await; + } + Ok(()) } @@ -1934,9 +2343,55 @@ impl SessionManager { pub(crate) async fn agent_models( self: &Arc<Self>, agent: AgentId, + ) -> Result<AgentModelsResponse, SandboxError> { + enum Acquisition { + Hit(AgentModelsResponse), + Wait(OwnedNotified), + Build(Arc<Notify>), + } + + loop { + let acquisition = { + let mut catalog = self.model_catalog.lock().await; + if let Some(response) = catalog.models.get(&agent) { + Acquisition::Hit(response.clone()) + } else if let Some(notify) = catalog.in_flight.get(&agent) { + Acquisition::Wait(notify.clone().notified_owned()) + } else { + let notify = Arc::new(Notify::new()); + catalog.in_flight.insert(agent, notify.clone()); + Acquisition::Build(notify) + } + }; + + match acquisition { + Acquisition::Hit(response) => return Ok(response), + Acquisition::Wait(waiting) => waiting.await, + Acquisition::Build(notify) => { + let response = self.fetch_agent_models_uncached(agent).await; + let mut catalog = self.model_catalog.lock().await; + catalog.in_flight.remove(&agent); + if let Ok(response_value) = &response { + if should_cache_agent_models(agent, response_value) { + catalog.models.insert(agent, response_value.clone()); + } + } + notify.notify_waiters(); + return response; + } + } + } + } + + async fn fetch_agent_models_uncached( + self: &Arc<Self>, + agent: AgentId, ) -> Result<AgentModelsResponse, SandboxError> { match agent { - AgentId::Claude => self.fetch_claude_models().await, + AgentId::Claude => match self.fetch_claude_models().await { + Ok(response) if !response.models.is_empty() => Ok(response), + _ => Ok(claude_fallback_models()), + }, AgentId::Codex => self.fetch_codex_models().await, AgentId::Opencode => match self.fetch_opencode_models().await { Ok(models) => Ok(models), @@ -1953,6 +2408,10 @@ impl SessionManager { default_model: None, }), }, + AgentId::Cursor => Ok(AgentModelsResponse { + models: Vec::new(), + default_model: None, + }), AgentId::Mock => Ok(mock_models_response()), } } @@ -1961,11 +2420,26 @@ impl SessionManager { self: &Arc<Self>, session_id: String, message: String, + attachments: Vec<MessageAttachment>, ) -> Result<(), SandboxError> { // Use allow_ended=true and do explicit check to allow resumable agents let session_snapshot = self.session_snapshot_for_message(&session_id).await?; + let prompt_with_attachments = format_message_with_attachments(&message, &attachments); + let prompt = if session_snapshot.agent == AgentId::Opencode { + message.clone() + } else { + prompt_with_attachments + }; + if !agent_emits_turn_started(session_snapshot.agent) { + let _ = self + .record_conversions( + &session_id, + vec![turn_started_event(None, None).synthetic()], + ) + .await; + } if session_snapshot.agent == AgentId::Mock { - self.send_mock_message(session_id, message).await?; + self.send_mock_message(session_id, prompt).await?; return Ok(()); } if matches!( @@ -1973,12 +2447,12 @@ impl SessionManager { AgentId::Claude | AgentId::Amp | AgentId::Pi ) { let _ = self - .record_conversions(&session_id, user_message_conversions(&message)) + .record_conversions(&session_id, user_message_conversions(&prompt)) .await; } if session_snapshot.agent == AgentId::Opencode { self.ensure_opencode_stream(session_id.clone()).await?; - self.send_opencode_prompt(&session_snapshot, &message) + self.send_opencode_prompt(&session_snapshot, &prompt, &attachments) .await?; if !agent_supports_item_started(session_snapshot.agent) { let _ = self @@ -1989,7 +2463,7 @@ impl SessionManager { } if session_snapshot.agent == AgentId::Codex { // Use the shared Codex app-server - self.send_codex_turn(&session_snapshot, &message).await?; + self.send_codex_turn(&session_snapshot, &prompt).await?; if !agent_supports_item_started(session_snapshot.agent) { let _ = self .emit_synthetic_assistant_start(&session_snapshot.session_id) @@ -2013,7 +2487,6 @@ impl SessionManager { self.reopen_session_if_ended(&session_id).await; let manager = self.agent_manager.clone(); - let prompt = message; let initial_input = if session_snapshot.agent == AgentId::Claude { Some(claude_user_message_line(&session_snapshot, &prompt)) } else { @@ -2171,25 +2644,40 @@ impl SessionManager { Ok(EventsResponse { events, has_more }) } - async fn list_sessions(&self) -> Vec<SessionInfo> { + pub(crate) async fn list_sessions(&self) -> Vec<SessionInfo> { let sessions = self.sessions.lock().await; sessions .iter() .rev() - .map(|state| 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(), - variant: state.variant.clone(), - native_session_id: state.native_session_id.clone(), - ended: state.ended, - event_count: state.events.len() as u64, - }) + .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(), + variant: state.variant.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(), + } + } + pub(crate) async fn subscribe( &self, session_id: &str, @@ -2280,7 +2768,7 @@ impl SessionManager { question_id: &str, answers: Vec<Vec<String>>, ) -> Result<(), SandboxError> { - let (agent, native_session_id, pending_question, claude_sender) = { + let (agent, native_session_id, pending_question, claude_sender, linked_permission) = { let mut sessions = self.sessions.lock().await; let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { SandboxError::SessionNotFound { @@ -2296,11 +2784,18 @@ impl SessionManager { if let Some(err) = session.ended_error() { return Err(err); } + // For Claude, check if there's a linked AskUserQuestion/ExitPlanMode permission + let linked_perm = if session.agent == AgentId::Claude { + session.take_question_tool_permission() + } else { + None + }; ( session.agent, session.native_session_id.clone(), pending, session.claude_sender(), + linked_perm, ) }; @@ -2313,28 +2808,66 @@ impl SessionManager { .ok_or_else(|| SandboxError::InvalidRequest { message: "missing OpenCode session id".to_string(), })?; - self.opencode_question_reply(&agent_session_id, question_id, answers) + self.opencode_question_reply(&agent_session_id, question_id, answers.clone()) .await?; } else if agent == AgentId::Claude { let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { message: "Claude session is not active".to_string(), })?; - let session_id = native_session_id - .clone() - .unwrap_or_else(|| session_id.to_string()); - let response_text = response.clone().unwrap_or_default(); - let line = claude_tool_result_line(&session_id, question_id, &response_text, false); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; + if let Some((perm_id, perm)) = &linked_permission { + // Use the permission control response to deliver the answer. + // Build updatedInput from the original input with the answers map added. + let original_input = perm + .metadata + .as_ref() + .and_then(|m| m.get("input")) + .cloned() + .unwrap_or(Value::Null); + let mut updated = match original_input { + Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + // Build answers map: { "0": "selected option", "1": "another option", ... } + let answers_map: serde_json::Map<String, Value> = answers + .iter() + .enumerate() + .filter_map(|(i, inner)| { + inner + .first() + .map(|v| (i.to_string(), Value::String(v.clone()))) + }) + .collect(); + updated.insert("answers".to_string(), Value::Object(answers_map)); + + let mut response_map = serde_json::Map::new(); + response_map.insert("updatedInput".to_string(), Value::Object(updated)); + let line = + claude_control_response_line(perm_id, "allow", Value::Object(response_map)); + sender + .send(line) + .map_err(|_| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + })?; + } else { + // No linked permission — fall back to tool_result + let native_sid = native_session_id + .clone() + .unwrap_or_else(|| session_id.to_string()); + let response_text = response.clone().unwrap_or_default(); + let line = claude_tool_result_line(&native_sid, question_id, &response_text, false); + sender + .send(line) + .map_err(|_| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + })?; + } } else { // TODO: Forward question replies to subprocess agents. } + // Emit QuestionResolved if let Some(pending) = pending_question { - let resolved = EventConversion::new( + let mut conversions = vec![EventConversion::new( UniversalEventType::QuestionResolved, UniversalEventData::Question(QuestionEventData { question_id: question_id.to_string(), @@ -2345,8 +2878,26 @@ impl SessionManager { }), ) .synthetic() - .with_native_session(native_session_id); - let _ = self.record_conversions(session_id, vec![resolved]).await; + .with_native_session(native_session_id.clone())]; + + // Also emit PermissionResolved for the linked permission + if let Some((perm_id, perm)) = linked_permission { + conversions.push( + EventConversion::new( + UniversalEventType::PermissionResolved, + UniversalEventData::Permission(PermissionEventData { + permission_id: perm_id, + action: perm.action, + status: PermissionStatus::Accept, + metadata: perm.metadata, + }), + ) + .synthetic() + .with_native_session(native_session_id), + ); + } + + let _ = self.record_conversions(session_id, conversions).await; } Ok(()) @@ -2357,7 +2908,7 @@ impl SessionManager { session_id: &str, question_id: &str, ) -> Result<(), SandboxError> { - let (agent, native_session_id, pending_question, claude_sender) = { + let (agent, native_session_id, pending_question, claude_sender, linked_permission) = { let mut sessions = self.sessions.lock().await; let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { SandboxError::SessionNotFound { @@ -2373,11 +2924,17 @@ impl SessionManager { if let Some(err) = session.ended_error() { return Err(err); } + let linked_perm = if session.agent == AgentId::Claude { + session.take_question_tool_permission() + } else { + None + }; ( session.agent, session.native_session_id.clone(), pending, session.claude_sender(), + linked_perm, ) }; @@ -2394,26 +2951,43 @@ impl SessionManager { let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { message: "Claude session is not active".to_string(), })?; - let session_id = native_session_id - .clone() - .unwrap_or_else(|| session_id.to_string()); - let line = claude_tool_result_line( - &session_id, - question_id, - "User rejected the question.", - true, - ); - sender - .send(line) - .map_err(|_| SandboxError::InvalidRequest { - message: "Claude session is not active".to_string(), - })?; + if let Some((perm_id, _)) = &linked_permission { + // Deny via the permission control response + let mut response_map = serde_json::Map::new(); + response_map.insert( + "message".to_string(), + Value::String("Permission denied.".to_string()), + ); + let line = + claude_control_response_line(perm_id, "deny", Value::Object(response_map)); + sender + .send(line) + .map_err(|_| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + })?; + } else { + let native_sid = native_session_id + .clone() + .unwrap_or_else(|| session_id.to_string()); + let line = claude_tool_result_line( + &native_sid, + question_id, + "User rejected the question.", + true, + ); + sender + .send(line) + .map_err(|_| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + })?; + } } else { // TODO: Forward question rejections to subprocess agents. } + // Emit QuestionResolved if let Some(pending) = pending_question { - let resolved = EventConversion::new( + let mut conversions = vec![EventConversion::new( UniversalEventType::QuestionResolved, UniversalEventData::Question(QuestionEventData { question_id: question_id.to_string(), @@ -2424,8 +2998,26 @@ impl SessionManager { }), ) .synthetic() - .with_native_session(native_session_id); - let _ = self.record_conversions(session_id, vec![resolved]).await; + .with_native_session(native_session_id.clone())]; + + // Also emit PermissionResolved for the linked permission + if let Some((perm_id, perm)) = linked_permission { + conversions.push( + EventConversion::new( + UniversalEventType::PermissionResolved, + UniversalEventData::Permission(PermissionEventData { + permission_id: perm_id, + action: perm.action, + status: PermissionStatus::Reject, + metadata: perm.metadata, + }), + ) + .synthetic() + .with_native_session(native_session_id), + ); + } + + let _ = self.record_conversions(session_id, conversions).await; } Ok(()) @@ -2451,6 +3043,12 @@ impl SessionManager { message: format!("unknown permission id: {permission_id}"), }); } + if matches!(reply_for_status, PermissionReply::Always) { + if let Some(pending) = pending.as_ref() { + session + .remember_permission_allow_for_session(&pending.action, &pending.metadata); + } + } if let Some(err) = session.ended_error() { return Err(err); } @@ -2471,46 +3069,7 @@ impl SessionManager { .ok_or_else(|| SandboxError::InvalidRequest { message: "missing codex permission metadata".to_string(), })?; - let metadata = pending.metadata.clone().unwrap_or(Value::Null); - let request_id = codex_request_id_from_metadata(&metadata) - .or_else(|| codex_request_id_from_string(permission_id)) - .ok_or_else(|| SandboxError::InvalidRequest { - message: "invalid codex permission request id".to_string(), - })?; - let request_kind = metadata - .get("codexRequestKind") - .and_then(Value::as_str) - .unwrap_or(""); - let response_value = match request_kind { - "commandExecution" => { - let decision = codex_command_decision_for_reply(reply.clone()); - let response = - codex_schema::CommandExecutionRequestApprovalResponse { decision }; - serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest { - message: err.to_string(), - })? - } - "fileChange" => { - let decision = codex_file_change_decision_for_reply(reply.clone()); - let response = codex_schema::FileChangeRequestApprovalResponse { decision }; - serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest { - message: err.to_string(), - })? - } - _ => { - return Err(SandboxError::InvalidRequest { - message: "unsupported codex permission request".to_string(), - }); - } - }; - let response = codex_schema::JsonrpcResponse { - id: request_id, - result: response_value, - }; - let line = - serde_json::to_string(&response).map_err(|err| SandboxError::InvalidRequest { - message: err.to_string(), - })?; + let line = codex_permission_response_line(permission_id, &pending, reply.clone())?; server .stdin_sender .send(line) @@ -2572,8 +3131,9 @@ impl SessionManager { if let Some(pending) = pending_permission { let status = match reply_for_status { - PermissionReply::Reject => PermissionStatus::Denied, - PermissionReply::Once | PermissionReply::Always => PermissionStatus::Approved, + PermissionReply::Reject => PermissionStatus::Reject, + PermissionReply::Once => PermissionStatus::Accept, + PermissionReply::Always => PermissionStatus::AcceptForSession, }; let resolved = EventConversion::new( UniversalEventType::PermissionResolved, @@ -2872,13 +3432,193 @@ impl SessionManager { session_id: &str, conversions: Vec<EventConversion>, ) -> Result<Vec<UniversalEvent>, SandboxError> { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), + let (events, auto_approvals) = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let mut accept_edits_permission_ids = Vec::new(); + if session.agent == AgentId::Codex && session.permission_mode == "acceptEdits" { + for conversion in &conversions { + if conversion.event_type != UniversalEventType::PermissionRequested { + continue; + } + let UniversalEventData::Permission(data) = &conversion.data else { + continue; + }; + if is_file_change_action(&data.action) { + accept_edits_permission_ids.push(data.permission_id.clone()); + } + } } - })?; - Ok(session.record_conversions(conversions)) + let events = session.record_conversions(conversions); + let mut auto_approvals = Vec::new(); + let mut seen = HashSet::new(); + for event in &events { + if event.event_type != UniversalEventType::PermissionRequested { + continue; + } + let UniversalEventData::Permission(data) = &event.data else { + continue; + }; + let cached = session.should_auto_approve_permission(&data.action, &data.metadata); + if is_question_tool_action(&data.action) || !cached { + continue; + } + if let Some(pending) = session.take_permission(&data.permission_id) { + auto_approvals.push(( + session.agent, + session.native_session_id.clone(), + session.claude_sender(), + data.permission_id.clone(), + pending, + PermissionReply::Always, + )); + seen.insert(data.permission_id.clone()); + } + } + for permission_id in accept_edits_permission_ids { + if seen.contains(&permission_id) { + continue; + } + if let Some(pending) = session.take_permission(&permission_id) { + auto_approvals.push(( + session.agent, + session.native_session_id.clone(), + session.claude_sender(), + permission_id.clone(), + pending, + PermissionReply::Always, + )); + seen.insert(permission_id); + } + } + (events, auto_approvals) + }; + + for (agent, native_session_id, claude_sender, permission_id, pending, reply) in + auto_approvals + { + let reply_for_status = reply.clone(); + let reply_result = match agent { + AgentId::Codex => { + let (server, _) = self + .server_manager + .ensure_stdio_server(AgentId::Codex) + .await?; + let line = + codex_permission_response_line(&permission_id, &pending, reply.clone())?; + server + .stdin_sender + .send(line) + .map_err(|_| SandboxError::InvalidRequest { + message: "codex server not active".to_string(), + }) + } + AgentId::Opencode => { + let agent_session_id = + native_session_id + .clone() + .ok_or_else(|| SandboxError::InvalidRequest { + message: "missing OpenCode session id".to_string(), + }); + match agent_session_id { + Ok(agent_session_id) => { + self.opencode_permission_reply( + &agent_session_id, + &permission_id, + reply.clone(), + ) + .await + } + Err(err) => Err(err), + } + } + AgentId::Claude => { + let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + }); + match sender { + Ok(sender) => { + let metadata = pending.metadata.as_ref().and_then(Value::as_object); + let updated_input = metadata + .and_then(|map| map.get("input")) + .cloned() + .unwrap_or(Value::Null); + let mut response_map = serde_json::Map::new(); + match reply.clone() { + PermissionReply::Reject => { + response_map.insert( + "message".to_string(), + Value::String("Permission denied.".to_string()), + ); + } + PermissionReply::Once | PermissionReply::Always => { + if !updated_input.is_null() { + response_map + .insert("updatedInput".to_string(), updated_input); + } + } + } + let behavior = match reply.clone() { + PermissionReply::Reject => "deny", + PermissionReply::Once | PermissionReply::Always => "allow", + }; + let line = claude_control_response_line( + &permission_id, + behavior, + Value::Object(response_map), + ); + sender.send(line).map_err(|_| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + }) + } + Err(err) => Err(err), + } + } + _ => Ok(()), + }; + + if let Err(err) = reply_result { + tracing::warn!( + session_id, + permission_id, + ?err, + "failed to auto-approve cached permission" + ); + let mut sessions = self.sessions.lock().await; + if let Some(session) = Self::session_mut(&mut sessions, session_id) { + session + .pending_permissions + .insert(permission_id.clone(), pending.clone()); + } + continue; + } + + let resolved = EventConversion::new( + UniversalEventType::PermissionResolved, + UniversalEventData::Permission(PermissionEventData { + permission_id: permission_id.clone(), + action: pending.action, + status: match reply_for_status { + PermissionReply::Reject => PermissionStatus::Reject, + PermissionReply::Once => PermissionStatus::Accept, + PermissionReply::Always => PermissionStatus::AcceptForSession, + }, + metadata: pending.metadata, + }), + ) + .synthetic() + .with_native_session(native_session_id); + let mut sessions = self.sessions.lock().await; + if let Some(session) = Self::session_mut(&mut sessions, session_id) { + session.record_conversions(vec![resolved]); + } + } + + Ok(events) } async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> { @@ -3085,7 +3825,7 @@ impl SessionManager { } }; - let url = format!("{base_url}/event/subscribe"); + let url = format!("{base_url}/event"); let response = match self.http_client.get(url).send().await { Ok(response) => response, Err(err) => { @@ -3176,12 +3916,91 @@ impl SessionManager { if !opencode_event_matches_session(&value, &native_session_id) { continue; } - let conversions = match serde_json::from_value(value.clone()) { - Ok(event) => match convert_opencode::event_to_universal(&event) { - Ok(conversions) => conversions, - Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], - }, - Err(err) => vec![agent_unparsed("opencode", &err.to_string(), value.clone())], + // Manual type-based dispatch to bypass broken #[serde(untagged)] + // enum ordering where ServerConnected (variant #5, empty properties) + // matches all events before MessageUpdated (variant #10) gets tried. + let event_type = value.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let conversions = match event_type { + "message.updated" => { + match serde_json::from_value::<opencode_schema::EventMessageUpdated>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::MessageUpdated(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("message.updated: {}", err), value.clone())], + } + } + "message.part.updated" => { + match serde_json::from_value::<opencode_schema::EventMessagePartUpdated>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::MessagePartUpdated(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("message.part.updated: {}", err), value.clone())], + } + } + "question.asked" => { + match serde_json::from_value::<opencode_schema::EventQuestionAsked>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::QuestionAsked(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("question.asked: {}", err), value.clone())], + } + } + "permission.asked" => { + match serde_json::from_value::<opencode_schema::EventPermissionAsked>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::PermissionAsked(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("permission.asked: {}", err), value.clone())], + } + } + "session.created" => { + match serde_json::from_value::<opencode_schema::EventSessionCreated>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionCreated(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("session.created: {}", err), value.clone())], + } + } + "session.status" => { + match serde_json::from_value::<opencode_schema::EventSessionStatus>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionStatus(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("session.status: {}", err), value.clone())], + } + } + "session.idle" => { + match serde_json::from_value::<opencode_schema::EventSessionIdle>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionIdle(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("session.idle: {}", err), value.clone())], + } + } + "session.error" => { + match serde_json::from_value::<opencode_schema::EventSessionError>(value.clone()) { + Ok(e) => match convert_opencode::event_to_universal(&opencode_schema::Event::SessionError(e)) { + Ok(c) => c, + Err(err) => vec![agent_unparsed("opencode", &err, value.clone())], + }, + Err(err) => vec![agent_unparsed("opencode", &format!("session.error: {}", err), value.clone())], + } + } + // Informational events we can safely skip + "server.connected" | "server.heartbeat" | "session.updated" + | "session.diff" | "file.watcher.updated" => { + continue; + } + _ => { + vec![agent_unparsed("opencode", &format!("unknown event type: {}", event_type), value.clone())] + } }; let _ = self.record_conversions(&session_id, conversions).await; } @@ -3245,7 +4064,8 @@ impl SessionManager { codex_schema::JsonrpcMessage::Response(response) => { // Route response to waiting request if let Some(id) = codex_request_id_to_i64(&response.id) { - server.complete_request(id, response.result.clone()); + server.take_request_session(id); + server.complete_request(id, CodexRequestResult::Response(response.result)); } } codex_schema::JsonrpcMessage::Notification(_) => { @@ -3257,6 +4077,20 @@ impl SessionManager { codex_thread_id_from_server_notification(¬ification) { if let Some(session_id) = server.session_for_thread(&thread_id) { + if let codex_schema::ServerNotification::Error(params) = + ¬ification + { + if let Some(model_id) = + codex_unavailable_model_from_message(¶ms.error.message) + { + self.handle_codex_model_unavailable( + &session_id, + &model_id, + Some(thread_id.clone()), + ) + .await; + } + } let conversions = match convert_codex::notification_to_universal(¬ification) { Ok(c) => c, @@ -3298,8 +4132,28 @@ impl SessionManager { } } codex_schema::JsonrpcMessage::Error(error) => { - // Log error but don't have a session to route to - eprintln!("Codex server error: {:?}", error); + if let Some(id) = codex_request_id_to_i64(&error.id) { + let session_id = server.take_request_session(id); + server.complete_request(id, CodexRequestResult::Error(error.error.clone())); + if let Some(session_id) = session_id { + if let Some(model_id) = + codex_unavailable_model_from_rpc_error(&error.error) + { + self.handle_codex_model_unavailable(&session_id, &model_id, None) + .await; + } + let _ = self + .record_conversions( + &session_id, + vec![codex_rpc_error_to_universal(&error)], + ) + .await; + } else { + eprintln!("Codex server error: {:?}", error); + } + } else { + eprintln!("Codex server error: {:?}", error); + } } } } @@ -3307,6 +4161,7 @@ impl SessionManager { /// Performs the initialize/initialized handshake with the Codex server. async fn codex_server_initialize(&self, server: &CodexServer) -> Result<(), SandboxError> { + let _initialize_guard = server.initialize_lock.lock().await; if server.is_initialized() { return Ok(()); } @@ -3332,7 +4187,7 @@ impl SessionManager { // Wait for initialize response with timeout let result = tokio::time::timeout(Duration::from_secs(30), rx).await; match result { - Ok(Ok(_)) => { + Ok(Ok(CodexRequestResult::Response(_))) => { // Send initialized notification let notification = codex_schema::JsonrpcNotification { method: "initialized".to_string(), @@ -3342,6 +4197,10 @@ impl SessionManager { server.set_initialized(); Ok(()) } + Ok(Ok(CodexRequestResult::Error(error))) => Err(codex_request_error_to_sandbox( + "initialize request failed", + &error, + )), Ok(Err(_)) => Err(SandboxError::StreamError { message: "initialize request cancelled".to_string(), }), @@ -3351,6 +4210,29 @@ impl SessionManager { } } + async fn reload_codex_mcp(&self, server: &CodexServer) -> Result<(), SandboxError> { + let id = server.next_request_id(); + let request = codex_schema::ClientRequest::ConfigMcpServerReload { + id: codex_schema::RequestId::from(id), + params: (), + }; + let rx = server + .send_request(id, &request) + .ok_or_else(|| SandboxError::StreamError { + message: "failed to send config/mcpServer/reload request".to_string(), + })?; + let result = tokio::time::timeout(Duration::from_secs(15), rx).await; + match result { + Ok(Ok(_)) => Ok(()), + Ok(Err(_)) => Err(SandboxError::StreamError { + message: "config/mcpServer/reload request cancelled".to_string(), + }), + Err(_) => Err(SandboxError::StreamError { + message: "config/mcpServer/reload request timed out".to_string(), + }), + } + } + /// Creates a new Codex thread/session via the shared app-server. async fn create_codex_thread( self: &Arc<Self>, @@ -3379,7 +4261,7 @@ impl SessionManager { // Wait for thread/start response let result = tokio::time::timeout(Duration::from_secs(30), rx).await; match result { - Ok(Ok(response)) => { + Ok(Ok(CodexRequestResult::Response(response))) => { // Extract thread_id from response let thread_id = response .get("thread") @@ -3396,6 +4278,10 @@ impl SessionManager { Ok(thread_id) } + Ok(Ok(CodexRequestResult::Error(error))) => Err(codex_request_error_to_sandbox( + "thread/start request failed", + &error, + )), Ok(Err(_)) => Err(SandboxError::StreamError { message: "thread/start request cancelled".to_string(), }), @@ -3427,7 +4313,7 @@ impl SessionManager { approval_policy: codex_approval_policy(Some(&session.permission_mode)), collaboration_mode: None, cwd: None, - effort: codex_effort_from_variant(session.variant.as_deref()), + effort: None, input: vec![codex_schema::UserInput::Text { text: prompt_text, text_elements: Vec::new(), @@ -3446,7 +4332,7 @@ impl SessionManager { // Send but don't wait for response - notifications will stream back server - .send_request(id, &request) + .send_request_with_session(id, &request, Some(session.session_id.clone())) .ok_or_else(|| SandboxError::StreamError { message: "failed to send turn/start request".to_string(), })?; @@ -4012,8 +4898,13 @@ impl SessionManager { } async fn fetch_claude_models(&self) -> Result<AgentModelsResponse, SandboxError> { + let started = Instant::now(); let credentials = self.extract_credentials().await?; let Some(cred) = credentials.anthropic else { + tracing::info!( + elapsed_ms = started.elapsed().as_millis() as u64, + "claude model fetch skipped (no anthropic credentials)" + ); return Ok(AgentModelsResponse { models: Vec::new(), default_model: None, @@ -4036,9 +4927,10 @@ impl SessionManager { if matches!(cred.auth_type, AuthType::Oauth) { tracing::warn!( status = %status, + elapsed_ms = started.elapsed().as_millis() as u64, "Anthropic model list rejected OAuth credentials; using Claude OAuth fallback models" ); - return Ok(claude_oauth_fallback_models()); + return Ok(claude_fallback_models()); } return Err(SandboxError::StreamError { message: format!("Anthropic models request failed {status}: {body}"), @@ -4096,11 +4988,18 @@ impl SessionManager { if models.is_empty() && matches!(cred.auth_type, AuthType::Oauth) { tracing::warn!( + elapsed_ms = started.elapsed().as_millis() as u64, "Anthropic model list was empty for OAuth credentials; using Claude OAuth fallback models" ); - return Ok(claude_oauth_fallback_models()); + return Ok(claude_fallback_models()); } + tracing::info!( + elapsed_ms = started.elapsed().as_millis() as u64, + model_count = models.len(), + has_default = default_model.is_some(), + "claude model fetch completed" + ); Ok(AgentModelsResponse { models, default_model, @@ -4108,14 +5007,21 @@ impl SessionManager { } async fn fetch_codex_models(self: &Arc<Self>) -> Result<AgentModelsResponse, SandboxError> { + let started = Instant::now(); let server = self.ensure_codex_server().await?; + tracing::info!( + elapsed_ms = started.elapsed().as_millis() as u64, + "codex model fetch server ready" + ); let mut models: Vec<AgentModelInfo> = Vec::new(); let mut default_model: Option<String> = None; let mut seen = HashSet::new(); let mut cursor: Option<String> = None; + let mut pages: usize = 0; loop { let id = server.next_request_id(); + let page_started = Instant::now(); let request = json!({ "jsonrpc": "2.0", "id": id, @@ -4132,20 +5038,51 @@ impl SessionManager { message: "failed to send model/list request".to_string(), })?; - let result = tokio::time::timeout(Duration::from_secs(30), rx).await; + let result = + tokio::time::timeout(Duration::from_secs(CODEX_MODEL_LIST_TIMEOUT_SECS), rx).await; let value = match result { - Ok(Ok(value)) => value, + Ok(Ok(CodexRequestResult::Response(value))) => value, + Ok(Ok(CodexRequestResult::Error(error))) => { + tracing::warn!( + elapsed_ms = started.elapsed().as_millis() as u64, + page = pages + 1, + error = %error.message, + "codex model/list request failed" + ); + return Err(codex_request_error_to_sandbox( + "model/list request failed", + &error, + )); + } Ok(Err(_)) => { + tracing::warn!( + elapsed_ms = started.elapsed().as_millis() as u64, + page = pages + 1, + "codex model/list request cancelled" + ); return Err(SandboxError::StreamError { message: "model/list request cancelled".to_string(), - }) + }); } Err(_) => { + tracing::warn!( + elapsed_ms = started.elapsed().as_millis() as u64, + page = pages + 1, + timeout_secs = CODEX_MODEL_LIST_TIMEOUT_SECS, + "codex model/list request timed out" + ); return Err(SandboxError::StreamError { message: "model/list request timed out".to_string(), - }) + }); } }; + pages += 1; + tracing::info!( + page = pages, + elapsed_ms = page_started.elapsed().as_millis() as u64, + total_elapsed_ms = started.elapsed().as_millis() as u64, + "codex model/list page fetched" + ); let data = value .get("data") @@ -4169,31 +5106,6 @@ impl SessionManager { .get("displayName") .and_then(Value::as_str) .map(|value| value.to_string()); - let default_variant = item - .get("defaultReasoningEffort") - .and_then(Value::as_str) - .map(|value| value.to_string()); - let mut variants: Vec<String> = item - .get("supportedReasoningEfforts") - .and_then(Value::as_array) - .map(|values| { - values - .iter() - .filter_map(|value| { - value - .get("reasoningEffort") - .and_then(Value::as_str) - .or_else(|| value.as_str()) - .map(|entry| entry.to_string()) - }) - .collect::<Vec<_>>() - }) - .unwrap_or_default(); - if variants.is_empty() { - variants = codex_variants(); - } - variants.sort(); - variants.dedup(); if default_model.is_none() && item @@ -4207,8 +5119,8 @@ impl SessionManager { models.push(AgentModelInfo { id: model_id.to_string(), name, - variants: Some(variants), - default_variant, + variants: None, + default_variant: None, }); } @@ -4227,6 +5139,13 @@ impl SessionManager { default_model = models.first().map(|model| model.id.clone()); } + tracing::info!( + elapsed_ms = started.elapsed().as_millis() as u64, + page_count = pages, + model_count = models.len(), + has_default = default_model.is_some(), + "codex model fetch completed" + ); Ok(AgentModelsResponse { models, default_model, @@ -4234,18 +5153,36 @@ impl SessionManager { } async fn fetch_opencode_models(&self) -> Result<AgentModelsResponse, SandboxError> { + let started = Instant::now(); let base_url = self.ensure_opencode_server().await?; let endpoints = [ format!("{base_url}/config/providers"), format!("{base_url}/provider"), ]; for url in endpoints { + let endpoint_started = Instant::now(); let response = self.http_client.get(&url).send().await; let response = match response { Ok(response) => response, - Err(_) => continue, + Err(err) => { + tracing::warn!( + url, + elapsed_ms = endpoint_started.elapsed().as_millis() as u64, + total_elapsed_ms = started.elapsed().as_millis() as u64, + ?err, + "opencode model endpoint request failed" + ); + continue; + } }; if !response.status().is_success() { + tracing::warn!( + url, + status = %response.status(), + elapsed_ms = endpoint_started.elapsed().as_millis() as u64, + total_elapsed_ms = started.elapsed().as_millis() as u64, + "opencode model endpoint returned non-success status" + ); continue; } let value: Value = response @@ -4255,9 +5192,27 @@ impl SessionManager { message: err.to_string(), })?; if let Some(models) = parse_opencode_models(&value) { + tracing::info!( + url, + elapsed_ms = endpoint_started.elapsed().as_millis() as u64, + total_elapsed_ms = started.elapsed().as_millis() as u64, + model_count = models.models.len(), + has_default = models.default_model.is_some(), + "opencode model fetch completed" + ); return Ok(models); } + tracing::warn!( + url, + elapsed_ms = endpoint_started.elapsed().as_millis() as u64, + total_elapsed_ms = started.elapsed().as_millis() as u64, + "opencode model endpoint parse returned no models" + ); } + tracing::warn!( + elapsed_ms = started.elapsed().as_millis() as u64, + "opencode model fetch failed" + ); Err(SandboxError::StreamError { message: "OpenCode models unavailable".to_string(), }) @@ -4355,10 +5310,52 @@ impl SessionManager { }) } + async fn apply_opencode_mcp( + &self, + mcp: &BTreeMap<String, McpServerConfig>, + ) -> Result<(), SandboxError> { + if mcp.is_empty() { + return Ok(()); + } + let base_url = self.ensure_opencode_server().await?; + let url = format!("{base_url}/mcp"); + let mut existing = HashSet::new(); + if let Ok(response) = self.http_client.get(&url).send().await { + if response.status().is_success() { + if let Ok(value) = response.json::<Value>().await { + if let Some(map) = value.as_object() { + for key in map.keys() { + existing.insert(key.clone()); + } + } + } + } + } + + for (name, config) in mcp { + if existing.contains(name) { + continue; + } + let config_value = opencode_mcp_config(config)?; + let body = json!({ "name": name, "config": config_value }); + let response = self.http_client.post(&url).json(&body).send().await; + let response = response.map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + if !response.status().is_success() { + return Err(SandboxError::StreamError { + message: format!("OpenCode MCP add failed: {}", response.status()), + }); + } + } + Ok(()) + } + async fn send_opencode_prompt( &self, session: &SessionSnapshot, prompt: &str, + attachments: &[MessageAttachment], ) -> Result<(), SandboxError> { let base_url = self.ensure_opencode_server().await?; let session_id = @@ -4369,9 +5366,13 @@ impl SessionManager { message: "missing OpenCode session id".to_string(), })?; let url = format!("{base_url}/session/{session_id}/prompt"); + let mut parts = vec![json!({ "type": "text", "text": prompt })]; + for attachment in attachments { + parts.push(opencode_file_part_input(attachment)); + } let mut body = json!({ "agent": session.agent_mode.clone(), - "parts": [{ "type": "text", "text": prompt }] + "parts": parts }); if let Some(model) = session.model.as_deref() { if let Some((provider, model_id)) = model.split_once('/') { @@ -4575,12 +5576,6 @@ pub struct AgentModeInfo { pub description: String, } -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct AgentModesResponse { - pub modes: Vec<AgentModeInfo>, -} - #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AgentModelInfo { @@ -4601,6 +5596,12 @@ pub struct AgentModelsResponse { pub default_model: Option<String>, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentModesResponse { + pub modes: Vec<AgentModeInfo>, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AgentCapabilities { @@ -4622,6 +5623,8 @@ pub struct AgentCapabilities { pub mcp_tools: bool, pub streaming_deltas: bool, pub item_started: bool, + /// Whether this agent supports thinking/variant modes + #[serde(default)] pub variants: bool, /// Whether this agent uses a shared long-running server process (vs per-turn subprocess) pub shared_process: bool, @@ -4657,6 +5660,8 @@ pub struct ServerStatusInfo { 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")] @@ -4685,6 +5690,14 @@ pub struct SessionInfo { 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)] @@ -4698,6 +5711,232 @@ pub struct HealthResponse { pub status: String, } +#[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, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SkillsConfig { + pub sources: Vec<SkillSource>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SkillSource { + #[serde(rename = "type")] + pub source_type: String, + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skills: Option<Vec<String>>, + #[serde(default, skip_serializing_if = "Option::is_none", rename = "ref")] + pub git_ref: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subpath: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(untagged)] +pub enum McpCommand { + Command(String), + CommandWithArgs(Vec<String>), +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum McpRemoteTransport { + Http, + Sse, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct McpOAuthConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_secret: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(untagged)] +pub enum McpOAuthConfigOrDisabled { + Config(McpOAuthConfig), + Disabled(bool), +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum McpServerConfig { + #[serde(rename = "local", alias = "stdio")] + Local { + command: McpCommand, + #[serde(default)] + args: Vec<String>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "environment" + )] + env: Option<BTreeMap<String, String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + enabled: Option<bool>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "timeoutMs", + alias = "timeout" + )] + #[schema(rename = "timeoutMs")] + timeout_ms: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option<String>, + }, + #[serde(rename = "remote", alias = "http")] + Remote { + url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + headers: Option<BTreeMap<String, String>>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "bearerTokenEnvVar", + alias = "bearerTokenEnvVar", + alias = "bearer_token_env_var" + )] + #[schema(rename = "bearerTokenEnvVar")] + bearer_token_env_var: Option<String>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "envHeaders", + alias = "envHttpHeaders", + alias = "env_http_headers" + )] + #[schema(rename = "envHeaders")] + env_headers: Option<BTreeMap<String, String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + oauth: Option<McpOAuthConfigOrDisabled>, + #[serde(default, skip_serializing_if = "Option::is_none")] + enabled: Option<bool>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + rename = "timeoutMs", + alias = "timeout" + )] + #[schema(rename = "timeoutMs")] + timeout_ms: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + transport: Option<McpRemoteTransport>, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -4712,6 +5951,13 @@ pub struct CreateSessionRequest { pub variant: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_version: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub directory: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option<String>, + 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)] @@ -4728,6 +5974,18 @@ pub struct CreateSessionResponse { #[serde(rename_all = "camelCase")] pub struct MessageRequest { pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attachments: Vec<MessageAttachment>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct MessageAttachment { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option<String>, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] @@ -4802,13 +6060,16 @@ impl std::str::FromStr for PermissionReply { request_body = AgentInstallRequest, responses( (status = 204, description = "Agent installed"), - (status = 400, body = ProblemDetails), - (status = 404, body = ProblemDetails), - (status = 500, body = ProblemDetails) + (status = 400, description = "Invalid request", body = ProblemDetails), + (status = 404, description = "Agent not found", body = ProblemDetails), + (status = 500, description = "Installation failed", body = ProblemDetails) ), params(("agent" = String, Path, description = "Agent id")), tag = "agents" )] +/// Install Agent +/// +/// Installs or updates a coding agent (e.g. claude, codex, opencode, amp). async fn install_agent( State(state): State<Arc<AppState>>, Path(agent): Path<String>, @@ -4841,12 +6102,15 @@ async fn install_agent( get, path = "/v1/agents/{agent}/modes", responses( - (status = 200, body = AgentModesResponse), - (status = 400, body = ProblemDetails) + (status = 200, description = "Available modes", body = AgentModesResponse), + (status = 400, description = "Invalid request", body = ProblemDetails) ), params(("agent" = String, Path, description = "Agent id")), tag = "agents" )] +/// List Agent Modes +/// +/// Returns the available interaction modes for an agent. async fn get_agent_modes( State(state): State<Arc<AppState>>, Path(agent): Path<String>, @@ -4860,12 +6124,15 @@ async fn get_agent_modes( get, path = "/v1/agents/{agent}/models", responses( - (status = 200, body = AgentModelsResponse), - (status = 400, body = ProblemDetails) + (status = 200, description = "Available models", body = AgentModelsResponse), + (status = 404, description = "Agent not found", body = ProblemDetails) ), params(("agent" = String, Path, description = "Agent id")), tag = "agents" )] +/// List Agent Models +/// +/// Returns the available LLM models for an agent. async fn get_agent_models( State(state): State<Arc<AppState>>, Path(agent): Path<String>, @@ -4875,35 +6142,33 @@ async fn get_agent_models( Ok(Json(models)) } -fn server_info(branding: BrandingMode) -> String { - format!( - "This is a {} server. Available endpoints:\n\ - \x20 - GET / - Server info\n\ - \x20 - GET /v1/health - Health check\n\ - \x20 - GET /ui/ - Inspector UI\n\n\ - See {} for API documentation.", - branding.product_name(), - branding.docs_url(), - ) +const SERVER_INFO: &str = "\ +This is a Sandbox Agent server. Available endpoints:\n\ + - GET / - Server info\n\ + - GET /v1/health - Health check\n\ + - GET /ui/ - Inspector UI\n\n\ +See https://sandboxagent.dev for API documentation."; + +async fn get_root() -> &'static str { + SERVER_INFO } -async fn get_root(State(state): State<Arc<AppState>>) -> String { - server_info(state.branding) -} - -async fn not_found(State(state): State<Arc<AppState>>) -> (StatusCode, String) { +async fn not_found() -> (StatusCode, String) { ( StatusCode::NOT_FOUND, - format!("404 Not Found\n\n{}", server_info(state.branding)), + format!("404 Not Found\n\n{SERVER_INFO}"), ) } #[utoipa::path( get, path = "/v1/health", - responses((status = 200, body = HealthResponse)), + responses((status = 200, description = "Server is healthy", body = HealthResponse)), tag = "meta" )] +/// Health Check +/// +/// Returns the server health status. async fn get_health() -> Json<HealthResponse> { Json(HealthResponse { status: "ok".to_string(), @@ -4913,9 +6178,12 @@ async fn get_health() -> Json<HealthResponse> { #[utoipa::path( get, path = "/v1/agents", - responses((status = 200, body = AgentListResponse)), + responses((status = 200, description = "List of available agents", body = AgentListResponse)), tag = "agents" )] +/// List Agents +/// +/// Returns all available coding agents and their installation status. async fn list_agents( State(state): State<Arc<AppState>>, ) -> Result<Json<AgentListResponse>, ApiError> { @@ -4924,6 +6192,10 @@ async fn list_agents( 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| { @@ -4932,6 +6204,15 @@ async fn list_agents( 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::Pi => true, + AgentId::Cursor => true, + AgentId::Mock => true, + }; + // Add server_status for agents with shared processes let server_status = if capabilities.shared_process { @@ -4951,6 +6232,7 @@ async fn list_agents( AgentInfo { id: agent_id.as_str().to_string(), installed, + credentials_available, version, path: path.map(|path| path.to_string_lossy().to_string()), capabilities, @@ -4970,9 +6252,12 @@ async fn list_agents( #[utoipa::path( get, path = "/v1/sessions", - responses((status = 200, body = SessionListResponse)), + responses((status = 200, description = "List of active sessions", body = SessionListResponse)), tag = "sessions" )] +/// List Sessions +/// +/// Returns all active sessions. async fn list_sessions( State(state): State<Arc<AppState>>, ) -> Result<Json<SessionListResponse>, ApiError> { @@ -4985,13 +6270,16 @@ async fn list_sessions( path = "/v1/sessions/{session_id}", request_body = CreateSessionRequest, responses( - (status = 200, body = CreateSessionResponse), - (status = 400, body = ProblemDetails), - (status = 409, body = ProblemDetails) + (status = 200, description = "Session created", body = CreateSessionResponse), + (status = 400, description = "Invalid request", body = ProblemDetails), + (status = 409, description = "Session already exists", body = ProblemDetails) ), params(("session_id" = String, Path, description = "Client session id")), tag = "sessions" )] +/// Create Session +/// +/// Creates a new agent session with the given configuration. async fn create_session( State(state): State<Arc<AppState>>, Path(session_id): Path<String>, @@ -5010,11 +6298,14 @@ async fn create_session( request_body = MessageRequest, responses( (status = 204, description = "Message accepted"), - (status = 404, body = ProblemDetails) + (status = 404, description = "Session not found", body = ProblemDetails) ), params(("session_id" = String, Path, description = "Session id")), tag = "sessions" )] +/// Send Message +/// +/// Sends a message to a session and returns immediately. async fn post_message( State(state): State<Arc<AppState>>, Path(session_id): Path<String>, @@ -5022,7 +6313,7 @@ async fn post_message( ) -> Result<StatusCode, ApiError> { state .session_manager - .send_message(session_id, request.message) + .send_message(session_id, request.message, request.attachments) .await?; Ok(StatusCode::NO_CONTENT) } @@ -5037,10 +6328,13 @@ async fn post_message( ), responses( (status = 200, description = "SSE event stream"), - (status = 404, body = ProblemDetails) + (status = 404, description = "Session not found", body = ProblemDetails) ), tag = "sessions" )] +/// Send Message (Streaming) +/// +/// Sends a message and returns an SSE event stream of the agent's response. async fn post_message_stream( State(state): State<Arc<AppState>>, Path(session_id): Path<String>, @@ -5054,7 +6348,7 @@ async fn post_message_stream( .await?; state .session_manager - .send_message(session_id, request.message) + .send_message(session_id, request.message, request.attachments) .await?; let stream = stream_turn_events(subscription, snapshot.agent, include_raw); Ok(Sse::new(stream)) @@ -5066,10 +6360,13 @@ async fn post_message_stream( params(("session_id" = String, Path, description = "Session id")), responses( (status = 204, description = "Session terminated"), - (status = 404, body = ProblemDetails) + (status = 404, description = "Session not found", body = ProblemDetails) ), tag = "sessions" )] +/// Terminate Session +/// +/// Terminates a running session and cleans up resources. async fn terminate_session( State(state): State<Arc<AppState>>, Path(session_id): Path<String>, @@ -5088,11 +6385,14 @@ async fn terminate_session( ("include_raw" = Option<bool>, Query, description = "Include raw provider payloads") ), responses( - (status = 200, body = EventsResponse), - (status = 404, body = ProblemDetails) + (status = 200, description = "Session events", body = EventsResponse), + (status = 404, description = "Session not found", body = ProblemDetails) ), tag = "sessions" )] +/// Get Events +/// +/// Returns session events with optional offset-based pagination. async fn get_events( State(state): State<Arc<AppState>>, Path(session_id): Path<String>, @@ -5122,6 +6422,9 @@ async fn get_events( responses((status = 200, description = "SSE event stream")), tag = "sessions" )] +/// Subscribe to Events (SSE) +/// +/// Opens an SSE stream for real-time session events. async fn get_events_sse( State(state): State<Arc<AppState>>, Path(session_id): Path<String>, @@ -5165,7 +6468,7 @@ async fn get_events_sse( request_body = QuestionReplyRequest, responses( (status = 204, description = "Question answered"), - (status = 404, body = ProblemDetails) + (status = 404, description = "Session or question not found", body = ProblemDetails) ), params( ("session_id" = String, Path, description = "Session id"), @@ -5173,6 +6476,9 @@ async fn get_events_sse( ), tag = "sessions" )] +/// Reply to Question +/// +/// Replies to a human-in-the-loop question from the agent. async fn reply_question( State(state): State<Arc<AppState>>, Path((session_id, question_id)): Path<(String, String)>, @@ -5190,7 +6496,7 @@ async fn reply_question( path = "/v1/sessions/{session_id}/questions/{question_id}/reject", responses( (status = 204, description = "Question rejected"), - (status = 404, body = ProblemDetails) + (status = 404, description = "Session or question not found", body = ProblemDetails) ), params( ("session_id" = String, Path, description = "Session id"), @@ -5198,6 +6504,9 @@ async fn reply_question( ), tag = "sessions" )] +/// Reject Question +/// +/// Rejects a human-in-the-loop question from the agent. async fn reject_question( State(state): State<Arc<AppState>>, Path((session_id, question_id)): Path<(String, String)>, @@ -5215,7 +6524,7 @@ async fn reject_question( request_body = PermissionReplyRequest, responses( (status = 204, description = "Permission reply accepted"), - (status = 404, body = ProblemDetails) + (status = 404, description = "Session or permission not found", body = ProblemDetails) ), params( ("session_id" = String, Path, description = "Session id"), @@ -5223,6 +6532,9 @@ async fn reject_question( ), tag = "sessions" )] +/// Reply to Permission +/// +/// Approves or denies a permission request from the agent. async fn reply_permission( State(state): State<Arc<AppState>>, Path((session_id, permission_id)): Path<(String, String)>, @@ -5235,13 +6547,350 @@ async fn reply_permission( Ok(StatusCode::NO_CONTENT) } -fn all_agents() -> [AgentId; 6] { +#[utoipa::path( + get, + path = "/v1/fs/entries", + params( + ("path" = Option<String>, Query, description = "Path to list (relative or absolute)"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ), + responses((status = 200, description = "Directory listing", body = Vec<FsEntry>)), + tag = "fs" +)] +/// List Directory +/// +/// Lists files and directories at the given path. +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)) +} + +#[utoipa::path( + get, + path = "/v1/fs/file", + params( + ("path" = String, Query, description = "File path (relative or absolute)"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ), + responses((status = 200, description = "File content", body = Vec<u8>)), + tag = "fs" +)] +/// Read File +/// +/// Reads the raw bytes of a file. +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()) +} + +#[utoipa::path( + put, + path = "/v1/fs/file", + request_body = Vec<u8>, + params( + ("path" = String, Query, description = "File path (relative or absolute)"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ), + responses((status = 200, description = "Write result", body = FsWriteResponse)), + tag = "fs" +)] +/// Write File +/// +/// Writes raw bytes to a file, creating it if it doesn't exist. +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, + })) +} + +#[utoipa::path( + delete, + path = "/v1/fs/entry", + params( + ("path" = String, Query, description = "File or directory path"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths"), + ("recursive" = Option<bool>, Query, description = "Delete directories recursively") + ), + responses((status = 200, description = "Delete result", body = FsActionResponse)), + tag = "fs" +)] +/// Delete Entry +/// +/// Deletes a file or directory. +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(), + })) +} + +#[utoipa::path( + post, + path = "/v1/fs/mkdir", + params( + ("path" = String, Query, description = "Directory path to create"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ), + responses((status = 200, description = "Directory created", body = FsActionResponse)), + tag = "fs" +)] +/// Create Directory +/// +/// Creates a directory, including any missing parent directories. +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(), + })) +} + +#[utoipa::path( + post, + path = "/v1/fs/move", + request_body = FsMoveRequest, + params(("session_id" = Option<String>, Query, description = "Session id for relative paths")), + responses((status = 200, description = "Move result", body = FsMoveResponse)), + tag = "fs" +)] +/// Move Entry +/// +/// Moves or renames a file or directory. +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(), + })) +} + +#[utoipa::path( + get, + path = "/v1/fs/stat", + params( + ("path" = String, Query, description = "Path to stat"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ), + responses((status = 200, description = "File metadata", body = FsStat)), + tag = "fs" +)] +/// Get File Info +/// +/// Returns metadata (size, timestamps, type) for a path. +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, + })) +} + +#[utoipa::path( + post, + path = "/v1/fs/upload-batch", + request_body = Vec<u8>, + params( + ("path" = Option<String>, Query, description = "Destination directory for extraction"), + ("session_id" = Option<String>, Query, description = "Session id for relative paths") + ), + responses((status = 200, description = "Upload result", body = FsUploadBatchResponse)), + tag = "fs" +)] +/// Upload Files +/// +/// Uploads a tar.gz archive and extracts it to the destination directory. +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, + })) +} + +fn all_agents() -> [AgentId; 7] { [ AgentId::Claude, AgentId::Codex, AgentId::Opencode, AgentId::Amp, AgentId::Pi, + AgentId::Cursor, AgentId::Mock, ] } @@ -5259,6 +6908,10 @@ fn agent_supports_item_started(agent: AgentId) -> bool { agent_capabilities_for(agent).item_started } +fn agent_emits_turn_started(agent: AgentId) -> bool { + matches!(agent, AgentId::Codex | AgentId::Opencode) +} + fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { match agent { // Claude CLI supports tool calls/results and permission prompts via the SDK control protocol, @@ -5278,7 +6931,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { status: false, command_execution: false, file_changes: false, - mcp_tools: false, + mcp_tools: true, streaming_deltas: true, item_started: false, variants: false, @@ -5302,7 +6955,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: true, streaming_deltas: true, item_started: true, - variants: true, + variants: false, shared_process: true, // shared app-server via JSON-RPC }, AgentId::Opencode => AgentCapabilities { @@ -5317,13 +6970,13 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { session_lifecycle: true, error_events: true, reasoning: false, - status: true, + status: false, command_execution: false, file_changes: false, - mcp_tools: false, + mcp_tools: true, streaming_deltas: true, item_started: true, - variants: true, + variants: false, shared_process: true, // shared HTTP server }, AgentId::Amp => AgentCapabilities { @@ -5341,10 +6994,10 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { status: false, command_execution: false, file_changes: false, - mcp_tools: false, + mcp_tools: true, streaming_deltas: false, item_started: false, - variants: true, + variants: false, shared_process: false, // per-turn subprocess with --continue }, AgentId::Pi => AgentCapabilities { @@ -5368,6 +7021,27 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { variants: true, shared_process: false, // one dedicated rpc process per session }, + AgentId::Cursor => AgentCapabilities { + plan_mode: false, + permissions: false, + questions: false, + tool_calls: false, + tool_results: false, + 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: false, + streaming_deltas: false, + item_started: false, + variants: false, + shared_process: false, + }, AgentId::Mock => AgentCapabilities { plan_mode: true, permissions: true, @@ -5451,6 +7125,11 @@ fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> { name: "Build".to_string(), description: "Default build mode".to_string(), }], + AgentId::Cursor => vec![AgentModeInfo { + id: "build".to_string(), + name: "Build".to_string(), + description: "Default build mode".to_string(), + }], AgentId::Mock => vec![ AgentModeInfo { id: "build".to_string(), @@ -5467,23 +7146,15 @@ fn agent_modes_for(agent: AgentId) -> Vec<AgentModeInfo> { } fn amp_models_response() -> AgentModelsResponse { - // NOTE: Amp models are hardcoded based on ampcode.com manual: - // - smart - // - rush - // - deep - // - free - let models = ["smart", "rush", "deep", "free"] - .into_iter() - .map(|id| AgentModelInfo { - id: id.to_string(), - name: None, - variants: Some(amp_variants()), - default_variant: Some("medium".to_string()), - }) - .collect(); + let models = vec![AgentModelInfo { + id: "amp-default".to_string(), + name: Some("Amp Default".to_string()), + variants: None, + default_variant: None, + }]; AgentModelsResponse { models, - default_model: Some("smart".to_string()), + default_model: Some("amp-default".to_string()), } } @@ -5499,18 +7170,11 @@ fn mock_models_response() -> AgentModelsResponse { } } -fn amp_variants() -> Vec<String> { - vec!["medium", "high", "xhigh"] - .into_iter() - .map(|value| value.to_string()) - .collect() -} - -fn codex_variants() -> Vec<String> { - vec!["none", "minimal", "low", "medium", "high", "xhigh"] - .into_iter() - .map(|value| value.to_string()) - .collect() +fn should_cache_agent_models(agent: AgentId, response: &AgentModelsResponse) -> bool { + if agent == AgentId::Opencode && response.models.is_empty() { + return false; + } + true } fn pi_variants() -> Vec<String> { @@ -5707,6 +7371,14 @@ fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result<Stri } .into()), }, + AgentId::Cursor => match mode { + "build" => Ok("build".to_string()), + value => Err(SandboxError::ModeNotSupported { + agent: agent.as_str().to_string(), + mode: value.to_string(), + } + .into()), + }, AgentId::Mock => match mode { "build" | "plan" => Ok(mode.to_string()), value => Err(SandboxError::ModeNotSupported { @@ -5734,7 +7406,7 @@ fn normalize_permission_mode( agent: AgentId, permission_mode: Option<&str>, ) -> Result<String, SandboxError> { - let mode = match permission_mode.unwrap_or("default") { + let mut mode = match permission_mode.unwrap_or("default") { "default" | "plan" | "bypass" | "acceptEdits" => permission_mode.unwrap_or("default"), value => { return Err(SandboxError::InvalidRequest { @@ -5743,6 +7415,10 @@ fn normalize_permission_mode( .into()) } }; + if agent != AgentId::Claude && mode == "acceptEdits" && agent != AgentId::Codex { + // acceptEdits is Claude-only unless explicitly handled; treat it as a no-op for other agents. + mode = "default"; + } if agent == AgentId::Claude { // Claude refuses --dangerously-skip-permissions when running as root, // which is common in container environments (Docker, Daytona, E2B). @@ -5761,10 +7437,11 @@ fn normalize_permission_mode( } let supported = match agent { AgentId::Claude => false, - AgentId::Codex => matches!(mode, "default" | "plan" | "bypass"), + AgentId::Codex => matches!(mode, "default" | "plan" | "bypass" | "acceptEdits"), AgentId::Amp => matches!(mode, "default" | "bypass"), - AgentId::Opencode => matches!(mode, "default"), + AgentId::Opencode => matches!(mode, "default" | "bypass"), AgentId::Pi => matches!(mode, "default"), + AgentId::Cursor => matches!(mode, "default"), AgentId::Mock => matches!(mode, "default" | "plan" | "bypass"), }; if !supported { @@ -5848,14 +7525,30 @@ fn build_spawn_options( } }); if let Some(anthropic) = credentials.anthropic { - options - .env - .entry("ANTHROPIC_API_KEY".to_string()) - .or_insert(anthropic.api_key.clone()); - options - .env - .entry("CLAUDE_API_KEY".to_string()) - .or_insert(anthropic.api_key); + let should_inject_claude_env = !(session.agent == AgentId::Claude + && anthropic.source == "claude-code" + && anthropic.provider == "anthropic"); + if should_inject_claude_env { + if session.agent == AgentId::Claude && anthropic.auth_type == AuthType::Oauth { + options + .env + .entry("CLAUDE_CODE_OAUTH_TOKEN".to_string()) + .or_insert(anthropic.api_key.clone()); + options + .env + .entry("ANTHROPIC_AUTH_TOKEN".to_string()) + .or_insert(anthropic.api_key); + } else { + options + .env + .entry("ANTHROPIC_API_KEY".to_string()) + .or_insert(anthropic.api_key.clone()); + options + .env + .entry("CLAUDE_API_KEY".to_string()) + .or_insert(anthropic.api_key); + } + } } if let Some(openai) = credentials.openai { options @@ -5870,6 +7563,1502 @@ fn build_spawn_options( options } +#[cfg(test)] +mod tests { + use super::*; + + /// Mutex to serialize tests that change the process-global CWD. + static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn test_snapshot(agent: AgentId) -> SessionSnapshot { + SessionSnapshot { + session_id: "test-session".to_string(), + agent, + agent_mode: "build".to_string(), + permission_mode: "default".to_string(), + model: None, + variant: None, + native_session_id: None, + } + } + + fn claude_code_api_key_credentials() -> ExtractedCredentials { + ExtractedCredentials { + anthropic: Some(ProviderCredentials { + api_key: "sk-ant-test".to_string(), + source: "claude-code".to_string(), + auth_type: AuthType::ApiKey, + provider: "anthropic".to_string(), + }), + openai: None, + other: HashMap::new(), + } + } + + fn environment_oauth_credentials() -> ExtractedCredentials { + ExtractedCredentials { + anthropic: Some(ProviderCredentials { + api_key: "oauth-token".to_string(), + source: "environment".to_string(), + auth_type: AuthType::Oauth, + provider: "anthropic".to_string(), + }), + openai: None, + other: HashMap::new(), + } + } + + #[test] + fn build_spawn_options_skips_claude_env_for_claude_code_source() { + let options = build_spawn_options( + &test_snapshot(AgentId::Claude), + "hello".to_string(), + claude_code_api_key_credentials(), + ); + + assert!(!options.env.contains_key("ANTHROPIC_API_KEY")); + assert!(!options.env.contains_key("CLAUDE_API_KEY")); + } + + #[test] + fn build_spawn_options_keeps_anthropic_env_for_non_claude_agent() { + let options = build_spawn_options( + &test_snapshot(AgentId::Amp), + "hello".to_string(), + claude_code_api_key_credentials(), + ); + + assert_eq!( + options.env.get("ANTHROPIC_API_KEY").map(String::as_str), + Some("sk-ant-test") + ); + assert_eq!( + options.env.get("CLAUDE_API_KEY").map(String::as_str), + Some("sk-ant-test") + ); + } + + #[test] + fn build_spawn_options_uses_oauth_env_for_claude_oauth_credentials() { + let options = build_spawn_options( + &test_snapshot(AgentId::Claude), + "hello".to_string(), + environment_oauth_credentials(), + ); + + assert_eq!( + options + .env + .get("CLAUDE_CODE_OAUTH_TOKEN") + .map(String::as_str), + Some("oauth-token") + ); + assert_eq!( + options.env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str), + Some("oauth-token") + ); + assert!(!options.env.contains_key("ANTHROPIC_API_KEY")); + assert!(!options.env.contains_key("CLAUDE_API_KEY")); + } + + #[test] + fn codex_unavailable_model_parser_handles_requested_model_message() { + let message = "The requested model 'gpt-5.3-codex' does not exist."; + assert_eq!( + codex_unavailable_model_from_message(message), + Some("gpt-5.3-codex".to_string()) + ); + } + + #[test] + fn codex_unavailable_model_parser_handles_chatgpt_account_message() { + let message = "The 'gpt-5.3-codex-NOTREAL' model is not supported when using Codex with a ChatGPT account."; + assert_eq!( + codex_unavailable_model_from_message(message), + Some("gpt-5.3-codex-NOTREAL".to_string()) + ); + } + + #[test] + fn codex_unavailable_model_parser_ignores_non_model_messages() { + let message = "Network error while contacting provider."; + assert_eq!(codex_unavailable_model_from_message(message), None); + } + + #[test] + fn codex_unavailable_model_parser_ignores_non_unavailable_model_messages() { + let message = "using model 'gpt-5.3-codex' for this turn"; + assert_eq!(codex_unavailable_model_from_message(message), None); + } + + #[test] + fn codex_unavailable_model_parser_handles_embedded_json_detail_message() { + let message = "http 400 Bad Request: Some(\"{\\\"detail\\\":\\\"The 'gpt-5.3-codex-NOTREAL' model is not supported when using Codex with a ChatGPT account.\\\"}\")"; + assert_eq!( + codex_unavailable_model_from_message(message), + Some("gpt-5.3-codex-NOTREAL".to_string()) + ); + } + + // ── Skill source tests ────────────────────────────────────────── + + fn make_skill_dir(base: &StdPath, name: &str) -> PathBuf { + let dir = base.join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("SKILL.md"), format!("# {name}")).unwrap(); + dir + } + + #[test] + fn skill_source_serde_github_roundtrip() { + let json = r#"{"type":"github","source":"rivet-dev/skills","skills":["sandbox-agent"],"ref":"main"}"#; + let source: SkillSource = serde_json::from_str(json).unwrap(); + assert_eq!(source.source_type, "github"); + assert_eq!(source.source, "rivet-dev/skills"); + assert_eq!(source.skills, Some(vec!["sandbox-agent".to_string()])); + assert_eq!(source.git_ref, Some("main".to_string())); + assert_eq!(source.subpath, None); + + let roundtrip = serde_json::to_string(&source).unwrap(); + let back: SkillSource = serde_json::from_str(&roundtrip).unwrap(); + assert_eq!(back.source_type, source.source_type); + assert_eq!(back.source, source.source); + } + + #[test] + fn skill_source_serde_local_minimal() { + let json = r#"{"type":"local","source":"/workspace/my-skill"}"#; + let source: SkillSource = serde_json::from_str(json).unwrap(); + assert_eq!(source.source_type, "local"); + assert_eq!(source.source, "/workspace/my-skill"); + assert_eq!(source.skills, None); + assert_eq!(source.git_ref, None); + assert_eq!(source.subpath, None); + } + + #[test] + fn skills_config_serde_roundtrip() { + let json = r#"{"sources":[{"type":"github","source":"owner/repo"},{"type":"local","source":"/path"}]}"#; + let config: SkillsConfig = serde_json::from_str(json).unwrap(); + assert_eq!(config.sources.len(), 2); + assert_eq!(config.sources[0].source_type, "github"); + assert_eq!(config.sources[1].source_type, "local"); + } + + #[test] + fn discover_skills_finds_root_skill() { + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(tmp.path(), "."); + // SKILL.md is directly in the search dir + fs::write(tmp.path().join("SKILL.md"), "# root skill").unwrap(); + + let found = discover_skills_in_dir(tmp.path(), None).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0], tmp.path().to_path_buf()); + } + + #[test] + fn discover_skills_finds_skills_subdir() { + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(&tmp.path().join("skills"), "alpha"); + make_skill_dir(&tmp.path().join("skills"), "beta"); + + let found = discover_skills_in_dir(tmp.path(), None).unwrap(); + let names: Vec<String> = found + .iter() + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"alpha".to_string())); + assert!(names.contains(&"beta".to_string())); + } + + #[test] + fn discover_skills_finds_top_level_children() { + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(tmp.path(), "my-skill"); + + let found = discover_skills_in_dir(tmp.path(), None).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].ends_with("my-skill")); + } + + #[test] + fn discover_skills_deduplicates_children_and_skills_subdir() { + let tmp = tempfile::tempdir().unwrap(); + // Put a skill both at top level and in skills/ subdir with same name + make_skill_dir(tmp.path(), "dupe"); + make_skill_dir(&tmp.path().join("skills"), "dupe"); + + let found = discover_skills_in_dir(tmp.path(), None).unwrap(); + let dupe_count = found + .iter() + .filter(|p| p.file_name().map(|n| n == "dupe").unwrap_or(false)) + .count(); + // Both should be present since they're different paths + assert_eq!(dupe_count, 2); + } + + #[test] + fn discover_skills_respects_subpath() { + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(&tmp.path().join("nested/skills"), "deep-skill"); + // Also put a skill at root that should NOT be discovered + make_skill_dir(tmp.path(), "root-skill"); + + let found = discover_skills_in_dir(tmp.path(), Some("nested")).unwrap(); + let names: Vec<String> = found + .iter() + .filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) + .collect(); + assert!(names.contains(&"deep-skill".to_string())); + assert!(!names.contains(&"root-skill".to_string())); + } + + #[test] + fn discover_skills_empty_dir_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let found = discover_skills_in_dir(tmp.path(), None).unwrap(); + assert!(found.is_empty()); + } + + #[test] + fn discover_skills_missing_subpath_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = discover_skills_in_dir(tmp.path(), Some("nonexistent")); + assert!(result.is_err()); + } + + #[test] + fn discover_skills_ignores_non_skill_dirs() { + let tmp = tempfile::tempdir().unwrap(); + // Create a directory without SKILL.md + fs::create_dir_all(tmp.path().join("not-a-skill")).unwrap(); + fs::write(tmp.path().join("not-a-skill/README.md"), "# readme").unwrap(); + // Create an actual skill + make_skill_dir(tmp.path(), "real-skill"); + + let found = discover_skills_in_dir(tmp.path(), None).unwrap(); + assert_eq!(found.len(), 1); + assert!(found[0].ends_with("real-skill")); + } + + #[test] + fn resolve_skill_source_local_absolute() { + let tmp = tempfile::tempdir().unwrap(); + let skill = make_skill_dir(tmp.path(), "my-skill"); + let source = SkillSource { + source_type: "local".to_string(), + source: skill.to_string_lossy().to_string(), + skills: None, + git_ref: None, + subpath: None, + }; + let result = resolve_skill_source(&source, tmp.path()).unwrap(); + assert_eq!(result, skill); + } + + #[test] + fn resolve_skill_source_local_relative() { + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(tmp.path(), "my-skill"); + let source = SkillSource { + source_type: "local".to_string(), + source: "my-skill".to_string(), + skills: None, + git_ref: None, + subpath: None, + }; + let result = resolve_skill_source(&source, tmp.path()).unwrap(); + assert_eq!(result, tmp.path().join("my-skill")); + } + + #[test] + fn resolve_skill_source_local_missing_dir_errors() { + let tmp = tempfile::tempdir().unwrap(); + let source = SkillSource { + source_type: "local".to_string(), + source: "/nonexistent/path".to_string(), + skills: None, + git_ref: None, + subpath: None, + }; + let result = resolve_skill_source(&source, tmp.path()); + assert!(result.is_err()); + } + + #[test] + fn resolve_skill_source_unsupported_type_errors() { + let tmp = tempfile::tempdir().unwrap(); + let source = SkillSource { + source_type: "s3".to_string(), + source: "bucket/key".to_string(), + skills: None, + git_ref: None, + subpath: None, + }; + let result = resolve_skill_source(&source, tmp.path()); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("unsupported"), + "expected 'unsupported' in: {msg}" + ); + } + + #[test] + fn install_skill_sources_local_single() { + let _lock = CWD_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(tmp.path(), "alpha"); + + let sources = vec![SkillSource { + source_type: "local".to_string(), + source: tmp.path().join("alpha").to_string_lossy().to_string(), + skills: None, + git_ref: None, + subpath: None, + }]; + + // Run from a temp working directory so symlinks go there + let work = tempfile::tempdir().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(work.path()).unwrap(); + let result = install_skill_sources(&sources); + std::env::set_current_dir(prev).unwrap(); + + let dirs = result.unwrap(); + assert_eq!(dirs.len(), 1); + // Verify symlinks were created + for root in SKILL_ROOTS { + let link = work.path().join(root).join("alpha"); + assert!(link.exists(), "expected skill link at {}", link.display()); + assert!(link.join("SKILL.md").exists()); + } + } + + #[test] + fn install_skill_sources_filters_by_name() { + let _lock = CWD_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + // Repo-like layout with skills/ subdir containing two skills + make_skill_dir(&tmp.path().join("skills"), "wanted"); + make_skill_dir(&tmp.path().join("skills"), "unwanted"); + + let sources = vec![SkillSource { + source_type: "local".to_string(), + source: tmp.path().to_string_lossy().to_string(), + skills: Some(vec!["wanted".to_string()]), + git_ref: None, + subpath: None, + }]; + + let work = tempfile::tempdir().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(work.path()).unwrap(); + let result = install_skill_sources(&sources); + std::env::set_current_dir(prev).unwrap(); + + let dirs = result.unwrap(); + assert_eq!(dirs.len(), 1); + assert!(dirs[0].ends_with("wanted")); + } + + #[test] + fn install_skill_sources_errors_when_filter_matches_nothing() { + let _lock = CWD_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + make_skill_dir(&tmp.path().join("skills"), "alpha"); + + let sources = vec![SkillSource { + source_type: "local".to_string(), + source: tmp.path().to_string_lossy().to_string(), + skills: Some(vec!["nonexistent".to_string()]), + git_ref: None, + subpath: None, + }]; + + let work = tempfile::tempdir().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(work.path()).unwrap(); + let result = install_skill_sources(&sources); + std::env::set_current_dir(prev).unwrap(); + + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("no skills found"), + "expected 'no skills found' in: {msg}" + ); + } + + #[test] + fn install_skill_sources_multiple_sources() { + let _lock = CWD_LOCK.lock().unwrap(); + let tmp1 = tempfile::tempdir().unwrap(); + let tmp2 = tempfile::tempdir().unwrap(); + make_skill_dir(tmp1.path(), "skill-a"); + make_skill_dir(tmp2.path(), "skill-b"); + + let sources = vec![ + SkillSource { + source_type: "local".to_string(), + source: tmp1.path().join("skill-a").to_string_lossy().to_string(), + skills: None, + git_ref: None, + subpath: None, + }, + SkillSource { + source_type: "local".to_string(), + source: tmp2.path().join("skill-b").to_string_lossy().to_string(), + skills: None, + git_ref: None, + subpath: None, + }, + ]; + + let work = tempfile::tempdir().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(work.path()).unwrap(); + let result = install_skill_sources(&sources); + std::env::set_current_dir(prev).unwrap(); + + let dirs = result.unwrap(); + assert_eq!(dirs.len(), 2); + } + + #[test] + fn install_skill_sources_deduplicates_same_skill() { + let _lock = CWD_LOCK.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let skill = make_skill_dir(tmp.path(), "shared"); + let path_str = skill.to_string_lossy().to_string(); + + let sources = vec![ + SkillSource { + source_type: "local".to_string(), + source: path_str.clone(), + skills: None, + git_ref: None, + subpath: None, + }, + SkillSource { + source_type: "local".to_string(), + source: path_str, + skills: None, + git_ref: None, + subpath: None, + }, + ]; + + let work = tempfile::tempdir().unwrap(); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(work.path()).unwrap(); + let result = install_skill_sources(&sources); + std::env::set_current_dir(prev).unwrap(); + + let dirs = result.unwrap(); + assert_eq!(dirs.len(), 1, "duplicate skill should be deduplicated"); + } + + #[test] + fn ensure_skill_link_replaces_dangling_symlink() { + let work = tempfile::tempdir().unwrap(); + let dest = work.path().join("test-link"); + + // Create a dangling symlink (target doesn't exist) + #[cfg(unix)] + std::os::unix::fs::symlink("/nonexistent/target", &dest).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_dir("/nonexistent/target", &dest).unwrap(); + + assert!(dest.symlink_metadata().is_ok(), "symlink should exist"); + assert!(!dest.exists(), "symlink target should not exist (dangling)"); + + // Create a real skill directory as the new target + let skill = tempfile::tempdir().unwrap(); + std::fs::write(skill.path().join("SKILL.md"), "# Test").unwrap(); + + // ensure_skill_link should replace the dangling symlink + let result = ensure_skill_link(skill.path(), &dest); + assert!( + result.is_ok(), + "should replace dangling symlink: {result:?}" + ); + assert!(dest.exists(), "link should now point to valid target"); + assert!(dest.join("SKILL.md").exists()); + } + + #[test] + fn download_github_zip_extracts_correctly() { + use std::io::Write; + + // Build a zip in memory with GitHub-style prefix directory + let buf = Vec::new(); + let cursor = std::io::Cursor::new(buf); + let mut zip_writer = zip::ZipWriter::new(cursor); + + let options = + zip::write::FileOptions::default().compression_method(zip::CompressionMethod::Stored); + + // GitHub wraps all content under "owner-repo-sha/" + zip_writer + .add_directory("owner-repo-abc123/", options) + .unwrap(); + + zip_writer + .start_file("owner-repo-abc123/SKILL.md", options) + .unwrap(); + zip_writer.write_all(b"# Test Skill").unwrap(); + + zip_writer + .add_directory("owner-repo-abc123/sub/", options) + .unwrap(); + + zip_writer + .start_file("owner-repo-abc123/sub/nested.txt", options) + .unwrap(); + zip_writer.write_all(b"nested content").unwrap(); + + let zip_bytes = zip_writer.finish().unwrap().into_inner(); + + // Extract using the same logic as download_github_zip (minus HTTP) + let work = tempfile::tempdir().unwrap(); + let dest = work.path().join("test-skill"); + + let reader = std::io::Cursor::new(&zip_bytes); + let mut archive = zip::ZipArchive::new(reader).unwrap(); + + // Detect prefix + let prefix = { + let first = archive.by_index(0).unwrap(); + let name = first.name().to_string(); + match name.find('/') { + Some(pos) => name[..=pos].to_string(), + None => String::new(), + } + }; + + fs::create_dir_all(&dest).unwrap(); + + for i in 0..archive.len() { + let mut file = archive.by_index(i).unwrap(); + let full_name = file.name().to_string(); + + let relative = if !prefix.is_empty() && full_name.starts_with(&prefix) { + &full_name[prefix.len()..] + } else { + &full_name + }; + + if relative.is_empty() { + continue; + } + + let out_path = dest.join(relative); + if !out_path.starts_with(&dest) { + continue; + } + + if file.is_dir() { + fs::create_dir_all(&out_path).unwrap(); + } else { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let mut out_file = fs::File::create(&out_path).unwrap(); + std::io::copy(&mut file, &mut out_file).unwrap(); + } + } + + // Verify files were extracted without the prefix directory + assert!( + dest.join("SKILL.md").exists(), + "SKILL.md should exist at root" + ); + assert_eq!( + fs::read_to_string(dest.join("SKILL.md")).unwrap(), + "# Test Skill" + ); + assert!( + dest.join("sub/nested.txt").exists(), + "nested file should exist" + ); + assert_eq!( + fs::read_to_string(dest.join("sub/nested.txt")).unwrap(), + "nested content" + ); + // Ensure no prefix directory leaked through + assert!( + !dest.join("owner-repo-abc123").exists(), + "prefix dir should be stripped" + ); + } +} + +fn install_skill_sources(sources: &[SkillSource]) -> Result<Vec<PathBuf>, SandboxError> { + let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let mut skill_dirs = Vec::new(); + + for source in sources { + let base_dir = resolve_skill_source(source, &cwd)?; + let discovered = discover_skills_in_dir(&base_dir, source.subpath.as_deref())?; + + let filtered: Vec<PathBuf> = if let Some(filter) = &source.skills { + discovered + .into_iter() + .filter(|p| { + p.file_name() + .map(|n| filter.iter().any(|f| f == n.to_string_lossy().as_ref())) + .unwrap_or(false) + }) + .collect() + } else { + discovered + }; + + if filtered.is_empty() { + let filter_msg = source + .skills + .as_ref() + .map(|f| format!(" (filter: {})", f.join(", "))) + .unwrap_or_default(); + return Err(SandboxError::InvalidRequest { + message: format!( + "no skills found in {} ({}){filter_msg}", + source.source, source.source_type + ), + }); + } + + for skill_path in &filtered { + let canonical = + fs::canonicalize(skill_path).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + if !skill_dirs.contains(&canonical) { + skill_dirs.push(canonical.clone()); + } + let skill_name = canonical + .file_name() + .ok_or_else(|| SandboxError::InvalidRequest { + message: format!("invalid skill directory: {}", canonical.display()), + })? + .to_string_lossy() + .to_string(); + for root in SKILL_ROOTS { + let dest = cwd.join(root).join(&skill_name); + ensure_skill_link(&canonical, &dest)?; + } + } + } + + Ok(skill_dirs) +} + +fn skills_cache_dir() -> Result<PathBuf, SandboxError> { + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .map_err(|_| SandboxError::StreamError { + message: "cannot determine home directory".to_string(), + })?; + let cache = PathBuf::from(home).join(".sandbox-agent/skills-cache"); + fs::create_dir_all(&cache).map_err(|err| SandboxError::StreamError { + message: format!("failed to create skills cache: {err}"), + })?; + Ok(cache) +} + +fn download_github_zip( + owner_repo: &str, + cache_name: &str, + git_ref: Option<&str>, +) -> Result<PathBuf, SandboxError> { + let cache = skills_cache_dir()?; + let dest = cache.join(cache_name); + + // Remove existing cache dir if present (no .git state to preserve) + if dest.is_dir() { + fs::remove_dir_all(&dest).map_err(|err| SandboxError::StreamError { + message: format!("failed to remove old skills cache: {err}"), + })?; + } + + let git_ref = git_ref.unwrap_or("HEAD"); + let url = format!( + "https://api.github.com/repos/{}/zipball/{}", + owner_repo, git_ref + ); + + let client = reqwest::blocking::Client::new(); + let response = client + .get(&url) + .header("User-Agent", "sandbox-agent") + .send() + .map_err(|err| SandboxError::StreamError { + message: format!("failed to download github zip for {owner_repo}: {err}"), + })?; + + if !response.status().is_success() { + return Err(SandboxError::StreamError { + message: format!( + "github zip download failed for {owner_repo}: HTTP {}", + response.status() + ), + }); + } + + let bytes = response.bytes().map_err(|err| SandboxError::StreamError { + message: format!("failed to read github zip response: {err}"), + })?; + + let reader = std::io::Cursor::new(bytes); + let mut archive = zip::ZipArchive::new(reader).map_err(|err| SandboxError::StreamError { + message: format!("failed to open github zip archive: {err}"), + })?; + + // GitHub zipball wraps contents in a {owner}-{repo}-{sha}/ prefix directory. + // Detect this prefix from the first entry and strip it during extraction. + let prefix = { + let first = archive + .by_index(0) + .map_err(|err| SandboxError::StreamError { + message: format!("failed to read zip entry: {err}"), + })?; + let name = first.name().to_string(); + // The first entry is typically the top-level directory itself (e.g. "owner-repo-sha/") + match name.find('/') { + Some(pos) => name[..=pos].to_string(), + None => String::new(), + } + }; + + fs::create_dir_all(&dest).map_err(|err| SandboxError::StreamError { + message: format!("failed to create skills cache dir: {err}"), + })?; + + for i in 0..archive.len() { + let mut file = archive + .by_index(i) + .map_err(|err| SandboxError::StreamError { + message: format!("failed to read zip entry: {err}"), + })?; + + let full_name = file.name().to_string(); + + // Strip the GitHub prefix directory + let relative = if !prefix.is_empty() && full_name.starts_with(&prefix) { + &full_name[prefix.len()..] + } else { + &full_name + }; + + // Skip the prefix directory entry itself and empty paths + if relative.is_empty() { + continue; + } + + let out_path = dest.join(relative); + + // Prevent path traversal + if !out_path.starts_with(&dest) { + continue; + } + + if file.is_dir() { + fs::create_dir_all(&out_path).map_err(|err| SandboxError::StreamError { + message: format!("failed to create directory: {err}"), + })?; + } else { + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent).map_err(|err| SandboxError::StreamError { + message: format!("failed to create parent directory: {err}"), + })?; + } + let mut out_file = + fs::File::create(&out_path).map_err(|err| SandboxError::StreamError { + message: format!("failed to create file: {err}"), + })?; + std::io::copy(&mut file, &mut out_file).map_err(|err| SandboxError::StreamError { + message: format!("failed to write file: {err}"), + })?; + } + } + + Ok(dest) +} + +fn clone_or_update_repo( + url: &str, + cache_name: &str, + git_ref: Option<&str>, +) -> Result<PathBuf, SandboxError> { + let cache = skills_cache_dir()?; + let dest = cache.join(cache_name); + + if dest.join(".git").is_dir() { + // Update existing clone + let mut cmd = std::process::Command::new("git"); + cmd.arg("-C").arg(&dest).arg("pull").arg("--ff-only"); + let output = cmd.output().map_err(|err| SandboxError::StreamError { + message: format!("git pull failed: {err}"), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!("git pull failed for {cache_name}, re-cloning: {stderr}"); + fs::remove_dir_all(&dest).map_err(|err| SandboxError::StreamError { + message: format!("failed to remove stale cache: {err}"), + })?; + return clone_or_update_repo(url, cache_name, git_ref); + } + } else { + // Fresh clone + let mut cmd = std::process::Command::new("git"); + cmd.arg("clone").arg("--depth").arg("1"); + if let Some(r) = git_ref { + cmd.arg("--branch").arg(r); + } + cmd.arg(url).arg(&dest); + let output = cmd.output().map_err(|err| SandboxError::StreamError { + message: format!("git clone failed: {err}"), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(SandboxError::StreamError { + message: format!("git clone failed for {url}: {stderr}"), + }); + } + } + + Ok(dest) +} + +fn resolve_skill_source(source: &SkillSource, cwd: &StdPath) -> Result<PathBuf, SandboxError> { + match source.source_type.as_str() { + "github" => { + let cache_name = source.source.replace('/', "-"); + download_github_zip(&source.source, &cache_name, source.git_ref.as_deref()) + } + "git" => { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + source.source.hash(&mut hasher); + let hash = format!("{:016x}", hasher.finish()); + clone_or_update_repo(&source.source, &hash, source.git_ref.as_deref()) + } + "local" => { + let mut path = PathBuf::from(&source.source); + if path.is_relative() { + path = cwd.join(path); + } + if !path.is_dir() { + return Err(SandboxError::InvalidRequest { + message: format!("local skill directory not found: {}", path.display()), + }); + } + Ok(path) + } + other => Err(SandboxError::InvalidRequest { + message: format!("unsupported skill source type: {other}"), + }), + } +} + +fn discover_skills_in_dir( + base: &StdPath, + subpath: Option<&str>, +) -> Result<Vec<PathBuf>, SandboxError> { + let search_dir = if let Some(sub) = subpath { + base.join(sub) + } else { + base.to_path_buf() + }; + + if !search_dir.is_dir() { + return Err(SandboxError::InvalidRequest { + message: format!("skill search directory not found: {}", search_dir.display()), + }); + } + + let mut skills = Vec::new(); + + // Check if the search dir itself is a skill + if search_dir.join("SKILL.md").exists() { + skills.push(search_dir.clone()); + } + + // Check skills/ subdirectory + let skills_subdir = search_dir.join("skills"); + if skills_subdir.is_dir() { + if let Ok(entries) = fs::read_dir(&skills_subdir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.join("SKILL.md").exists() { + skills.push(path); + } + } + } + } + + // Check immediate children of search dir (for repos with skills at top level) + if let Ok(entries) = fs::read_dir(&search_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && path.join("SKILL.md").exists() && !skills.contains(&path) { + skills.push(path); + } + } + } + + Ok(skills) +} + +fn ensure_skill_link(target: &StdPath, dest: &StdPath) -> Result<(), SandboxError> { + if dest.exists() { + if dest.is_dir() && dest.join("SKILL.md").exists() { + return Ok(()); + } + if let Ok(link_target) = fs::read_link(dest) { + if link_target == target { + return Ok(()); + } + } + return Err(SandboxError::InvalidRequest { + message: format!("skill path conflict: {} already exists", dest.display()), + }); + } + // Remove dangling symlinks (exists() follows symlinks and returns false for dangling ones) + if dest.symlink_metadata().is_ok() { + let _ = fs::remove_file(dest); + } + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + } + if let Err(err) = create_symlink_dir(target, dest) { + copy_dir_recursive(target, dest).map_err(|copy_err| SandboxError::StreamError { + message: format!("{err}; fallback copy failed: {copy_err}"), + })?; + } + Ok(()) +} + +#[cfg(unix)] +fn create_symlink_dir(target: &StdPath, dest: &StdPath) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, dest) +} + +#[cfg(windows)] +fn create_symlink_dir(target: &StdPath, dest: &StdPath) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(target, dest) +} + +#[cfg(not(any(unix, windows)))] +fn create_symlink_dir(_target: &StdPath, _dest: &StdPath) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Other, + "symlinks unsupported", + )) +} + +fn copy_dir_recursive(src: &StdPath, dest: &StdPath) -> std::io::Result<()> { + fs::create_dir_all(dest)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dest_path)?; + } else { + fs::copy(&src_path, &dest_path)?; + } + } + Ok(()) +} + +fn write_claude_mcp_config(mcp: &BTreeMap<String, McpServerConfig>) -> Result<(), SandboxError> { + let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let path = cwd.join(".mcp.json"); + let mut root = if path.exists() { + let text = fs::read_to_string(&path).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + serde_json::from_str::<Value>(&text).map_err(|err| SandboxError::InvalidRequest { + message: format!("invalid .mcp.json: {err}"), + })? + } else { + Value::Object(Map::new()) + }; + let Some(object) = root.as_object_mut() else { + return Err(SandboxError::InvalidRequest { + message: "invalid .mcp.json: expected object".to_string(), + }); + }; + let servers = object + .entry("mcpServers") + .or_insert_with(|| Value::Object(Map::new())); + let Some(server_map) = servers.as_object_mut() else { + return Err(SandboxError::InvalidRequest { + message: "invalid .mcp.json: mcpServers must be an object".to_string(), + }); + }; + for (name, config) in mcp { + server_map.insert(name.clone(), claude_mcp_entry(config)?); + } + fs::write( + &path, + serde_json::to_string_pretty(&root).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?, + ) + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + Ok(()) +} + +fn write_codex_mcp_config(mcp: &BTreeMap<String, McpServerConfig>) -> Result<(), SandboxError> { + let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let path = cwd.join(".codex").join("config.toml"); + let mut doc = if path.exists() { + let text = fs::read_to_string(&path).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + text.parse::<DocumentMut>() + .map_err(|err| SandboxError::InvalidRequest { + message: format!("invalid Codex config.toml: {err}"), + })? + } else { + DocumentMut::new() + }; + let mcp_item = doc + .entry("mcp_servers") + .or_insert(Item::Table(Table::new())); + let mcp_table = mcp_item + .as_table_mut() + .ok_or_else(|| SandboxError::InvalidRequest { + message: "invalid Codex config.toml: mcp_servers must be a table".to_string(), + })?; + for (name, config) in mcp { + let table = codex_mcp_table(config)?; + mcp_table.insert(name, Item::Table(table)); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + } + fs::write(&path, doc.to_string()).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + Ok(()) +} + +fn apply_amp_mcp_config( + agent_manager: &AgentManager, + mcp: &BTreeMap<String, McpServerConfig>, +) -> Result<(), SandboxError> { + let path = agent_manager.resolve_binary(AgentId::Amp).map_err(|_| { + SandboxError::AgentNotInstalled { + agent: "amp".to_string(), + } + })?; + let cwd = std::env::current_dir().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + for (name, config) in mcp { + let mut cmd = Command::new(&path); + cmd.current_dir(&cwd); + cmd.arg("mcp").arg("add").arg(name); + match config { + McpServerConfig::Local { command, args, .. } => { + let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; + cmd.arg("--").arg(cmd_name).args(cmd_args); + } + McpServerConfig::Remote { + url, + headers, + bearer_token_env_var, + env_headers, + .. + } => { + let merged = merged_headers( + headers.as_ref(), + bearer_token_env_var.as_ref(), + env_headers.as_ref(), + ); + for (key, value) in merged { + cmd.arg("--header").arg(format!("{key}: {value}")); + } + cmd.arg(url); + } + } + let output = cmd.output().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + if !output.status.success() { + return Err(SandboxError::StreamError { + message: format!("amp mcp add failed for {name}: {}", output.status), + }); + } + } + Ok(()) +} + +fn opencode_mcp_config(config: &McpServerConfig) -> Result<Value, SandboxError> { + match config { + McpServerConfig::Local { + command, + args, + env, + enabled, + timeout_ms, + .. + } => { + let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; + let mut map = Map::new(); + map.insert("type".to_string(), Value::String("local".to_string())); + let mut command_parts = vec![Value::String(cmd_name)]; + command_parts.extend(cmd_args.into_iter().map(Value::String)); + map.insert("command".to_string(), Value::Array(command_parts)); + if let Some(env) = env { + map.insert("environment".to_string(), json!(env)); + } + if let Some(enabled) = enabled { + map.insert("enabled".to_string(), Value::Bool(*enabled)); + } + if let Some(timeout) = timeout_ms { + map.insert( + "timeout".to_string(), + Value::Number(serde_json::Number::from(*timeout)), + ); + } + Ok(Value::Object(map)) + } + McpServerConfig::Remote { + url, + headers, + bearer_token_env_var, + env_headers, + oauth, + enabled, + timeout_ms, + .. + } => { + let mut map = Map::new(); + map.insert("type".to_string(), Value::String("remote".to_string())); + map.insert("url".to_string(), Value::String(url.clone())); + let merged = merged_headers( + headers.as_ref(), + bearer_token_env_var.as_ref(), + env_headers.as_ref(), + ); + if !merged.is_empty() { + map.insert("headers".to_string(), json!(merged)); + } + if let Some(oauth) = oauth { + map.insert( + "oauth".to_string(), + serde_json::to_value(oauth).map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?, + ); + } + if let Some(enabled) = enabled { + map.insert("enabled".to_string(), Value::Bool(*enabled)); + } + if let Some(timeout) = timeout_ms { + map.insert( + "timeout".to_string(), + Value::Number(serde_json::Number::from(*timeout)), + ); + } + Ok(Value::Object(map)) + } + } +} + +fn claude_mcp_entry(config: &McpServerConfig) -> Result<Value, SandboxError> { + match config { + McpServerConfig::Local { + command, args, env, .. + } => { + let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; + let mut map = Map::new(); + map.insert("command".to_string(), Value::String(cmd_name)); + if !cmd_args.is_empty() { + map.insert( + "args".to_string(), + Value::Array(cmd_args.into_iter().map(Value::String).collect()), + ); + } + if let Some(env) = env { + map.insert("env".to_string(), json!(env)); + } + Ok(Value::Object(map)) + } + McpServerConfig::Remote { + url, + headers, + bearer_token_env_var, + env_headers, + transport, + .. + } => { + let mut map = Map::new(); + let transport = transport.clone().unwrap_or(McpRemoteTransport::Http); + map.insert( + "type".to_string(), + Value::String( + match transport { + McpRemoteTransport::Http => "http", + McpRemoteTransport::Sse => "sse", + } + .to_string(), + ), + ); + map.insert("url".to_string(), Value::String(url.clone())); + let merged = merged_headers( + headers.as_ref(), + bearer_token_env_var.as_ref(), + env_headers.as_ref(), + ); + if !merged.is_empty() { + map.insert("headers".to_string(), json!(merged)); + } + Ok(Value::Object(map)) + } + } +} + +fn codex_mcp_table(config: &McpServerConfig) -> Result<Table, SandboxError> { + let mut table = Table::new(); + match config { + McpServerConfig::Local { + command, + args, + env, + enabled, + timeout_ms, + .. + } => { + let (cmd_name, cmd_args) = mcp_command_parts(command, args)?; + table.insert("command", value(cmd_name)); + if !cmd_args.is_empty() { + let mut array = Array::new(); + for arg in cmd_args { + array.push(arg); + } + table.insert("args", value(array)); + } + if let Some(env) = env { + let mut env_table = Table::new(); + for (key, val) in env { + env_table.insert(key, value(val.clone())); + } + table.insert("env", Item::Table(env_table)); + } + if let Some(enabled) = enabled { + table.insert("enabled", value(*enabled)); + } + if let Some(timeout) = timeout_ms { + let seconds = (*timeout + 999) / 1000; + table.insert("tool_timeout_sec", value(seconds as i64)); + } + } + McpServerConfig::Remote { + url, + headers, + bearer_token_env_var, + env_headers, + enabled, + timeout_ms, + .. + } => { + table.insert("url", value(url.clone())); + if let Some(headers) = headers { + let mut header_table = Table::new(); + for (key, val) in headers { + header_table.insert(key, value(val.clone())); + } + table.insert("http_headers", Item::Table(header_table)); + } + if let Some(env_headers) = env_headers { + let mut header_table = Table::new(); + for (key, val) in env_headers { + header_table.insert(key, value(val.clone())); + } + table.insert("env_http_headers", Item::Table(header_table)); + } + if let Some(bearer) = bearer_token_env_var { + table.insert("bearer_token_env_var", value(bearer.clone())); + } + if let Some(enabled) = enabled { + table.insert("enabled", value(*enabled)); + } + if let Some(timeout) = timeout_ms { + let seconds = (*timeout + 999) / 1000; + table.insert("tool_timeout_sec", value(seconds as i64)); + } + } + } + Ok(table) +} + +fn mcp_command_parts( + command: &McpCommand, + args: &[String], +) -> Result<(String, Vec<String>), SandboxError> { + match command { + McpCommand::Command(value) => Ok((value.clone(), args.to_vec())), + McpCommand::CommandWithArgs(values) => { + if values.is_empty() { + return Err(SandboxError::InvalidRequest { + message: "mcp command cannot be empty".to_string(), + }); + } + let mut iter = values.iter(); + let cmd = iter.next().map(|value| value.to_string()).ok_or_else(|| { + SandboxError::InvalidRequest { + message: "mcp command cannot be empty".to_string(), + } + })?; + let mut cmd_args = iter.map(|value| value.to_string()).collect::<Vec<_>>(); + cmd_args.extend(args.iter().cloned()); + Ok((cmd, cmd_args)) + } + } +} + +fn merged_headers( + headers: Option<&BTreeMap<String, String>>, + bearer_token_env_var: Option<&String>, + env_headers: Option<&BTreeMap<String, String>>, +) -> BTreeMap<String, String> { + let mut merged = headers.cloned().unwrap_or_default(); + if let Some(env_var) = bearer_token_env_var { + merged + .entry("Authorization".to_string()) + .or_insert_with(|| format!("Bearer ${env_var}")); + } + if let Some(env_headers) = env_headers { + for (key, value) in env_headers { + merged + .entry(key.clone()) + .or_insert_with(|| format!("${value}")); + } + } + merged +} + +async fn resolve_fs_path( + state: &Arc<AppState>, + session_id: Option<&str>, + raw_path: &str, +) -> Result<PathBuf, SandboxError> { + let path = PathBuf::from(raw_path); + if path.is_absolute() { + return Ok(path); + } + let root = resolve_fs_root(state, session_id).await?; + let relative = sanitize_relative_path(&path)?; + Ok(root.join(relative)) +} + +async fn resolve_fs_root( + state: &Arc<AppState>, + session_id: Option<&str>, +) -> Result<PathBuf, SandboxError> { + if let Some(session_id) = session_id { + return state.session_manager.session_working_dir(session_id).await; + } + let home = dirs::home_dir().ok_or_else(|| SandboxError::InvalidRequest { + message: "home directory unavailable".to_string(), + })?; + Ok(home) +} + +fn sanitize_relative_path(path: &StdPath) -> Result<PathBuf, SandboxError> { + use std::path::Component; + let mut sanitized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => sanitized.push(value), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(SandboxError::InvalidRequest { + message: format!("invalid relative path: {}", path.display()), + }); + } + } + } + Ok(sanitized) +} + +fn map_fs_error(path: &StdPath, err: std::io::Error) -> SandboxError { + if err.kind() == std::io::ErrorKind::NotFound { + SandboxError::InvalidRequest { + message: format!("path not found: {}", path.display()), + } + } else { + SandboxError::StreamError { + message: err.to_string(), + } + } +} + +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) +} + fn claude_input_session_id(session: &SessionSnapshot) -> String { session .native_session_id @@ -5944,6 +9133,81 @@ fn claude_control_response_line(request_id: &str, behavior: &str, response: Valu .to_string() } +/// Returns true if the given action name corresponds to a question tool +/// (AskUserQuestion or ExitPlanMode in any casing convention). +pub(crate) fn is_question_tool_action(action: &str) -> bool { + matches!( + action, + "AskUserQuestion" + | "ask_user_question" + | "askUserQuestion" + | "ask-user-question" + | "ExitPlanMode" + | "exit_plan_mode" + | "exitPlanMode" + | "exit-plan-mode" + ) +} + +fn is_file_change_action(action: &str) -> bool { + matches!(action, "fileChange" | "file_change" | "file-change") + || action.eq_ignore_ascii_case("filechange") +} + +fn permission_cache_keys(action: &str, metadata: &Option<Value>) -> Vec<String> { + let mut keys = Vec::new(); + push_permission_cache_key(&mut keys, action); + if let Some(metadata) = metadata.as_ref().and_then(Value::as_object) { + if let Some(permission) = metadata.get("permission").and_then(Value::as_str) { + push_permission_cache_key(&mut keys, permission); + } + if let Some(kind) = metadata.get("codexRequestKind").and_then(Value::as_str) { + push_permission_cache_key(&mut keys, kind); + } + if let Some(tool_name) = metadata + .get("toolName") + .or_else(|| metadata.get("tool_name")) + .and_then(Value::as_str) + { + push_permission_cache_key(&mut keys, tool_name); + } + } + keys.sort_unstable(); + keys.dedup(); + keys +} + +fn push_permission_cache_key(keys: &mut Vec<String>, raw: &str) { + let raw = raw.trim(); + if raw.is_empty() { + return; + } + keys.push(raw.to_string()); + if let Some(category) = permission_action_category(raw) { + keys.push(category); + } +} + +fn permission_action_category(action: &str) -> Option<String> { + let first = action.split_whitespace().next().unwrap_or(action); + let stripped = first + .split_once(':') + .map(|(prefix, _)| prefix) + .unwrap_or(first) + .trim(); + if stripped.is_empty() { + return None; + } + if stripped + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.') + { + Some(stripped.to_ascii_lowercase()) + } else { + None + } +} + fn read_lines<R: std::io::Read>(reader: R, sender: mpsc::UnboundedSender<String>) { let mut reader = BufReader::new(reader); let mut line = String::new(); @@ -5989,7 +9253,6 @@ struct CodexAppServerState { next_id: i64, prompt: String, model: Option<String>, - effort: Option<codex_schema::ReasoningEffort>, cwd: Option<String>, approval_policy: Option<codex_schema::AskForApproval>, sandbox_mode: Option<codex_schema::SandboxMode>, @@ -6014,7 +9277,6 @@ impl CodexAppServerState { next_id: 1, prompt, model: options.model.clone(), - effort: codex_effort_from_variant(options.variant.as_deref()), cwd, approval_policy: codex_approval_policy(options.permission_mode.as_deref()), sandbox_mode: codex_sandbox_mode(options.permission_mode.as_deref()), @@ -6260,7 +9522,7 @@ impl CodexAppServerState { approval_policy: self.approval_policy, collaboration_mode: None, cwd: self.cwd.clone(), - effort: self.effort.clone(), + effort: None, input: vec![codex_schema::UserInput::Text { text: self.prompt.clone(), text_elements: Vec::new(), @@ -6303,15 +9565,6 @@ fn codex_prompt_for_mode(prompt: &str, mode: Option<&str>) -> String { } } -fn codex_effort_from_variant(variant: Option<&str>) -> Option<codex_schema::ReasoningEffort> { - let variant = variant?.trim(); - if variant.is_empty() { - return None; - } - let normalized = variant.to_lowercase(); - serde_json::from_value(Value::String(normalized)).ok() -} - fn codex_approval_policy(mode: Option<&str>) -> Option<codex_schema::AskForApproval> { match mode { Some("plan") => Some(codex_schema::AskForApproval::Untrusted), @@ -6483,6 +9736,156 @@ fn codex_rpc_error_to_universal(error: &codex_schema::JsonrpcError) -> EventConv EventConversion::new(UniversalEventType::Error, UniversalEventData::Error(data)) } +fn codex_request_error_to_sandbox( + context: &str, + error: &codex_schema::JsonrpcErrorError, +) -> SandboxError { + SandboxError::StreamError { + message: format!("{context}: {} (code {})", error.message, error.code), + } +} + +fn codex_model_unavailable_status_event( + native_session_id: Option<String>, + model_id: &str, +) -> EventConversion { + EventConversion::new( + UniversalEventType::ItemCompleted, + UniversalEventData::Item(ItemEventData { + item: UniversalItem { + item_id: String::new(), + native_item_id: None, + parent_id: None, + kind: ItemKind::Status, + role: Some(ItemRole::System), + content: vec![ContentPart::Status { + label: "codex.model.unavailable".to_string(), + detail: Some(format!( + "Model '{}' was rejected by provider; falling back to default for this session.", + model_id + )), + }], + status: ItemStatus::Completed, + }, + }), + ) + .synthetic() + .with_native_session(native_session_id) +} + +fn codex_unavailable_model_from_message(message: &str) -> Option<String> { + let normalized = message.to_ascii_lowercase(); + if !normalized.contains("model") { + return None; + } + let is_known_unavailable_shape = normalized.contains("does not exist") + || normalized.contains("model_not_found") + || normalized.contains("requested model") + || normalized.contains("not supported when using codex with a chatgpt account"); + if !is_known_unavailable_shape { + return None; + } + for token in extract_quoted_tokens(message, '\'') + .into_iter() + .chain(extract_quoted_tokens(message, '"').into_iter()) + { + if is_likely_model_id(token) { + return Some(token.to_string()); + } + } + None +} + +fn codex_unavailable_model_from_rpc_error( + error: &codex_schema::JsonrpcErrorError, +) -> Option<String> { + codex_unavailable_model_from_message(&error.message).or_else(|| { + error + .data + .as_ref() + .and_then(|data| codex_unavailable_model_from_message(&data.to_string())) + }) +} + +fn extract_quoted_tokens<'a>(message: &'a str, quote: char) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut start: Option<usize> = None; + for (idx, ch) in message.char_indices() { + if ch != quote { + continue; + } + if let Some(open) = start.take() { + if open < idx { + out.push(&message[open..idx]); + } + } else { + start = Some(idx + ch.len_utf8()); + } + } + out +} + +fn is_likely_model_id(candidate: &str) -> bool { + if candidate.len() < 3 || candidate.len() > 128 { + return false; + } + if candidate.chars().any(|ch| ch.is_whitespace()) { + return false; + } + if !candidate + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) + { + return false; + } + candidate.contains('-') +} + +fn codex_permission_response_line( + permission_id: &str, + pending: &PendingPermission, + reply: PermissionReply, +) -> Result<String, SandboxError> { + let metadata = pending.metadata.clone().unwrap_or(Value::Null); + let request_id = codex_request_id_from_metadata(&metadata) + .or_else(|| codex_request_id_from_string(permission_id)) + .ok_or_else(|| SandboxError::InvalidRequest { + message: "invalid codex permission request id".to_string(), + })?; + let request_kind = metadata + .get("codexRequestKind") + .and_then(Value::as_str) + .unwrap_or(""); + let response_value = match request_kind { + "commandExecution" => { + let decision = codex_command_decision_for_reply(reply); + let response = codex_schema::CommandExecutionRequestApprovalResponse { decision }; + serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest { + message: err.to_string(), + })? + } + "fileChange" => { + let decision = codex_file_change_decision_for_reply(reply); + let response = codex_schema::FileChangeRequestApprovalResponse { decision }; + serde_json::to_value(response).map_err(|err| SandboxError::InvalidRequest { + message: err.to_string(), + })? + } + _ => { + return Err(SandboxError::InvalidRequest { + message: "unsupported codex permission request".to_string(), + }); + } + }; + let response = codex_schema::JsonrpcResponse { + id: request_id, + result: response_value, + }; + serde_json::to_string(&response).map_err(|err| SandboxError::InvalidRequest { + message: err.to_string(), + }) +} + fn codex_request_id_from_metadata(metadata: &Value) -> Option<codex_schema::RequestId> { let metadata = metadata.as_object()?; let value = metadata.get("codexRequestId")?; @@ -6572,6 +9975,11 @@ fn parse_agent_line(agent: AgentId, line: &str, session_id: &str) -> Vec<EventCo .unwrap_or_else(|err| vec![agent_unparsed("pi", &err, value)]), Err(err) => vec![agent_unparsed("pi", &err.to_string(), value)], }, + AgentId::Cursor => vec![agent_unparsed( + "cursor", + "cursor agent does not parse streaming output", + value, + )], AgentId::Mock => vec![agent_unparsed( "mock", "mock agent does not parse streaming output", @@ -7325,6 +10733,10 @@ pub mod test_utils { model: None, variant: None, agent_version: None, + directory: None, + title: None, + mcp: None, + skills: None, }; let mut session = SessionState::new(session_id.to_string(), agent, &request).expect("session"); @@ -7615,13 +11027,13 @@ fn mock_command_conversions(prefix: &str, input: &str) -> Vec<EventConversion> { return vec![]; } let mut events = mock_command_events(prefix, trimmed); - if should_append_turn_completed(&events) { - events.push(turn_completed_event()); + if should_append_turn_ended(&events) { + events.push(turn_ended_event(None, None).synthetic()); } events } -fn should_append_turn_completed(events: &[EventConversion]) -> bool { +fn should_append_turn_ended(events: &[EventConversion]) -> bool { let Some(last) = events.last() else { return false; }; @@ -8470,34 +11882,16 @@ fn stream_turn_events( fn is_turn_terminal(event: &UniversalEvent, _agent: AgentId) -> bool { match event.event_type { - UniversalEventType::SessionEnded + UniversalEventType::TurnEnded + | UniversalEventType::SessionEnded | UniversalEventType::Error | UniversalEventType::AgentUnparsed | UniversalEventType::PermissionRequested | UniversalEventType::QuestionRequested => true, - UniversalEventType::ItemCompleted => { - let UniversalEventData::Item(ItemEventData { item }) = &event.data else { - return false; - }; - matches!(status_label(item), Some("turn.completed" | "session.idle")) - } _ => false, } } -fn status_label(item: &UniversalItem) -> Option<&str> { - if item.kind != ItemKind::Status { - return None; - } - item.content.iter().find_map(|part| { - if let ContentPart::Status { label, .. } = part { - Some(label.as_str()) - } else { - None - } - }) -} - fn to_sse_event(event: UniversalEvent) -> Event { Event::default() .json_data(&event) diff --git a/server/packages/sandbox-agent/src/ui.rs b/server/packages/sandbox-agent/src/ui.rs index 3bb475f..c2c27ef 100644 --- a/server/packages/sandbox-agent/src/ui.rs +++ b/server/packages/sandbox-agent/src/ui.rs @@ -15,7 +15,10 @@ pub fn is_enabled() -> bool { pub fn router() -> Router { if !INSPECTOR_ENABLED { - return Router::new(); + return Router::new() + .route("/ui", get(handle_not_built)) + .route("/ui/", get(handle_not_built)) + .route("/ui/*path", get(handle_not_built)); } Router::new() .route("/ui", get(handle_index)) @@ -23,6 +26,18 @@ pub fn router() -> Router { .route("/ui/*path", get(handle_path)) } +async fn handle_not_built() -> Response { + let body = "Inspector UI was not included in this build.\n\n\ + To enable it, build the frontend first:\n\n\ + cd frontend/packages/inspector && pnpm install && pnpm build\n\n\ + Then rebuild sandbox-agent without SANDBOX_AGENT_SKIP_INSPECTOR.\n"; + Response::builder() + .status(StatusCode::NOT_FOUND) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from(body)) + .unwrap() +} + async fn handle_index() -> Response { serve_path("") } diff --git a/server/packages/sandbox-agent/tests/common/http.rs b/server/packages/sandbox-agent/tests/common/http.rs index c2b40ae..f9268a1 100644 --- a/server/packages/sandbox-agent/tests/common/http.rs +++ b/server/packages/sandbox-agent/tests/common/http.rs @@ -1048,6 +1048,13 @@ async fn run_turn_stream_check(app: &Router, config: &TestAgentConfig) { create_session(app, config.agent, &session_id, test_permission_mode(config.agent)).await; let events = read_turn_stream_events(app, &session_id, Duration::from_secs(120)).await; + assert!( + events + .iter() + .any(|event| event.get("type").and_then(Value::as_str) == Some("turn.ended")), + "turn stream did not include turn.ended for {}", + config.agent + ); let events = truncate_after_first_stop(&events); assert!( !events.is_empty(), diff --git a/server/packages/sandbox-agent/tests/http/agent_endpoints.rs b/server/packages/sandbox-agent/tests/http/agent_endpoints.rs index de8b536..3f90bed 100644 --- a/server/packages/sandbox-agent/tests/http/agent_endpoints.rs +++ b/server/packages/sandbox-agent/tests/http/agent_endpoints.rs @@ -262,3 +262,150 @@ async fn pi_capabilities_and_models_expose_variants() { } } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_session_with_skill_sources() { + let app = TestApp::new(); + + // Create a temp skill directory with SKILL.md + let skill_dir = tempfile::tempdir().expect("create skill dir"); + let skill_path = skill_dir.path().join("my-test-skill"); + std::fs::create_dir_all(&skill_path).expect("create skill subdir"); + std::fs::write(skill_path.join("SKILL.md"), "# Test Skill\nA test skill.") + .expect("write SKILL.md"); + + // Create session with local skill source + let (status, payload) = send_json( + &app.app, + Method::POST, + "/v1/sessions/skill-test-session", + Some(json!({ + "agent": "mock", + "skills": { + "sources": [ + { + "type": "local", + "source": skill_dir.path().to_string_lossy() + } + ] + } + })), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "create session with skills: {payload}" + ); + assert!( + payload + .get("healthy") + .and_then(Value::as_bool) + .unwrap_or(false), + "session should be healthy" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_session_with_skill_sources_filter() { + let app = TestApp::new(); + + // Create a temp directory with two skills + let skill_dir = tempfile::tempdir().expect("create skill dir"); + let wanted = skill_dir.path().join("wanted-skill"); + let unwanted = skill_dir.path().join("unwanted-skill"); + std::fs::create_dir_all(&wanted).expect("create wanted dir"); + std::fs::create_dir_all(&unwanted).expect("create unwanted dir"); + std::fs::write(wanted.join("SKILL.md"), "# Wanted").expect("write wanted SKILL.md"); + std::fs::write(unwanted.join("SKILL.md"), "# Unwanted").expect("write unwanted SKILL.md"); + + // Create session with filter + let (status, payload) = send_json( + &app.app, + Method::POST, + "/v1/sessions/skill-filter-session", + Some(json!({ + "agent": "mock", + "skills": { + "sources": [ + { + "type": "local", + "source": skill_dir.path().to_string_lossy(), + "skills": ["wanted-skill"] + } + ] + } + })), + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "create session with skill filter: {payload}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_session_with_invalid_skill_source() { + let app = TestApp::new(); + + // Use a non-existent path + let (status, _payload) = send_json( + &app.app, + Method::POST, + "/v1/sessions/skill-invalid-session", + Some(json!({ + "agent": "mock", + "skills": { + "sources": [ + { + "type": "local", + "source": "/nonexistent/path/to/skills" + } + ] + } + })), + ) + .await; + // Should fail with a 4xx or 5xx error + assert_ne!( + status, + StatusCode::OK, + "session with invalid skill source should fail" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_session_with_skill_filter_no_match() { + let app = TestApp::new(); + + let skill_dir = tempfile::tempdir().expect("create skill dir"); + let skill_path = skill_dir.path().join("alpha"); + std::fs::create_dir_all(&skill_path).expect("create alpha dir"); + std::fs::write(skill_path.join("SKILL.md"), "# Alpha").expect("write SKILL.md"); + + // Filter for a skill that doesn't exist + let (status, _payload) = send_json( + &app.app, + Method::POST, + "/v1/sessions/skill-nomatch-session", + Some(json!({ + "agent": "mock", + "skills": { + "sources": [ + { + "type": "local", + "source": skill_dir.path().to_string_lossy(), + "skills": ["nonexistent"] + } + ] + } + })), + ) + .await; + assert_ne!( + status, + StatusCode::OK, + "session with no matching skills should fail" + ); +} diff --git a/server/packages/sandbox-agent/tests/http/fs_endpoints.rs b/server/packages/sandbox-agent/tests/http/fs_endpoints.rs new file mode 100644 index 0000000..d82d2f6 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/fs_endpoints.rs @@ -0,0 +1,270 @@ +// Filesystem HTTP endpoints. +include!("../common/http.rs"); + +use std::fs as stdfs; + +use tar::{Builder, Header}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_read_write_move_delete() { + let app = TestApp::new(); + let cwd = std::env::current_dir().expect("cwd"); + let temp = tempfile::tempdir_in(&cwd).expect("tempdir"); + + let dir_path = temp.path(); + let file_path = dir_path.join("hello.txt"); + let file_path_str = file_path.to_string_lossy().to_string(); + + let request = Request::builder() + .method(Method::PUT) + .uri(format!("/v1/fs/file?path={file_path_str}")) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::from("hello")) + .expect("write request"); + let (status, _headers, _payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "write file"); + + let request = Request::builder() + .method(Method::GET) + .uri(format!("/v1/fs/file?path={file_path_str}")) + .body(Body::empty()) + .expect("read request"); + let (status, headers, bytes) = send_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "read file"); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/octet-stream") + ); + assert_eq!(bytes.as_ref(), b"hello"); + + let entries_path = dir_path.to_string_lossy().to_string(); + let (status, entries) = send_json( + &app.app, + Method::GET, + &format!("/v1/fs/entries?path={entries_path}"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "list entries"); + let entry_list = entries.as_array().cloned().unwrap_or_default(); + let entry_names: Vec<String> = entry_list + .iter() + .filter_map(|entry| entry.get("name").and_then(|value| value.as_str())) + .map(|value| value.to_string()) + .collect(); + assert!(entry_names.contains(&"hello.txt".to_string())); + + let new_path = dir_path.join("moved.txt"); + let new_path_str = new_path.to_string_lossy().to_string(); + let (status, _payload) = send_json( + &app.app, + Method::POST, + "/v1/fs/move", + Some(json!({ + "from": file_path_str, + "to": new_path_str, + "overwrite": true + })), + ) + .await; + assert_eq!(status, StatusCode::OK, "move file"); + assert!(new_path.exists(), "moved file exists"); + + let (status, _payload) = send_json( + &app.app, + Method::DELETE, + &format!("/v1/fs/entry?path={}", new_path.to_string_lossy()), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "delete file"); + assert!(!new_path.exists(), "file deleted"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_upload_batch_tar() { + let app = TestApp::new(); + let cwd = std::env::current_dir().expect("cwd"); + let dest_dir = tempfile::tempdir_in(&cwd).expect("tempdir"); + + let mut builder = Builder::new(Vec::new()); + let mut tar_header = Header::new_gnu(); + let contents = b"hello"; + tar_header.set_size(contents.len() as u64); + tar_header.set_cksum(); + builder + .append_data(&mut tar_header, "a.txt", &contents[..]) + .expect("append tar entry"); + + let mut tar_header = Header::new_gnu(); + let contents = b"world"; + tar_header.set_size(contents.len() as u64); + tar_header.set_cksum(); + builder + .append_data(&mut tar_header, "nested/b.txt", &contents[..]) + .expect("append tar entry"); + + let tar_bytes = builder.into_inner().expect("tar bytes"); + + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "/v1/fs/upload-batch?path={}", + dest_dir.path().to_string_lossy() + )) + .header(header::CONTENT_TYPE, "application/x-tar") + .body(Body::from(tar_bytes)) + .expect("tar request"); + + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "upload batch"); + assert!(payload + .get("paths") + .and_then(|value| value.as_array()) + .map(|value| !value.is_empty()) + .unwrap_or(false)); + assert!(payload.get("truncated").and_then(|value| value.as_bool()) == Some(false)); + + let a_path = dest_dir.path().join("a.txt"); + let b_path = dest_dir.path().join("nested").join("b.txt"); + assert!(a_path.exists(), "a.txt extracted"); + assert!(b_path.exists(), "b.txt extracted"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_relative_paths_use_session_dir() { + let app = TestApp::new(); + + let session_id = "fs-session"; + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}"), + Some(json!({ "agent": "mock" })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create session"); + + let cwd = std::env::current_dir().expect("cwd"); + let temp = tempfile::tempdir_in(&cwd).expect("tempdir"); + let relative_dir = temp + .path() + .strip_prefix(&cwd) + .expect("strip prefix") + .to_path_buf(); + let relative_path = relative_dir.join("session.txt"); + + let request = Request::builder() + .method(Method::PUT) + .uri(format!( + "/v1/fs/file?session_id={session_id}&path={}", + relative_path.to_string_lossy() + )) + .header(header::CONTENT_TYPE, "application/octet-stream") + .body(Body::from("session")) + .expect("write request"); + let (status, _headers, _payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "write relative file"); + + let absolute_path = cwd.join(relative_path); + let content = stdfs::read_to_string(&absolute_path).expect("read file"); + assert_eq!(content, "session"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_upload_batch_truncates_paths() { + let app = TestApp::new(); + let cwd = std::env::current_dir().expect("cwd"); + let dest_dir = tempfile::tempdir_in(&cwd).expect("tempdir"); + + let mut builder = Builder::new(Vec::new()); + for index in 0..1030 { + let mut tar_header = Header::new_gnu(); + tar_header.set_size(0); + tar_header.set_cksum(); + let name = format!("file_{index}.txt"); + builder + .append_data(&mut tar_header, name, &[][..]) + .expect("append tar entry"); + } + let tar_bytes = builder.into_inner().expect("tar bytes"); + + let request = Request::builder() + .method(Method::POST) + .uri(format!( + "/v1/fs/upload-batch?path={}", + dest_dir.path().to_string_lossy() + )) + .header(header::CONTENT_TYPE, "application/x-tar") + .body(Body::from(tar_bytes)) + .expect("tar request"); + + let (status, _headers, payload) = send_json_request(&app.app, request).await; + assert_eq!(status, StatusCode::OK, "upload batch"); + let paths = payload + .get("paths") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + assert_eq!(paths.len(), 1024); + assert_eq!( + payload.get("truncated").and_then(|value| value.as_bool()), + Some(true) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_mkdir_stat_and_delete_directory() { + let app = TestApp::new(); + let cwd = std::env::current_dir().expect("cwd"); + let temp = tempfile::tempdir_in(&cwd).expect("tempdir"); + + let dir_path = temp.path().join("nested"); + let dir_path_str = dir_path.to_string_lossy().to_string(); + + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/fs/mkdir?path={dir_path_str}"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "mkdir"); + assert!(dir_path.exists(), "directory created"); + + let (status, stat) = send_json( + &app.app, + Method::GET, + &format!("/v1/fs/stat?path={dir_path_str}"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "stat directory"); + assert_eq!(stat["entryType"], "directory"); + + let file_path = dir_path.join("note.txt"); + stdfs::write(&file_path, "content").expect("write file"); + let file_path_str = file_path.to_string_lossy().to_string(); + + let (status, stat) = send_json( + &app.app, + Method::GET, + &format!("/v1/fs/stat?path={file_path_str}"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "stat file"); + assert_eq!(stat["entryType"], "file"); + + let status = send_status( + &app.app, + Method::DELETE, + &format!("/v1/fs/entry?path={dir_path_str}&recursive=true"), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "delete directory"); + assert!(!dir_path.exists(), "directory deleted"); +} diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_codex.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_codex.snap new file mode 100644 index 0000000..d01df04 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_codex.snap @@ -0,0 +1,6 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 145 +expression: snapshot_status(status) +--- +status: 204 diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_opencode.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_opencode.snap new file mode 100644 index 0000000..1b82694 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_opencode.snap @@ -0,0 +1,5 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +expression: snapshot_status(status) +--- +status: 204 diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap new file mode 100644 index 0000000..cc870bc --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap @@ -0,0 +1,13 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 185 +expression: "normalize_agent_models(&models, config.agent)" +--- +defaultInList: true +defaultModel: amp-default +hasDefault: true +hasVariants: false +ids: + - amp-default +modelCount: 1 +nonEmpty: true diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap new file mode 100644 index 0000000..04e00af --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap @@ -0,0 +1,9 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 185 +expression: "normalize_agent_models(&models, config.agent)" +--- +defaultInList: true +hasDefault: true +hasVariants: "<redacted>" +nonEmpty: true diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap new file mode 100644 index 0000000..cd3164e --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap @@ -0,0 +1,9 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 185 +expression: "normalize_agent_models(&models, config.agent)" +--- +defaultInList: true +hasDefault: true +hasVariants: false +nonEmpty: true diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap new file mode 100644 index 0000000..636d1bf --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap @@ -0,0 +1,8 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +expression: "normalize_agent_models(&models, config.agent)" +--- +defaultInList: true +hasDefault: true +hasVariants: "<redacted>" +nonEmpty: true diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_amp.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_amp.snap new file mode 100644 index 0000000..98a948b --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_amp.snap @@ -0,0 +1,9 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 162 +expression: normalize_agent_modes(&modes) +--- +modes: + - description: true + id: build + name: Build diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_codex.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_codex.snap new file mode 100644 index 0000000..400b572 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_codex.snap @@ -0,0 +1,12 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 162 +expression: normalize_agent_modes(&modes) +--- +modes: + - description: true + id: build + name: Build + - description: true + id: plan + name: Plan diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_opencode.snap b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_opencode.snap new file mode 100644 index 0000000..8a9108b --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_modes_opencode.snap @@ -0,0 +1,14 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +expression: normalize_agent_modes(&modes) +--- +modes: + - description: true + id: build + name: Build + - description: true + id: custom + name: Custom + - description: true + id: plan + name: Plan diff --git a/server/packages/sandbox-agent/tests/http_endpoints.rs b/server/packages/sandbox-agent/tests/http_endpoints.rs index a443a95..b9987f6 100644 --- a/server/packages/sandbox-agent/tests/http_endpoints.rs +++ b/server/packages/sandbox-agent/tests/http_endpoints.rs @@ -1,2 +1,4 @@ #[path = "http/agent_endpoints.rs"] mod agent_endpoints; +#[path = "http/fs_endpoints.rs"] +mod fs_endpoints; diff --git a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts index 2140ef3..367e6e8 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts @@ -17,6 +17,25 @@ describe("OpenCode-compatible Event Streaming", () => { let handle: SandboxAgentHandle; let client: OpencodeClient; + function uniqueSessionId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + } + + async function initSessionViaHttp( + sessionId: string, + body: Record<string, unknown> + ): Promise<void> { + const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, { + method: "POST", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + expect(response.ok).toBe(true); + } + beforeAll(async () => { await buildSandboxAgent(); }); @@ -144,6 +163,182 @@ describe("OpenCode-compatible Event Streaming", () => { expect(response.data).toBeDefined(); }); + + it("should be idle before first prompt and return to idle after prompt completion", async () => { + const sessionId = uniqueSessionId("status-idle"); + await initSessionViaHttp(sessionId, { providerID: "mock", modelID: "mock" }); + + const initial = await client.session.status(); + expect(initial.data?.[sessionId]?.type).toBe("idle"); + + const eventStream = await client.event.subscribe(); + const statuses: string[] = []; + + const collectIdle = new Promise<void>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for session.idle")), + 15_000 + ); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event?.properties?.sessionID !== sessionId) continue; + if (event.type === "session.status") { + const statusType = event?.properties?.status?.type; + if (typeof statusType === "string") statuses.push(statusType); + } + if (event.type === "session.idle") { + clearTimeout(timeout); + resolve(); + break; + } + } + } catch { + // Stream ended + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "Say hello" }], + }, + }); + + await collectIdle; + + expect(statuses).toContain("busy"); + expect(statuses.filter((status) => status === "busy")).toHaveLength(1); + const finalStatus = await client.session.status(); + expect(finalStatus.data?.[sessionId]?.type).toBe("idle"); + }); + + it("should report busy via /session/status while turn is in flight", async () => { + const sessionId = uniqueSessionId("status-busy-inflight"); + await initSessionViaHttp(sessionId, { providerID: "mock", modelID: "mock" }); + + const eventStream = await client.event.subscribe(); + let busySnapshot: string | undefined; + + const waitForIdle = new Promise<void>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for busy status snapshot + session.idle")), + 15_000 + ); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event?.properties?.sessionID !== sessionId) continue; + + if (event.type === "session.status" && event?.properties?.status?.type === "busy" && !busySnapshot) { + for (let attempt = 0; attempt < 5; attempt += 1) { + const status = await client.session.status(); + busySnapshot = status.data?.[sessionId]?.type; + if (busySnapshot === "busy") { + break; + } + await new Promise((resolveAttempt) => setTimeout(resolveAttempt, 20)); + } + } + + if (event.type === "session.idle") { + clearTimeout(timeout); + resolve(); + break; + } + } + } catch { + // Stream ended + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "tool" }], + }, + }); + + await waitForIdle; + expect(busySnapshot).toBe("busy"); + }); + + it("should emit session.error and return idle for failed turns", async () => { + const sessionId = uniqueSessionId("status-error"); + await initSessionViaHttp(sessionId, { providerID: "mock", modelID: "mock" }); + + const eventStream = await client.event.subscribe(); + const errors: any[] = []; + const idles: any[] = []; + + const collectErrorAndIdle = new Promise<void>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("Timed out waiting for session.error + session.idle")), + 15_000 + ); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event?.properties?.sessionID !== sessionId) continue; + if (event.type === "session.error") { + errors.push(event); + } + if (event.type === "session.idle") { + idles.push(event); + } + if (errors.length > 0 && idles.length > 0) { + clearTimeout(timeout); + resolve(); + break; + } + } + } catch { + // Stream ended + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "error" }], + }, + }); + + await collectErrorAndIdle; + + expect(errors.length).toBeGreaterThan(0); + const finalStatus = await client.session.status(); + expect(finalStatus.data?.[sessionId]?.type).toBe("idle"); + }); + + it("should report idle for newly initialized sessions across connected providers", async () => { + const providersResponse = await fetch(`${handle.baseUrl}/opencode/provider`, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(providersResponse.ok).toBe(true); + const providersData = await providersResponse.json(); + + const connected: string[] = providersData.connected ?? []; + const defaults: Record<string, string> = providersData.default ?? {}; + + for (const providerID of connected) { + const modelID = defaults[providerID]; + if (!modelID) continue; + + const sessionId = uniqueSessionId(`status-${providerID.replace(/[^a-zA-Z0-9_-]/g, "_")}`); + + await initSessionViaHttp(sessionId, { providerID, modelID }); + + const status = await client.session.status(); + expect(status.data?.[sessionId]?.type).toBe("idle"); + } + }); }); describe("session.idle count", () => { @@ -238,5 +433,85 @@ describe("OpenCode-compatible Event Streaming", () => { ); expect(toolParts.length).toBeGreaterThan(0); }); + + it("should preserve part order based on first stream appearance", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + + const eventStream = await client.event.subscribe(); + const seenPartIds: string[] = []; + let targetMessageId: string | null = null; + + const collectIdle = new Promise<void>((resolve, reject) => { + let lingerTimer: ReturnType<typeof setTimeout> | null = null; + const timeout = setTimeout(() => reject(new Error("Timed out waiting for session.idle")), 15_000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event?.properties?.sessionID !== sessionId) { + continue; + } + + if (event.type === "message.part.updated") { + const messageId = event.properties?.messageID; + const partId = event.properties?.part?.id; + const partType = event.properties?.part?.type; + if (!targetMessageId && partType === "tool" && typeof messageId === "string") { + targetMessageId = messageId; + } + if ( + targetMessageId && + messageId === targetMessageId && + typeof partId === "string" && + !seenPartIds.includes(partId) + ) { + seenPartIds.push(partId); + } + } + + if (event.type === "session.idle") { + if (!lingerTimer) { + lingerTimer = setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 500); + } + } + } + } catch { + // Stream ended + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "tool" }], + }, + }); + + await collectIdle; + + expect(targetMessageId).toBeTruthy(); + expect(seenPartIds.length).toBeGreaterThan(0); + + const response = await fetch( + `${handle.baseUrl}/opencode/session/${sessionId}/message/${targetMessageId}`, + { + headers: { Authorization: `Bearer ${handle.token}` }, + } + ); + expect(response.ok).toBe(true); + const message = (await response.json()) as any; + const returnedPartIds = (message?.parts ?? []) + .map((part: any) => part?.id) + .filter((id: any) => typeof id === "string"); + + const expectedSet = new Set(seenPartIds); + const returnedFiltered = returnedPartIds.filter((id: string) => expectedSet.has(id)); + expect(returnedFiltered).toEqual(seenPartIds); + }); }); }); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts index 0742da7..2b38d3b 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts @@ -53,6 +53,37 @@ describe("OpenCode-compatible Permission API", () => { throw new Error("Timed out waiting for permission request"); } + async function waitForCondition( + check: () => boolean | Promise<boolean>, + timeoutMs = 10_000, + intervalMs = 100, + ) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await check()) { + return; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("Timed out waiting for condition"); + } + + async function waitForValue<T>( + getValue: () => T | undefined | Promise<T | undefined>, + timeoutMs = 10_000, + intervalMs = 100, + ): Promise<T> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = await getValue(); + if (value !== undefined) { + return value; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("Timed out waiting for value"); + } + describe("permission.reply (global)", () => { it("should receive permission.asked and reply via global endpoint", async () => { await client.session.prompt({ @@ -71,6 +102,108 @@ describe("OpenCode-compatible Permission API", () => { }); expect(response.error).toBeUndefined(); }); + + it("should emit permission.replied with always when reply is always", async () => { + const eventStream = await client.event.subscribe(); + const repliedEventPromise = new Promise<any>((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for permission.replied")), 15_000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event.type === "permission.replied") { + clearTimeout(timeout); + resolve(event); + break; + } + } + } catch (err) { + clearTimeout(timeout); + reject(err); + } + })(); + }); + + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const asked = await waitForPermissionRequest(); + const requestId = asked?.id; + expect(requestId).toBeDefined(); + + const response = await client.permission.reply({ + requestID: requestId, + reply: "always", + }); + expect(response.error).toBeUndefined(); + + const replied = await repliedEventPromise; + expect(replied?.properties?.requestID).toBe(requestId); + expect(replied?.properties?.reply).toBe("always"); + }); + + it("should auto-reply subsequent matching permissions after always", async () => { + const eventStream = await client.event.subscribe(); + const repliedEvents: any[] = []; + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event.type === "permission.replied") { + repliedEvents.push(event); + } + } + } catch { + // Stream can end during test teardown. + } + })(); + + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const firstAsked = await waitForPermissionRequest(); + const firstRequestId = firstAsked?.id; + expect(firstRequestId).toBeDefined(); + + const firstReply = await client.permission.reply({ + requestID: firstRequestId, + reply: "always", + }); + expect(firstReply.error).toBeUndefined(); + + await waitForCondition(() => + repliedEvents.some( + (event) => + event?.properties?.requestID === firstRequestId && + event?.properties?.reply === "always", + ), + ); + + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const autoReplyEvent = await waitForValue(() => + repliedEvents.find( + (event) => + event?.properties?.requestID !== firstRequestId && + event?.properties?.reply === "always", + ), + ); + const autoRequestId = autoReplyEvent?.properties?.requestID; + expect(autoRequestId).toBeDefined(); + + await waitForCondition(async () => { + const list = await client.permission.list(); + return !(list.data ?? []).some((item) => item?.id === autoRequestId); + }); + }); }); describe("postSessionIdPermissionsPermissionId (session)", () => { diff --git a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts index c778691..94c8762 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts @@ -20,6 +20,90 @@ describe("OpenCode-compatible Session API", () => { let handle: SandboxAgentHandle; let client: OpencodeClient; + async function createSessionViaHttp(body: Record<string, unknown>) { + const response = await fetch(`${handle.baseUrl}/opencode/session`, { + method: "POST", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + expect(response.ok).toBe(true); + return response.json(); + } + + async function getBackingSessionPermissionMode(sessionId: string) { + const response = await fetch(`${handle.baseUrl}/v1/sessions`, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + const data = await response.json(); + const session = (data.sessions ?? []).find((item: any) => item.sessionId === sessionId); + return session?.permissionMode; + } + + async function getBackingSession(sessionId: string) { + const response = await fetch(`${handle.baseUrl}/v1/sessions`, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + const data = await response.json(); + return (data.sessions ?? []).find((item: any) => item.sessionId === sessionId); + } + + async function initSessionViaHttp( + sessionId: string, + body: Record<string, unknown> = {} + ): Promise<{ response: Response; data: any }> { + const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/init`, { + method: "POST", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + const data = await response.json(); + return { response, data }; + } + + async function listMessagesViaHttp(sessionId: string): Promise<any[]> { + const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}/message`, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + return response.json(); + } + + async function getProvidersViaHttp(): Promise<{ + connected: string[]; + default: Record<string, string>; + }> { + const response = await fetch(`${handle.baseUrl}/opencode/provider`, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + const data = await response.json(); + return { + connected: data.connected ?? [], + default: data.default ?? {}, + }; + } + + async function waitForAssistantMessage(sessionId: string, timeoutMs = 10_000): Promise<any> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const messages = await listMessagesViaHttp(sessionId); + const assistant = messages.find((message) => message?.info?.role === "assistant"); + if (assistant) { + return assistant; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Timed out waiting for assistant message"); + } + beforeAll(async () => { // Build the binary if needed await buildSandboxAgent(); @@ -63,6 +147,42 @@ describe("OpenCode-compatible Session API", () => { expect(session1.data?.id).not.toBe(session2.data?.id); }); + + it("should pass permissionMode bypass to backing session", async () => { + const session = await createSessionViaHttp({ permissionMode: "bypass" }); + const sessionId = session.id as string; + expect(sessionId).toBeDefined(); + + const prompt = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "hello" }], + }, + }); + expect(prompt.error).toBeUndefined(); + + const permissionMode = await getBackingSessionPermissionMode(sessionId); + expect(permissionMode).toBe("bypass"); + }); + + it("should accept permission_mode alias and pass bypass to backing session", async () => { + const session = await createSessionViaHttp({ permission_mode: "bypass" }); + const sessionId = session.id as string; + expect(sessionId).toBeDefined(); + + const prompt = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "hello" }], + }, + }); + expect(prompt.error).toBeUndefined(); + + const permissionMode = await getBackingSessionPermissionMode(sessionId); + expect(permissionMode).toBe("bypass"); + }); }); describe("session.list", () => { @@ -86,6 +206,78 @@ describe("OpenCode-compatible Session API", () => { }); }); + describe("session.init", () => { + it("should accept empty init body and keep message flow working", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + + const initialized = await initSessionViaHttp(sessionId, {}); + expect(initialized.response.ok).toBe(true); + expect(initialized.data).toBe(true); + + const prompt = await client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: "hello after init" }], + } as any, + }); + expect(prompt.error).toBeUndefined(); + + const assistant = await waitForAssistantMessage(sessionId); + expect(assistant?.info?.role).toBe("assistant"); + }); + + it("should apply explicit init model selection to the backing session", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + + const initialized = await initSessionViaHttp(sessionId, { + providerID: "codex", + modelID: "gpt-5", + messageID: "msg_init", + }); + expect(initialized.response.ok).toBe(true); + expect(initialized.data).toBe(true); + + const backingSession = await getBackingSession(sessionId); + expect(backingSession?.agent).toBe("codex"); + expect(backingSession?.model).toBe("gpt-5"); + }); + + it("should accept first prompt after codex init without session-not-found", async () => { + const providers = await getProvidersViaHttp(); + if (!providers.connected.includes("codex")) { + return; + } + const codexDefaultModel = providers.default?.codex; + if (!codexDefaultModel) { + return; + } + + const session = await client.session.create(); + const sessionId = session.data?.id!; + expect(sessionId).toBeDefined(); + + const initialized = await initSessionViaHttp(sessionId, { + providerID: "codex", + modelID: codexDefaultModel, + }); + expect(initialized.response.ok).toBe(true); + expect(initialized.data).toBe(true); + + const prompt = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "codex", modelID: codexDefaultModel }, + parts: [{ type: "text", text: "hello after codex init" }], + }, + }); + expect(prompt.error).toBeUndefined(); + }); + }); + describe("session.get", () => { it("should retrieve session by ID", async () => { const created = await client.session.create({ body: { title: "Test" } }); @@ -99,6 +291,41 @@ describe("OpenCode-compatible Session API", () => { expect(response.data?.title).toBe("Test"); }); + it("should keep session.get available during first prompt after /new-style creation", async () => { + const providers = await getProvidersViaHttp(); + const providerId = providers.connected.find( + (provider) => provider !== "mock" && typeof providers.default?.[provider] === "string" + ); + if (!providerId) { + return; + } + const modelId = providers.default?.[providerId]; + if (!modelId) { + return; + } + + const created = await client.session.create({ body: { title: "Race Repro" } }); + const sessionId = created.data?.id!; + expect(sessionId).toBeDefined(); + + const promptPromise = client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: providerId, modelID: modelId }, + parts: [{ type: "text", text: "hello after /new" }], + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + const getDuringPrompt = await client.session.get({ path: { id: sessionId } }); + expect(getDuringPrompt.error).toBeUndefined(); + expect(getDuringPrompt.data?.id).toBe(sessionId); + + // Best-effort settle; this assertion focuses on availability during the in-flight turn. + await promptPromise; + }); + it("should return error for non-existent session", async () => { const response = await client.session.get({ path: { id: "non-existent-session-id" }, @@ -121,6 +348,34 @@ describe("OpenCode-compatible Session API", () => { const response = await client.session.get({ path: { id: sessionId } }); expect(response.data?.title).toBe("Updated"); }); + + it("should reject model changes after session creation", async () => { + const created = await client.session.create({ body: { title: "Original" } }); + const sessionId = created.data?.id!; + + const payloads = [ + { providerID: "codex", modelID: "gpt-5" }, + { provider_id: "codex", model_id: "gpt-5" }, + { providerId: "codex", modelId: "gpt-5" }, + ]; + + for (const payload of payloads) { + const response = await fetch(`${handle.baseUrl}/opencode/session/${sessionId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data?.errors?.[0]?.message).toBe( + "OpenCode compatibility currently does not support changing the model after creating a session. Export with /export and load in to a new session." + ); + } + }); }); describe("session.delete", () => { diff --git a/server/packages/sandbox-agent/tests/sessions/permissions.rs b/server/packages/sandbox-agent/tests/sessions/permissions.rs index a114236..78c6c23 100644 --- a/server/packages/sandbox-agent/tests/sessions/permissions.rs +++ b/server/packages/sandbox-agent/tests/sessions/permissions.rs @@ -80,3 +80,193 @@ async fn permission_flow_snapshots() { } } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_reply_always_sets_accept_for_session_status() { + let app = TestApp::new(); + install_agent(&app.app, AgentId::Mock).await; + + let session_id = "perm-always-mock"; + create_session(&app.app, AgentId::Mock, session_id, "plan").await; + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PERMISSION_PROMPT })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "send permission prompt"); + + let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + find_permission_id(events).is_some() || should_stop(events) + }) + .await; + let permission_id = find_permission_id(&events).expect("permission.requested missing"); + + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"), + Some(json!({ "reply": "always" })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "reply permission always"); + + let resolved_events = + poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + events.iter().any(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(permission_id.as_str()) + }) + }) + .await; + + let resolved = resolved_events + .iter() + .rev() + .find(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(permission_id.as_str()) + }) + .expect("permission.resolved missing"); + let status = resolved + .get("data") + .and_then(|data| data.get("status")) + .and_then(Value::as_str); + assert_eq!(status, Some("accept_for_session")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_reply_always_auto_approves_subsequent_permissions() { + let app = TestApp::new(); + install_agent(&app.app, AgentId::Mock).await; + + let session_id = "perm-always-auto-mock"; + create_session(&app.app, AgentId::Mock, session_id, "plan").await; + + let first_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PERMISSION_PROMPT })), + ) + .await; + assert_eq!( + first_status, + StatusCode::NO_CONTENT, + "send first permission prompt" + ); + + let first_events = + poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + find_permission_id(events).is_some() || should_stop(events) + }) + .await; + let first_permission_id = + find_permission_id(&first_events).expect("first permission.requested missing"); + + let reply_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/permissions/{first_permission_id}/reply"), + Some(json!({ "reply": "always" })), + ) + .await; + assert_eq!( + reply_status, + StatusCode::NO_CONTENT, + "reply first permission always" + ); + + let second_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PERMISSION_PROMPT })), + ) + .await; + assert_eq!( + second_status, + StatusCode::NO_CONTENT, + "send second permission prompt" + ); + + let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + let requested_ids = events + .iter() + .filter_map(|event| { + if event.get("type").and_then(Value::as_str) != Some("permission.requested") { + return None; + } + event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + .collect::<Vec<_>>(); + if requested_ids.len() < 2 { + return false; + } + let second_permission_id = &requested_ids[1]; + events.iter().any(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(second_permission_id.as_str()) + && event + .get("data") + .and_then(|data| data.get("status")) + .and_then(Value::as_str) + == Some("accept_for_session") + }) + }) + .await; + + let requested_ids = events + .iter() + .filter_map(|event| { + if event.get("type").and_then(Value::as_str) != Some("permission.requested") { + return None; + } + event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + .collect::<Vec<_>>(); + assert!( + requested_ids.len() >= 2, + "expected at least two permission.requested events" + ); + let second_permission_id = &requested_ids[1]; + + let second_resolved = events.iter().any(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(second_permission_id.as_str()) + && event + .get("data") + .and_then(|data| data.get("status")) + .and_then(Value::as_str) + == Some("accept_for_session") + }); + assert!( + second_resolved, + "second permission should auto-resolve as accept_for_session" + ); +} diff --git a/server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs b/server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs index cfa22d4..14cfdac 100644 --- a/server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs +++ b/server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs @@ -82,6 +82,46 @@ async fn http_events_snapshots() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn accept_edits_noop_for_non_claude() { + let app = TestApp::new(); + let session_id = "accept-edits-noop"; + + let (status, _) = send_json( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}"), + Some(json!({ + "agent": AgentId::Mock.as_str(), + "permissionMode": "acceptEdits" + })), + ) + .await; + assert_eq!(status, StatusCode::OK, "create session with acceptEdits"); + + let (status, sessions) = send_json(&app.app, Method::GET, "/v1/sessions", None).await; + assert_eq!(status, StatusCode::OK, "list sessions"); + + let sessions = sessions + .get("sessions") + .and_then(Value::as_array) + .expect("sessions list"); + let session = sessions + .iter() + .find(|entry| { + entry + .get("sessionId") + .and_then(Value::as_str) + .is_some_and(|id| id == session_id) + }) + .expect("created session"); + let permission_mode = session + .get("permissionMode") + .and_then(Value::as_str) + .expect("permissionMode"); + assert_eq!(permission_mode, "default"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sse_events_snapshots() { let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents"); @@ -125,6 +165,11 @@ async fn turn_stream_route() { let configs = test_agents_from_env().expect("configure SANDBOX_TEST_AGENTS or install agents"); for config in &configs { + // OpenCode's embedded bun can hang while installing plugins, which blocks turn streaming. + // OpenCode turn behavior is covered by the dedicated opencode-compat suite. + if config.agent == AgentId::Opencode { + continue; + } let app = TestApp::new(); let capabilities = fetch_capabilities(&app.app).await; let caps = capabilities @@ -137,6 +182,34 @@ async fn turn_stream_route() { } } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn turn_stream_emits_turn_lifecycle_for_mock() { + let app = TestApp::new(); + install_agent(&app.app, AgentId::Mock).await; + + let session_id = "turn-lifecycle-mock"; + create_session( + &app.app, + AgentId::Mock, + session_id, + test_permission_mode(AgentId::Mock), + ) + .await; + + let events = read_turn_stream_events(&app.app, session_id, Duration::from_secs(30)).await; + let started_count = events + .iter() + .filter(|event| event.get("type").and_then(Value::as_str) == Some("turn.started")) + .count(); + let ended_count = events + .iter() + .filter(|event| event.get("type").and_then(Value::as_str) == Some("turn.ended")) + .count(); + + assert_eq!(started_count, 1, "expected exactly one turn.started event"); + assert_eq!(ended_count, 1, "expected exactly one turn.ended event"); +} + async fn run_concurrency_snapshot(app: &Router, config: &TestAgentConfig) { let _guard = apply_credentials(&config.credentials); install_agent(app, config.agent).await; diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap index 5b6e01d..0d52eb2 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs +assertion_line: 15 expression: value --- first: @@ -15,19 +16,13 @@ first: status: in_progress seq: 2 type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - item: content_types: @@ -35,13 +30,13 @@ first: kind: message role: assistant status: in_progress - seq: 5 + seq: 4 type: item.started - delta: delta: "<redacted>" item_id: "<redacted>" native_item_id: "<redacted>" - seq: 6 + seq: 5 type: item.delta - item: content_types: @@ -49,7 +44,7 @@ first: kind: message role: assistant status: completed - seq: 7 + seq: 6 type: item.completed second: - item: @@ -60,19 +55,13 @@ second: status: in_progress seq: 1 type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 2 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 3 + seq: 2 type: item.completed - item: content_types: @@ -80,13 +69,13 @@ second: kind: message role: assistant status: in_progress - seq: 4 + seq: 3 type: item.started - delta: delta: "<redacted>" item_id: "<redacted>" native_item_id: "<redacted>" - seq: 5 + seq: 4 type: item.delta - item: content_types: @@ -94,5 +83,5 @@ second: kind: message role: assistant status: completed - seq: 6 + seq: 5 type: item.completed diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new deleted file mode 100644 index d6e25cf..0000000 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new +++ /dev/null @@ -1,108 +0,0 @@ ---- -source: server/packages/sandbox-agent/tests/sessions/multi_turn.rs -assertion_line: 15 -expression: value ---- -first: - - metadata: true - seq: 1 - session: started - type: session.started - - item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - - item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed - - item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 6 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 7 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 8 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 9 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 10 - type: item.delta -second: - - item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 1 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 2 - type: item.delta - - item: - content_types: - - text - kind: message - role: user - status: completed - seq: 3 - type: item.completed - - item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 4 - type: item.started - - item: - content_types: [] - kind: message - role: assistant - status: completed - seq: 5 - type: item.completed diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap index 3edf1f8..da134af 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/permissions.rs +assertion_line: 12 expression: value --- - metadata: true @@ -14,23 +15,17 @@ expression: value status: in_progress seq: 2 type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - permission: action: command_execution id: "<redacted>" status: requested - seq: 5 + seq: 4 type: permission.requested diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new deleted file mode 100644 index 82e9c6c..0000000 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new +++ /dev/null @@ -1,69 +0,0 @@ ---- -source: server/packages/sandbox-agent/tests/sessions/permissions.rs -assertion_line: 12 -expression: value ---- -- metadata: true - seq: 1 - session: started - type: session.started -- item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta -- item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed -- item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 6 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 7 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 8 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 9 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 10 - type: item.delta diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reject_events_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reject_events_mock.snap index 4559351..7fba50f 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reject_events_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reject_events_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/questions.rs +assertion_line: 12 expression: value --- - metadata: true @@ -14,23 +15,17 @@ expression: value status: in_progress seq: 2 type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - question: id: "<redacted>" options: 2 status: requested - seq: 5 + seq: 4 type: question.requested diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap index 4559351..7fba50f 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/questions.rs +assertion_line: 12 expression: value --- - metadata: true @@ -14,23 +15,17 @@ expression: value status: in_progress seq: 2 type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - question: id: "<redacted>" options: 2 status: requested - seq: 5 + seq: 4 type: question.requested diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new deleted file mode 100644 index 0428c57..0000000 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new +++ /dev/null @@ -1,137 +0,0 @@ ---- -source: server/packages/sandbox-agent/tests/sessions/questions.rs -assertion_line: 12 -expression: value ---- -- metadata: true - seq: 1 - session: started - type: session.started -- item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta -- item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed -- item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 6 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 7 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 8 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 9 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 10 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 11 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 12 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 13 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 14 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 15 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 16 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 17 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 18 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 19 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 20 - type: item.delta -- item: - content_types: - - text - kind: message - role: assistant - status: completed - seq: 21 - type: item.completed diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap index b08f8ac..e97635e 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs +assertion_line: 12 expression: value --- session_a: @@ -15,19 +16,13 @@ session_a: status: in_progress seq: 2 type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - item: content_types: @@ -35,13 +30,13 @@ session_a: kind: message role: assistant status: in_progress - seq: 5 + seq: 4 type: item.started - delta: delta: "<redacted>" item_id: "<redacted>" native_item_id: "<redacted>" - seq: 6 + seq: 5 type: item.delta - item: content_types: @@ -49,7 +44,7 @@ session_a: kind: message role: assistant status: completed - seq: 7 + seq: 6 type: item.completed session_b: - metadata: true @@ -64,19 +59,13 @@ session_b: status: in_progress seq: 2 type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - item: content_types: @@ -84,13 +73,13 @@ session_b: kind: message role: assistant status: in_progress - seq: 5 + seq: 4 type: item.started - delta: delta: "<redacted>" item_id: "<redacted>" native_item_id: "<redacted>" - seq: 6 + seq: 5 type: item.delta - item: content_types: @@ -98,5 +87,5 @@ session_b: kind: message role: assistant status: completed - seq: 7 + seq: 6 type: item.completed diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap.new deleted file mode 100644 index 38d2285..0000000 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap.new +++ /dev/null @@ -1,117 +0,0 @@ ---- -source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs -assertion_line: 12 -expression: value ---- -session_a: - - metadata: true - seq: 1 - session: started - type: session.started - - item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - - item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed - - item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 6 - type: item.delta -session_b: - - metadata: true - seq: 1 - session: started - type: session.started - - item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - - item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed - - item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 6 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 7 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 8 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 9 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 10 - type: item.delta - - delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 11 - type: item.delta diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap similarity index 88% rename from server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap.new rename to server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap index b63c3a7..8a578ee 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap.new +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap @@ -1,6 +1,5 @@ --- source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs -assertion_line: 12 expression: value --- healthy: true diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@sessions_list_mock-2.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@sessions_list_mock-2.snap new file mode 100644 index 0000000..fba833a --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@sessions_list_mock-2.snap @@ -0,0 +1,6 @@ +--- +source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs +expression: value +--- +hasExpectedFields: true +sessionCount: 1 diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap index d7a4317..e82d105 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/../common/http.rs +assertion_line: 1001 expression: normalized --- - metadata: true @@ -14,19 +15,13 @@ expression: normalized status: in_progress seq: 2 type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - item: content_types: @@ -34,13 +29,13 @@ expression: normalized kind: message role: assistant status: in_progress - seq: 5 + seq: 4 type: item.started - delta: delta: "<redacted>" item_id: "<redacted>" native_item_id: "<redacted>" - seq: 6 + seq: 5 type: item.delta - item: content_types: @@ -48,5 +43,5 @@ expression: normalized kind: message role: assistant status: completed - seq: 7 + seq: 6 type: item.completed diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap.new deleted file mode 100644 index 158b730..0000000 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap.new +++ /dev/null @@ -1,69 +0,0 @@ ---- -source: server/packages/sandbox-agent/tests/sessions/../common/http.rs -assertion_line: 1001 -expression: normalized ---- -- metadata: true - seq: 1 - session: started - type: session.started -- item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta -- item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed -- item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 6 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 7 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 8 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 9 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 10 - type: item.delta diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap index d7a4317..baff647 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap @@ -1,5 +1,6 @@ --- source: server/packages/sandbox-agent/tests/sessions/../common/http.rs +assertion_line: 1039 expression: normalized --- - metadata: true @@ -14,19 +15,13 @@ expression: normalized status: in_progress seq: 2 type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 3 - type: item.delta - item: content_types: - text kind: message role: user status: completed - seq: 4 + seq: 3 type: item.completed - item: content_types: @@ -34,13 +29,13 @@ expression: normalized kind: message role: assistant status: in_progress - seq: 5 + seq: 4 type: item.started - delta: delta: "<redacted>" item_id: "<redacted>" native_item_id: "<redacted>" - seq: 6 + seq: 5 type: item.delta - item: content_types: @@ -48,5 +43,5 @@ expression: normalized kind: message role: assistant status: completed - seq: 7 + seq: 6 type: item.completed diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap.new index 8df795c..e3cfcc3 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap.new +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_sse_events_snapshot@sse_events_mock.snap.new @@ -7,20 +7,16 @@ expression: normalized seq: 1 session: started type: session.started +- seq: 2 + type: turn.started - item: content_types: - text kind: message role: user status: in_progress - seq: 2 - type: item.started -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" seq: 3 - type: item.delta + type: item.started - item: content_types: - text @@ -43,33 +39,3 @@ expression: normalized native_item_id: "<redacted>" seq: 6 type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 7 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 8 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 9 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 10 - type: item.delta -- delta: - delta: "<redacted>" - item_id: "<redacted>" - native_item_id: "<redacted>" - seq: 11 - type: item.delta diff --git a/server/packages/universal-agent-schema/src/agents/amp.rs b/server/packages/universal-agent-schema/src/agents/amp.rs index 7134896..a305796 100644 --- a/server/packages/universal-agent-schema/src/agents/amp.rs +++ b/server/packages/universal-agent-schema/src/agents/amp.rs @@ -4,7 +4,7 @@ use serde_json::Value; use crate::amp as schema; use crate::{ - turn_completed_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, + turn_ended_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy, UniversalEventData, UniversalEventType, UniversalItem, }; @@ -21,6 +21,72 @@ pub fn event_to_universal( ) -> Result<Vec<EventConversion>, String> { let mut events = Vec::new(); match event.type_ { + // System init message - contains metadata like cwd, tools, session_id + // We skip this as it's not a user-facing event + schema::StreamJsonMessageType::System => {} + // User message - extract content from the nested message field + schema::StreamJsonMessageType::User => { + if !event.message.is_empty() { + let text = event + .message + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let item = UniversalItem { + item_id: next_temp_id("tmp_amp_user"), + native_item_id: event.session_id.clone(), + parent_id: None, + kind: ItemKind::Message, + role: Some(ItemRole::User), + content: vec![ContentPart::Text { text: text.clone() }], + status: ItemStatus::Completed, + }; + events.extend(message_events(item, text)); + } + } + // Assistant message - extract content from the nested message field + schema::StreamJsonMessageType::Assistant => { + if !event.message.is_empty() { + let text = event + .message + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let item = UniversalItem { + item_id: next_temp_id("tmp_amp_assistant"), + native_item_id: event.session_id.clone(), + parent_id: None, + kind: ItemKind::Message, + role: Some(ItemRole::Assistant), + content: vec![ContentPart::Text { text: text.clone() }], + status: ItemStatus::Completed, + }; + events.extend(message_events(item, text)); + } + } + // Result message - signals completion + schema::StreamJsonMessageType::Result => { + events.push(turn_ended_event(None, None).synthetic()); + events.push( + EventConversion::new( + UniversalEventType::SessionEnded, + UniversalEventData::SessionEnded(SessionEndedData { + reason: if event.is_error.unwrap_or(false) { + SessionEndReason::Error + } else { + SessionEndReason::Completed + }, + terminated_by: TerminatedBy::Agent, + message: event.result.clone(), + exit_code: None, + stderr: None, + }), + ) + .with_raw(serde_json::to_value(event).ok()), + ); + } schema::StreamJsonMessageType::Message => { let text = event.content.clone().unwrap_or_default(); let item = UniversalItem { @@ -99,7 +165,7 @@ pub fn event_to_universal( )); } schema::StreamJsonMessageType::Done => { - events.push(turn_completed_event()); + events.push(turn_ended_event(None, None).synthetic()); events.push( EventConversion::new( UniversalEventType::SessionEnded, diff --git a/server/packages/universal-agent-schema/src/agents/claude.rs b/server/packages/universal-agent-schema/src/agents/claude.rs index 94ff081..44dff1b 100644 --- a/server/packages/universal-agent-schema/src/agents/claude.rs +++ b/server/packages/universal-agent-schema/src/agents/claude.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::Value; use crate::{ - turn_completed_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, + turn_ended_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, SessionStartedData, UniversalEventData, UniversalEventType, UniversalItem, }; @@ -425,7 +425,7 @@ fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConver UniversalEventType::ItemCompleted, UniversalEventData::Item(ItemEventData { item: message_item }), ), - turn_completed_event(), + turn_ended_event(None, None).synthetic(), ] } diff --git a/server/packages/universal-agent-schema/src/agents/codex.rs b/server/packages/universal-agent-schema/src/agents/codex.rs index 470e406..d918e11 100644 --- a/server/packages/universal-agent-schema/src/agents/codex.rs +++ b/server/packages/universal-agent-schema/src/agents/codex.rs @@ -4,7 +4,7 @@ use crate::codex as schema; use crate::{ ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, ReasoningVisibility, SessionEndReason, SessionEndedData, SessionStartedData, - TerminatedBy, UniversalEventData, UniversalEventType, UniversalItem, + TerminatedBy, TurnEventData, TurnPhase, UniversalEventData, UniversalEventType, UniversalItem, }; /// Convert a Codex ServerNotification to universal events. @@ -36,18 +36,26 @@ pub fn notification_to_universal( Some(params.thread_id.clone()), raw, )]), - schema::ServerNotification::TurnStarted(params) => Ok(vec![status_event( - "turn.started", - serde_json::to_string(¶ms.turn).ok(), - Some(params.thread_id.clone()), - raw, - )]), - schema::ServerNotification::TurnCompleted(params) => Ok(vec![status_event( - "turn.completed", - serde_json::to_string(¶ms.turn).ok(), - Some(params.thread_id.clone()), - raw, - )]), + schema::ServerNotification::TurnStarted(params) => Ok(vec![EventConversion::new( + UniversalEventType::TurnStarted, + UniversalEventData::Turn(TurnEventData { + phase: TurnPhase::Started, + turn_id: Some(params.turn.id.clone()), + metadata: serde_json::to_value(¶ms.turn).ok(), + }), + ) + .with_native_session(Some(params.thread_id.clone())) + .with_raw(raw)]), + schema::ServerNotification::TurnCompleted(params) => Ok(vec![EventConversion::new( + UniversalEventType::TurnEnded, + UniversalEventData::Turn(TurnEventData { + phase: TurnPhase::Ended, + turn_id: Some(params.turn.id.clone()), + metadata: serde_json::to_value(¶ms.turn).ok(), + }), + ) + .with_native_session(Some(params.thread_id.clone())) + .with_raw(raw)]), schema::ServerNotification::TurnDiffUpdated(params) => Ok(vec![status_event( "turn.diff.updated", serde_json::to_string(params).ok(), diff --git a/server/packages/universal-agent-schema/src/agents/opencode.rs b/server/packages/universal-agent-schema/src/agents/opencode.rs index 4dad152..ab74ae2 100644 --- a/server/packages/universal-agent-schema/src/agents/opencode.rs +++ b/server/packages/universal-agent-schema/src/agents/opencode.rs @@ -3,8 +3,9 @@ use serde_json::Value; use crate::opencode as schema; use crate::{ ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, - PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, SessionStartedData, - UniversalEventData, UniversalEventType, UniversalItem, + PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility, + SessionStartedData, TurnEventData, TurnPhase, UniversalEventData, UniversalEventType, + UniversalItem, }; pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, String> { @@ -69,27 +70,37 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, ); } schema::Part::ReasoningPart(reasoning_part) => { - let delta_text = delta + let reasoning_text = delta .as_ref() .cloned() .unwrap_or_else(|| reasoning_part.text.clone()); - let stub = stub_message_item(&message_id, ItemRole::Assistant); + let reasoning_id = reasoning_part.id.clone(); + let mut started = stub_message_item(&reasoning_id, ItemRole::Assistant); + started.parent_id = Some(message_id.clone()); + let completed = UniversalItem { + item_id: String::new(), + native_item_id: Some(reasoning_id), + parent_id: Some(message_id.clone()), + kind: ItemKind::Message, + role: Some(ItemRole::Assistant), + content: vec![ContentPart::Reasoning { + text: reasoning_text, + visibility: ReasoningVisibility::Public, + }], + status: ItemStatus::Completed, + }; events.push( EventConversion::new( UniversalEventType::ItemStarted, - UniversalEventData::Item(ItemEventData { item: stub }), + UniversalEventData::Item(ItemEventData { item: started }), ) .synthetic() .with_raw(raw.clone()), ); events.push( EventConversion::new( - UniversalEventType::ItemDelta, - UniversalEventData::ItemDelta(ItemDeltaData { - item_id: String::new(), - native_item_id: Some(message_id.clone()), - delta: delta_text, - }), + UniversalEventType::ItemCompleted, + UniversalEventData::Item(ItemEventData { item: completed }), ) .with_native_session(session_id.clone()) .with_raw(raw.clone()), @@ -207,26 +218,59 @@ pub fn event_to_universal(event: &schema::Event) -> Result<Vec<EventConversion>, properties, type_: _, } = status; + let status_type = serde_json::to_value(&properties.status) + .ok() + .and_then(|value| { + value + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }); let detail = serde_json::to_string(&properties.status).unwrap_or_else(|_| "status".to_string()); let item = status_item("session.status", Some(detail)); - let conversion = EventConversion::new( + let mut events = vec![EventConversion::new( UniversalEventType::ItemCompleted, UniversalEventData::Item(ItemEventData { item }), ) .with_native_session(Some(properties.session_id.clone())) - .with_raw(raw); - Ok(vec![conversion]) + .with_raw(raw.clone())]; + + if matches!(status_type.as_deref(), Some("busy" | "idle")) { + let (event_type, phase) = if status_type.as_deref() == Some("busy") { + (UniversalEventType::TurnStarted, TurnPhase::Started) + } else { + (UniversalEventType::TurnEnded, TurnPhase::Ended) + }; + events.push( + EventConversion::new( + event_type, + UniversalEventData::Turn(TurnEventData { + phase, + turn_id: None, + metadata: Some( + serde_json::to_value(&properties.status).unwrap_or(Value::Null), + ), + }), + ) + .with_native_session(Some(properties.session_id.clone())) + .with_raw(raw), + ); + } + Ok(events) } schema::Event::SessionIdle(idle) => { let schema::EventSessionIdle { properties, type_: _, } = idle; - let item = status_item("session.idle", None); let conversion = EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { item }), + UniversalEventType::TurnEnded, + UniversalEventData::Turn(TurnEventData { + phase: TurnPhase::Ended, + turn_id: None, + metadata: None, + }), ) .with_native_session(Some(properties.session_id.clone())) .with_raw(raw); @@ -528,3 +572,50 @@ fn permission_from_opencode(request: &schema::PermissionRequest) -> PermissionEv metadata: serde_json::to_value(request).ok(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reasoning_part_updates_stay_typed_not_text_delta() { + let event = schema::Event::MessagePartUpdated(schema::EventMessagePartUpdated { + properties: schema::EventMessagePartUpdatedProperties { + delta: Some("Preparing friendly brief response".to_string()), + part: schema::Part::ReasoningPart(schema::ReasoningPart { + id: "part_reason_1".to_string(), + message_id: "msg_1".to_string(), + metadata: serde_json::Map::new(), + session_id: "ses_1".to_string(), + text: "Preparing".to_string(), + time: schema::ReasoningPartTime { + end: None, + start: 0.0, + }, + type_: "reasoning".to_string(), + }), + }, + type_: "message.part.updated".to_string(), + }); + + let converted = event_to_universal(&event).expect("conversion succeeds"); + assert_eq!(converted.len(), 2); + assert!(converted + .iter() + .all(|entry| entry.event_type != UniversalEventType::ItemDelta)); + + let completed = converted + .iter() + .find(|entry| entry.event_type == UniversalEventType::ItemCompleted) + .expect("item.completed exists"); + let UniversalEventData::Item(ItemEventData { item }) = &completed.data else { + panic!("expected item payload"); + }; + assert_eq!(item.native_item_id.as_deref(), Some("part_reason_1")); + assert!(matches!( + item.content.first(), + Some(ContentPart::Reasoning { text, .. }) + if text == "Preparing friendly brief response" + )); + } +} diff --git a/server/packages/universal-agent-schema/src/lib.rs b/server/packages/universal-agent-schema/src/lib.rs index 8431e35..5ab1237 100644 --- a/server/packages/universal-agent-schema/src/lib.rs +++ b/server/packages/universal-agent-schema/src/lib.rs @@ -40,6 +40,10 @@ pub enum UniversalEventType { SessionStarted, #[serde(rename = "session.ended")] SessionEnded, + #[serde(rename = "turn.started")] + TurnStarted, + #[serde(rename = "turn.ended")] + TurnEnded, #[serde(rename = "item.started")] ItemStarted, #[serde(rename = "item.delta")] @@ -63,6 +67,7 @@ pub enum UniversalEventType { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(untagged)] pub enum UniversalEventData { + Turn(TurnEventData), SessionStarted(SessionStartedData), SessionEnded(SessionEndedData), Item(ItemEventData), @@ -93,6 +98,22 @@ pub struct SessionEndedData { pub stderr: Option<StderrOutput>, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +pub struct TurnEventData { + pub phase: TurnPhase, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option<Value>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum TurnPhase { + Started, + Ended, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] pub struct StderrOutput { /// First N lines of stderr (if truncated) or full stderr (if not truncated) @@ -161,8 +182,9 @@ pub struct PermissionEventData { #[serde(rename_all = "snake_case")] pub enum PermissionStatus { Requested, - Approved, - Denied, + Accept, + AcceptForSession, + Reject, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -317,25 +339,26 @@ impl EventConversion { } } -pub fn turn_completed_event() -> EventConversion { +pub fn turn_started_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion { EventConversion::new( - UniversalEventType::ItemCompleted, - UniversalEventData::Item(ItemEventData { - item: UniversalItem { - item_id: String::new(), - native_item_id: None, - parent_id: None, - kind: ItemKind::Status, - role: Some(ItemRole::System), - content: vec![ContentPart::Status { - label: "turn.completed".to_string(), - detail: None, - }], - status: ItemStatus::Completed, - }, + UniversalEventType::TurnStarted, + UniversalEventData::Turn(TurnEventData { + phase: TurnPhase::Started, + turn_id, + metadata, + }), + ) +} + +pub fn turn_ended_event(turn_id: Option<String>, metadata: Option<Value>) -> EventConversion { + EventConversion::new( + UniversalEventType::TurnEnded, + UniversalEventData::Turn(TurnEventData { + phase: TurnPhase::Ended, + turn_id, + metadata, }), ) - .synthetic() } pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem { diff --git a/spec/universal-schema.json b/spec/universal-schema.json index e8fd21a..ede37c6 100644 --- a/spec/universal-schema.json +++ b/spec/universal-schema.json @@ -370,8 +370,9 @@ "type": "string", "enum": [ "requested", - "approved", - "denied" + "accept", + "accept_for_session", + "reject" ] }, "QuestionEventData": { @@ -518,8 +519,36 @@ "daemon" ] }, + "TurnEventData": { + "type": "object", + "required": [ + "phase" + ], + "properties": { + "metadata": true, + "phase": { + "$ref": "#/definitions/TurnPhase" + }, + "turn_id": { + "type": [ + "string", + "null" + ] + } + } + }, + "TurnPhase": { + "type": "string", + "enum": [ + "started", + "ended" + ] + }, "UniversalEventData": { "anyOf": [ + { + "$ref": "#/definitions/TurnEventData" + }, { "$ref": "#/definitions/SessionStartedData" }, @@ -551,6 +580,8 @@ "enum": [ "session.started", "session.ended", + "turn.started", + "turn.ended", "item.started", "item.delta", "item.completed",