diff --git a/.github/media/gigacode-header.jpeg b/.github/media/gigacode-header.jpeg new file mode 100644 index 0000000..4708249 Binary files /dev/null and b/.github/media/gigacode-header.jpeg differ diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d1bad4c..ce5b8a7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -153,6 +153,7 @@ jobs: COMMIT_SHA_SHORT="${GITHUB_SHA::7}" BINARY_PATH="dist/sandbox-agent-${{ matrix.target }}${{ matrix.binary_ext }}" + GIGACODE_PATH="dist/gigacode-${{ matrix.target }}${{ matrix.binary_ext }}" # Must specify --checksum-algorithm for compatibility with R2 aws s3 cp \ @@ -162,6 +163,13 @@ jobs: --endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com \ --checksum-algorithm CRC32 + aws s3 cp \ + "${GIGACODE_PATH}" \ + "s3://rivet-releases/sandbox-agent/${COMMIT_SHA_SHORT}/binaries/gigacode-${{ matrix.target }}${{ matrix.binary_ext }}" \ + --region auto \ + --endpoint-url https://2a94c6a0ced8d35ea63cddc86c2681e7.r2.cloudflarestorage.com \ + --checksum-algorithm CRC32 + docker: name: "Build & Push Docker Images" needs: [setup] diff --git a/CLAUDE.md b/CLAUDE.md index 9f6a874..fae0758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Universal schema guidance: - `sandbox-agent api agents list` ↔ `GET /v1/agents` - `sandbox-agent api agents install` ↔ `POST /v1/agents/{agent}/install` - `sandbox-agent api agents modes` ↔ `GET /v1/agents/{agent}/modes` +- `sandbox-agent api agents models` ↔ `GET /v1/agents/{agent}/models` - `sandbox-agent api sessions list` ↔ `GET /v1/sessions` - `sandbox-agent api sessions create` ↔ `POST /v1/sessions/{sessionId}` - `sandbox-agent api sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages` @@ -80,6 +81,10 @@ The OpenCode compatibility suite lives at `server/packages/sandbox-agent/tests/o SANDBOX_AGENT_SKIP_INSPECTOR=1 pnpm --filter @sandbox-agent/opencode-compat-tests test ``` +## Naming + +- The product name is "Gigacode" (capital G, lowercase c). The CLI binary/package is `gigacode` (lowercase). + ## Git Commits - Do not include any co-authors in commit messages (no `Co-Authored-By` lines) diff --git a/Cargo.toml b/Cargo.toml index e192959..7644f8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] resolver = "2" -members = ["server/packages/*"] +members = ["server/packages/*", "gigacode"] [workspace.package] -version = "0.1.6" +version = "0.1.7" edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" @@ -12,12 +12,12 @@ description = "Universal API for automatic coding agents in sandboxes. Supports [workspace.dependencies] # Internal crates -sandbox-agent = { version = "0.1.6", path = "server/packages/sandbox-agent" } -sandbox-agent-error = { version = "0.1.6", path = "server/packages/error" } -sandbox-agent-agent-management = { version = "0.1.6", path = "server/packages/agent-management" } -sandbox-agent-agent-credentials = { version = "0.1.6", path = "server/packages/agent-credentials" } -sandbox-agent-universal-agent-schema = { version = "0.1.6", path = "server/packages/universal-agent-schema" } -sandbox-agent-extracted-agent-schemas = { version = "0.1.6", path = "server/packages/extracted-agent-schemas" } +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" } # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/README.md b/README.md index a1af778..24ef108 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ DocumentationAPI ReferenceDiscord

+

+ Experimental: Gigacode — use OpenCode's TUI with any coding agent. +

+ ## Why Sandbox Agent? Running coding agents remotely is hard. Existing SDKs assume local execution, SSH breaks TTY handling and streaming, and every agent has a different API. Building from scratch means reimplementing everything for each coding agent. diff --git a/docker/release/build.sh b/docker/release/build.sh index 6e7d66f..2f5204e 100755 --- a/docker/release/build.sh +++ b/docker/release/build.sh @@ -17,30 +17,35 @@ case $TARGET in DOCKERFILE="linux-x86_64.Dockerfile" TARGET_STAGE="x86_64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; aarch64-unknown-linux-musl) echo "Building for Linux aarch64 musl" DOCKERFILE="linux-aarch64.Dockerfile" TARGET_STAGE="aarch64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; x86_64-pc-windows-gnu) echo "Building for Windows x86_64" DOCKERFILE="windows.Dockerfile" TARGET_STAGE="" BINARY="sandbox-agent-$TARGET.exe" + GIGACODE="gigacode-$TARGET.exe" ;; x86_64-apple-darwin) echo "Building for macOS x86_64" DOCKERFILE="macos-x86_64.Dockerfile" TARGET_STAGE="x86_64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; aarch64-apple-darwin) echo "Building for macOS aarch64" DOCKERFILE="macos-aarch64.Dockerfile" TARGET_STAGE="aarch64-builder" BINARY="sandbox-agent-$TARGET" + GIGACODE="gigacode-$TARGET" ;; *) echo "Unsupported target: $TARGET" @@ -59,10 +64,13 @@ CONTAINER_ID=$(docker create "sandbox-agent-builder-$TARGET") mkdir -p dist docker cp "$CONTAINER_ID:/artifacts/$BINARY" "dist/" +docker cp "$CONTAINER_ID:/artifacts/$GIGACODE" "dist/" docker rm "$CONTAINER_ID" if [[ "$BINARY" != *.exe ]]; then chmod +x "dist/$BINARY" + chmod +x "dist/$GIGACODE" fi echo "Binary saved to: dist/$BINARY" +echo "Binary saved to: dist/$GIGACODE" diff --git a/docker/release/linux-aarch64.Dockerfile b/docker/release/linux-aarch64.Dockerfile index e1f3acd..290ecc4 100644 --- a/docker/release/linux-aarch64.Dockerfile +++ b/docker/release/linux-aarch64.Dockerfile @@ -66,9 +66,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac 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 --target aarch64-unknown-linux-musl && \ + cargo build -p sandbox-agent -p gigacode --release --target aarch64-unknown-linux-musl && \ mkdir -p /artifacts && \ - cp target/aarch64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-aarch64-unknown-linux-musl + cp target/aarch64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-aarch64-unknown-linux-musl && \ + cp target/aarch64-unknown-linux-musl/release/gigacode /artifacts/gigacode-aarch64-unknown-linux-musl # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/linux-x86_64.Dockerfile b/docker/release/linux-x86_64.Dockerfile index 89a8a30..1269740 100644 --- a/docker/release/linux-x86_64.Dockerfile +++ b/docker/release/linux-x86_64.Dockerfile @@ -100,9 +100,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac 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 --target x86_64-unknown-linux-musl && \ + cargo build -p sandbox-agent -p gigacode --release --target x86_64-unknown-linux-musl && \ mkdir -p /artifacts && \ - cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl + cp target/x86_64-unknown-linux-musl/release/sandbox-agent /artifacts/sandbox-agent-x86_64-unknown-linux-musl && \ + cp target/x86_64-unknown-linux-musl/release/gigacode /artifacts/gigacode-x86_64-unknown-linux-musl # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/macos-aarch64.Dockerfile b/docker/release/macos-aarch64.Dockerfile index dbb173a..ecc4e40 100644 --- a/docker/release/macos-aarch64.Dockerfile +++ b/docker/release/macos-aarch64.Dockerfile @@ -98,9 +98,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac 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 --target aarch64-apple-darwin && \ + cargo build -p sandbox-agent -p gigacode --release --target aarch64-apple-darwin && \ mkdir -p /artifacts && \ - cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin + cp target/aarch64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-aarch64-apple-darwin && \ + cp target/aarch64-apple-darwin/release/gigacode /artifacts/gigacode-aarch64-apple-darwin # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/macos-x86_64.Dockerfile b/docker/release/macos-x86_64.Dockerfile index 98d3a31..9150a3a 100644 --- a/docker/release/macos-x86_64.Dockerfile +++ b/docker/release/macos-x86_64.Dockerfile @@ -98,9 +98,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac 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 --target x86_64-apple-darwin && \ + cargo build -p sandbox-agent -p gigacode --release --target x86_64-apple-darwin && \ mkdir -p /artifacts && \ - cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin + cp target/x86_64-apple-darwin/release/sandbox-agent /artifacts/sandbox-agent-x86_64-apple-darwin && \ + cp target/x86_64-apple-darwin/release/gigacode /artifacts/gigacode-x86_64-apple-darwin # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docker/release/windows.Dockerfile b/docker/release/windows.Dockerfile index ca7eb16..462350c 100644 --- a/docker/release/windows.Dockerfile +++ b/docker/release/windows.Dockerfile @@ -84,9 +84,10 @@ COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/pac 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 --target x86_64-pc-windows-gnu && \ + cargo build -p sandbox-agent -p gigacode --release --target x86_64-pc-windows-gnu && \ mkdir -p /artifacts && \ - cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe + cp target/x86_64-pc-windows-gnu/release/sandbox-agent.exe /artifacts/sandbox-agent-x86_64-pc-windows-gnu.exe && \ + cp target/x86_64-pc-windows-gnu/release/gigacode.exe /artifacts/gigacode-x86_64-pc-windows-gnu.exe # Default command to show help CMD ["ls", "-la", "/artifacts"] diff --git a/docs/cli.mdx b/docs/cli.mdx index b761234..dce67e8 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -24,12 +24,13 @@ sandbox-agent server [OPTIONS] | `-A, --cors-allow-header
` | all | CORS allowed header (repeatable) | | `-C, --cors-allow-credentials` | - | Enable CORS credentials | | `--no-telemetry` | - | Disable anonymous telemetry | +| `--log-to-file` | - | Redirect server logs to a daily log file | ```bash sandbox-agent server --token "$TOKEN" --port 3000 ``` -Server logs are redirected to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr. +Server logs print to stdout/stderr by default. Use `--log-to-file` or `SANDBOX_AGENT_LOG_TO_FILE=1` to redirect logs to a daily log file under the sandbox-agent data directory (for example, `~/.local/share/sandbox-agent/logs`). Override the directory with `SANDBOX_AGENT_LOG_DIR`, or set `SANDBOX_AGENT_LOG_STDOUT=1` to force stdout/stderr. HTTP request logging is enabled by default. Control it with: - `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs @@ -57,7 +58,7 @@ sandbox-agent install-agent claude --reinstall ## OpenCode (Experimental) -Start a sandbox-agent server and attach an OpenCode session (uses `opencode attach`): +Start (or reuse) a sandbox-agent daemon and attach an OpenCode session (uses `opencode attach`): ```bash sandbox-agent opencode [OPTIONS] @@ -76,7 +77,54 @@ sandbox-agent opencode [OPTIONS] sandbox-agent opencode --token "$TOKEN" ``` -Requires the `opencode` binary to be installed (or set `OPENCODE_BIN` / `--opencode-bin`). +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. + +--- + +## Daemon + +Manage the background daemon. See the [Daemon](/daemon) docs for details on lifecycle and auto-upgrade. + +### Start + +```bash +sandbox-agent daemon start [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host to bind to | +| `-p, --port ` | `2468` | Port to bind to | +| `-t, --token ` | - | Authentication token | +| `-n, --no-token` | - | Disable authentication | + +```bash +sandbox-agent daemon start --no-token +``` + +### Stop + +```bash +sandbox-agent daemon stop [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | + +### Status + +```bash +sandbox-agent daemon status [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | --- @@ -169,6 +217,16 @@ sandbox-agent api agents modes sandbox-agent api agents modes claude ``` +#### Get Agent Models + +```bash +sandbox-agent api agents models +``` + +```bash +sandbox-agent api agents models claude +``` + --- ### Sessions @@ -329,6 +387,7 @@ sandbox-agent api sessions reply-permission my-session perm1 --reply once | `api agents list` | `GET /v1/agents` | | `api agents install` | `POST /v1/agents/{agent}/install` | | `api agents modes` | `GET /v1/agents/{agent}/modes` | +| `api agents models` | `GET /v1/agents/{agent}/models` | | `api sessions list` | `GET /v1/sessions` | | `api sessions create` | `POST /v1/sessions/{sessionId}` | | `api sessions send-message` | `POST /v1/sessions/{sessionId}/messages` | diff --git a/docs/conversion.mdx b/docs/conversion.mdx index 647c1b5..0c7b05c 100644 --- a/docs/conversion.mdx +++ b/docs/conversion.mdx @@ -84,4 +84,4 @@ Message normalization notes - If Pi message_update events omit messageId, we synthesize a stable message id and emit a synthetic item.started before the first delta so streaming text stays grouped. - Pi auto_compaction_start/auto_compaction_end and auto_retry_start/auto_retry_end events are mapped to status items (label `pi.*`). - Pi extension_ui_request/extension_error events are mapped to status items. -- Pi RPC from pi-coding-agent does not include sessionId in events; we route events to the current Pi session (single-session semantics). +- Pi RPC from pi-coding-agent does not include sessionId in events; each daemon session owns a dedicated Pi RPC process, so events are routed by runtime ownership (parallel sessions supported). diff --git a/docs/daemon.mdx b/docs/daemon.mdx new file mode 100644 index 0000000..b76c8f8 --- /dev/null +++ b/docs/daemon.mdx @@ -0,0 +1,96 @@ +--- +title: "Daemon" +description: "Background daemon lifecycle, auto-upgrade, and management." +icon: "microchip" +--- + +The sandbox-agent daemon is a background server process that stays running between sessions. Commands like `sandbox-agent opencode` and `gigacode` automatically start it when needed and restart it when the binary is updated. + +## How it works + +1. When you run `sandbox-agent opencode`, `sandbox-agent daemon start`, or `gigacode`, the CLI checks if a daemon is already healthy at the configured host and port. +2. If no daemon is running, one is spawned in the background with stdout/stderr redirected to a log file. +3. The CLI writes a PID file and a build ID file to track the running process and its version. +4. On subsequent invocations, if the daemon is still running but was built from a different commit, the CLI automatically stops the old daemon and starts a new one. + +## Auto-upgrade + +Each build of sandbox-agent embeds a unique **build ID** (the git short hash, or a version-timestamp fallback). When a daemon is started, this build ID is written to a version file alongside the PID file. + +On every invocation of `ensure_running` (called by `opencode`, `gigacode`, and `daemon start`), the CLI compares the stored build ID against the current binary's build ID. If they differ, the running daemon is stopped and replaced automatically: + +``` +daemon outdated (build a1b2c3d -> f4e5d6c), restarting... +``` + +This means installing a new version of sandbox-agent and running any daemon-aware command is enough to upgrade — no manual restart needed. + +## Managing the daemon + +### Start + +Start a daemon in the background. If one is already running and healthy, this is a no-op. + +```bash +sandbox-agent daemon start [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host to bind to | +| `-p, --port ` | `2468` | Port to bind to | +| `-t, --token ` | - | Authentication token | +| `-n, --no-token` | - | Disable authentication | + +```bash +sandbox-agent daemon start --no-token +``` + +### Stop + +Stop a running daemon. Sends SIGTERM and waits up to 5 seconds for a graceful shutdown before falling back to SIGKILL. + +```bash +sandbox-agent daemon stop [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | + +```bash +sandbox-agent daemon stop +``` + +### Status + +Show whether the daemon is running, its PID, build ID, and log path. + +```bash +sandbox-agent daemon status [OPTIONS] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `-H, --host ` | `127.0.0.1` | Host of the daemon | +| `-p, --port ` | `2468` | Port of the daemon | + +```bash +sandbox-agent daemon status +# Daemon running (PID 12345, build a1b2c3d, logs: ~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log) +``` + +If the daemon was started with an older binary, the status includes an `[outdated, restart recommended]` notice. + +## Files + +All daemon state files live under the sandbox-agent data directory (typically `~/.local/share/sandbox-agent/daemon/`): + +| File | Purpose | +|------|---------| +| `daemon-{host}-{port}.pid` | PID of the running daemon process | +| `daemon-{host}-{port}.version` | Build ID of the running daemon | +| `daemon-{host}-{port}.log` | Daemon stdout/stderr log output | + +Multiple daemons can run on different host/port combinations without conflicting. diff --git a/docs/docs.json b/docs/docs.json index 45a3fa5..61bfbf9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -41,7 +41,12 @@ "pages": [ { "group": "Getting started", - "pages": ["quickstart", "building-chat-ui", "manage-sessions", "opencode-compatibility"] + "pages": [ + "quickstart", + "building-chat-ui", + "manage-sessions", + "opencode-compatibility" + ] }, { "group": "Deploy", @@ -61,18 +66,18 @@ }, { "group": "Reference", - "pages": [ - "cli", - "inspector", - "session-transcript-schema", - "cors", + "pages": [ + "cli", + "inspector", + "session-transcript-schema", + "gigacode", { "group": "AI", "pages": ["ai/skill", "ai/llms-txt"] }, { "group": "Advanced", - "pages": ["telemetry"] + "pages": ["daemon", "cors", "telemetry"] } ] }, diff --git a/docs/gigacode.mdx b/docs/gigacode.mdx new file mode 100644 index 0000000..ccc9e39 --- /dev/null +++ b/docs/gigacode.mdx @@ -0,0 +1,6 @@ +--- +title: Gigacode +url: "https://github.com/rivet-dev/sandbox-agent/tree/main/gigacode" +--- + + diff --git a/docs/openapi.json b/docs/openapi.json index 06b7e41..bbe1c2e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.1.6-rc.1" + "version": "0.1.7" }, "servers": [ { @@ -102,6 +102,47 @@ } } }, + "/v1/agents/{agent}/models": { + "get": { + "tags": [ + "agents" + ], + "operationId": "get_agent_models", + "parameters": [ + { + "name": "agent", + "in": "path", + "description": "Agent id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentModelsResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/v1/agents/{agent}/modes": { "get": { "tags": [ @@ -669,6 +710,7 @@ "mcpTools", "streamingDeltas", "itemStarted", + "variants", "sharedProcess" ], "properties": { @@ -726,6 +768,9 @@ }, "toolResults": { "type": "boolean" + }, + "variants": { + "type": "boolean" } } }, @@ -832,6 +877,50 @@ } } }, + "AgentModelInfo": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "defaultVariant": { + "type": "string", + "nullable": true + }, + "id": { + "type": "string" + }, + "name": { + "type": "string", + "nullable": true + }, + "variants": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + } + }, + "AgentModelsResponse": { + "type": "object", + "required": [ + "models" + ], + "properties": { + "defaultModel": { + "type": "string", + "nullable": true + }, + "models": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentModelInfo" + } + } + } + }, "AgentModesResponse": { "type": "object", "required": [ diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx index be7da6f..004f048 100644 --- a/docs/opencode-compatibility.mdx +++ b/docs/opencode-compatibility.mdx @@ -112,6 +112,7 @@ for await (const event of events.stream) { - **Authentication**: If sandbox-agent is started with `--token`, include `Authorization: Bearer ` header or use `--password` flag with CLI - **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 ()` to preserve their native provider grouping. Each model keeps its real model ID, and variants are exposed when available (Codex/OpenCode/Amp). ## Endpoint Coverage @@ -132,7 +133,7 @@ See the full endpoint compatibility table below. Most endpoints are functional f | `POST /permission/{id}/reply` | ✓ | Respond to permission requests | | `GET /question` | ✓ | List pending questions | | `POST /question/{id}/reply` | ✓ | Answer agent questions | -| `GET /provider` | − | Returns provider metadata | +| `GET /provider` | ✓ | Returns provider metadata | | `GET /agent` | − | Returns agent list | | `GET /config` | − | Returns config | | *other endpoints* | − | Return empty/stub responses | diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx index 7b87029..11d965d 100644 --- a/docs/session-transcript-schema.mdx +++ b/docs/session-transcript-schema.mdx @@ -29,6 +29,7 @@ This table shows which agent feature coverage appears in the universal event str | File Changes | - | ✓ | - | - | | | MCP Tools | - | ✓ | - | - | | | Streaming Deltas | ✓ | ✓ | ✓ | - | ✓ | +| Variants | | ✓ | ✓ | ✓ | | Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) · [Codex](https://github.com/openai/codex) · [OpenCode](https://github.com/opencode-ai/opencode) · [Amp](https://ampcode.com) · [Pi](https://buildwithpi.ai/pi-cli) @@ -76,6 +77,9 @@ Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`. + + Model variants such as reasoning effort or depth. Agents may expose different variant sets per model. + Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it. diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index 767e2bb..2479599 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -3,6 +3,7 @@ import { SandboxAgentError, SandboxAgent, type AgentInfo, + type AgentModelInfo, type AgentModeInfo, type PermissionEventData, type QuestionEventData, @@ -31,18 +32,6 @@ type ItemDeltaEventData = { delta: string; }; -const shouldHidePiStatusItem = (item: UniversalItem) => { - if (item.kind !== "status") return false; - const statusParts = (item.content ?? []).filter( - (part) => (part as { type?: string }).type === "status" - ) as Array<{ label?: string }>; - if (statusParts.length === 0) return false; - return statusParts.every((part) => { - const label = part.label ?? ""; - return label.startsWith("pi.turn_") || label.startsWith("pi.agent_"); - }); -}; - const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => { return { item_id: itemId, @@ -101,6 +90,8 @@ export default function App() { const [agents, setAgents] = useState([]); const [modesByAgent, setModesByAgent] = useState>({}); + const [modelsByAgent, setModelsByAgent] = useState>({}); + const [defaultModelByAgent, setDefaultModelByAgent] = useState>({}); const [sessions, setSessions] = useState([]); const [agentsLoading, setAgentsLoading] = useState(false); const [agentsError, setAgentsError] = useState(null); @@ -108,6 +99,8 @@ export default function App() { const [sessionsError, setSessionsError] = useState(null); const [modesLoadingByAgent, setModesLoadingByAgent] = useState>({}); const [modesErrorByAgent, setModesErrorByAgent] = useState>({}); + const [modelsLoadingByAgent, setModelsLoadingByAgent] = useState>({}); + const [modelsErrorByAgent, setModelsErrorByAgent] = useState>({}); const [agentId, setAgentId] = useState("claude"); const [agentMode, setAgentMode] = useState(""); @@ -264,10 +257,14 @@ export default function App() { stopTurnStream(); setAgents([]); setSessions([]); + setModelsByAgent({}); + setDefaultModelByAgent({}); setAgentsLoading(false); setSessionsLoading(false); setAgentsError(null); setSessionsError(null); + setModelsLoadingByAgent({}); + setModelsErrorByAgent({}); }; const refreshAgents = async () => { @@ -280,6 +277,7 @@ export default function App() { for (const agent of agentList) { if (agent.installed) { loadModes(agent.id); + loadModels(agent.id); } } } catch (error) { @@ -326,6 +324,29 @@ export default function App() { } }; + const loadModels = async (targetId: string) => { + setModelsLoadingByAgent((prev) => ({ ...prev, [targetId]: true })); + setModelsErrorByAgent((prev) => ({ ...prev, [targetId]: null })); + try { + const data = await getClient().getAgentModels(targetId); + const models = data.models ?? []; + setModelsByAgent((prev) => ({ ...prev, [targetId]: models })); + if (data.defaultModel) { + setDefaultModelByAgent((prev) => ({ ...prev, [targetId]: data.defaultModel! })); + } else { + setDefaultModelByAgent((prev) => { + const next = { ...prev }; + delete next[targetId]; + return next; + }); + } + } catch { + setModelsErrorByAgent((prev) => ({ ...prev, [targetId]: "Unable to load models." })); + } finally { + setModelsLoadingByAgent((prev) => ({ ...prev, [targetId]: false })); + } + }; + const sendMessage = async () => { const prompt = message.trim(); if (!prompt || !sessionId || turnStreaming) return; @@ -746,10 +767,7 @@ export default function App() { } } - return entries.filter((entry) => { - if (entry.kind !== "item" || !entry.item) return true; - return !shouldHidePiStatusItem(entry.item); - }); + return entries; }, [events]); useEffect(() => { @@ -840,6 +858,12 @@ export default function App() { } }, [connected, agentId]); + useEffect(() => { + if (connected && agentId && !modelsByAgent[agentId]) { + loadModels(agentId); + } + }, [connected, agentId]); + useEffect(() => { const modes = modesByAgent[agentId]; if (modes && modes.length > 0 && !agentMode) { @@ -851,6 +875,15 @@ export default function App() { 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 agentDisplayNames: Record = { claude: "Claude Code", codex: "Codex", @@ -952,6 +985,13 @@ export default function App() { 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} diff --git a/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx b/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx index 59ebb92..0ff41d0 100644 --- a/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx +++ b/frontend/packages/inspector/src/components/agents/FeatureCoverageBadges.tsx @@ -10,6 +10,7 @@ import { GitBranch, HelpCircle, Image, + Layers, MessageSquare, Paperclip, PlayCircle, @@ -37,7 +38,8 @@ const badges = [ { key: "fileChanges", label: "File Changes", icon: FileDiff }, { key: "mcpTools", label: "MCP", icon: Plug }, { key: "streamingDeltas", label: "Deltas", icon: Activity }, - { key: "itemStarted", label: "Item Start", icon: CircleDot } + { key: "itemStarted", label: "Item Start", icon: CircleDot }, + { key: "variants", label: "Variants", icon: Layers } ] as const; type BadgeItem = (typeof badges)[number]; diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index 7d1a35e..ff2be69 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -1,6 +1,6 @@ import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import type { AgentInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent"; +import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent"; import ApprovalsTab from "../debug/ApprovalsTab"; import ChatInput from "./ChatInput"; import ChatMessages from "./ChatMessages"; @@ -28,6 +28,13 @@ const ChatPanel = ({ permissionMode, model, variant, + modelOptions, + defaultModel, + modelsLoading, + modelsError, + variantOptions, + defaultVariant, + supportsVariants, streamMode, activeModes, currentAgentVersion, @@ -70,6 +77,13 @@ const ChatPanel = ({ 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; @@ -278,6 +292,13 @@ const ChatPanel = ({ 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} diff --git a/frontend/packages/inspector/src/components/chat/ChatSetup.tsx b/frontend/packages/inspector/src/components/chat/ChatSetup.tsx index ded3825..3fa42d1 100644 --- a/frontend/packages/inspector/src/components/chat/ChatSetup.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatSetup.tsx @@ -1,10 +1,17 @@ -import type { AgentModeInfo } from "sandbox-agent"; +import type { AgentModelInfo, AgentModeInfo } from "sandbox-agent"; const ChatSetup = ({ agentMode, permissionMode, model, variant, + modelOptions, + defaultModel, + modelsLoading, + modelsError, + variantOptions, + defaultVariant, + supportsVariants, activeModes, hasSession, modesLoading, @@ -18,6 +25,13 @@ const ChatSetup = ({ 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; @@ -27,6 +41,15 @@ const ChatSetup = ({ 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 (
@@ -71,26 +94,82 @@ const ChatSetup = ({
Model - onModelChange(e.target.value)} - placeholder="Model" - title="Model" - disabled={!hasSession} - /> + {showModelSelect ? ( + + ) : ( + onModelChange(e.target.value)} + placeholder="Model" + title="Model" + disabled={!hasSession} + /> + )}
Variant - onVariantChange(e.target.value)} - placeholder="Variant" - title="Variant" - disabled={!hasSession} - /> + {showVariantSelect ? ( + + ) : ( + onVariantChange(e.target.value)} + placeholder={supportsVariants ? "Variant" : "Variants unsupported"} + title="Variant" + disabled={!hasSession || !supportsVariants} + /> + )}
); diff --git a/frontend/packages/inspector/src/types/agents.ts b/frontend/packages/inspector/src/types/agents.ts index 80e7e5b..4a1b9f6 100644 --- a/frontend/packages/inspector/src/types/agents.ts +++ b/frontend/packages/inspector/src/types/agents.ts @@ -14,6 +14,7 @@ export type FeatureCoverageView = AgentCapabilities & { mcpTools?: boolean; streamingDeltas?: boolean; itemStarted?: boolean; + variants?: boolean; }; export const emptyFeatureCoverage: FeatureCoverageView = { @@ -34,5 +35,6 @@ export const emptyFeatureCoverage: FeatureCoverageView = { mcpTools: false, streamingDeltas: false, itemStarted: false, + variants: false, sharedProcess: false }; diff --git a/frontend/packages/website/src/components/GetStarted.tsx b/frontend/packages/website/src/components/GetStarted.tsx index 63c87ef..ee816d4 100644 --- a/frontend/packages/website/src/components/GetStarted.tsx +++ b/frontend/packages/website/src/components/GetStarted.tsx @@ -103,6 +103,9 @@ export function GetStarted() {

Choose the installation method that works best for your use case.

+

+ Quick OpenCode attach: npx @sandbox-agent/gigacode +

diff --git a/gigacode/Cargo.toml b/gigacode/Cargo.toml new file mode 100644 index 0000000..29a4e86 --- /dev/null +++ b/gigacode/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gigacode" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +description = "Sandbox Agent CLI with OpenCode attach by default" +repository.workspace = true + +[[bin]] +name = "gigacode" +path = "src/main.rs" + +[dependencies] +clap.workspace = true +sandbox-agent.workspace = true +tracing.workspace = true diff --git a/gigacode/README.md b/gigacode/README.md new file mode 100644 index 0000000..3ebbd89 --- /dev/null +++ b/gigacode/README.md @@ -0,0 +1,97 @@ +

+ Gigacode. Use OpenCode's UI with any coding agent. +

+ +

Supports Claude Code, Codex, and Amp.

+ +

+ This is not a fork (and never will be).
It's powered by Sandbox Agent SDK's wizardry.
Experimental & just for fun.
+

+ +

+ IssuesDiscordSupported OpenCode Features +

+ + +## How It Works + +``` +┌─ Gigacode ────────────────────────────────────────────────────────┐ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ OpenCode TUI │───▶│ Sandbox Agent │───▶│ Claude Code / │ │ +│ │ │ │ │ │ Codex / Amp │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +- [Sandbox Agent SDK](https://sandboxagent.dev) provides a universal HTTP API for controlling Claude Code, Codex, and Amp +- Sandbox Agent SDK exposes an [OpenCode-compatible endpoint](https://sandboxagent.dev/docs/opencode-compatibility) so OpenCode can talk to any agent +- OpenCode connects to Sandbox Agent SDK via [`attach`](https://opencode.ai/docs/cli/#attach) + +## OpenCode Models vs Gigacode Agents + +- **OpenCode** supports **switching between inference providers** (Anthropic, OpenAI, etc.). This is OpenCode talking directly to the models with its own tools, system prompts, and agentic loop. +- **Gigacode** automates other coding agent harnesses, so it's using the **exact same logic that you would if you ran Claude Code**, Codex, or Amp natively. + +``` +OpenCode (native): Model → OpenCode's tool loop → result +Gigacode: Model → Claude Code / Codex / Amp CLI → result +``` + +This means you get each agent's specialized capabilities (such as Claude Code's `Read`/`Write`/`Bash` tools, Codex's sandboxed execution, and Amp's permission rules) rather than a single tool loop with different models behind it. + +## Install + +**macOS / Linux / WSL (Recommended)** + +```bash +curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/gigacode-install.sh | sh +``` + +**npm i -g** + +```bash +npm install -g @sandbox-agent/gigacode +gigacode --help +``` + +**bun add -g** + +```bash +bun add -g @sandbox-agent/gigacode +# Allow Bun to run postinstall scripts for native binaries. +bun pm -g trust @sandbox-agent/gigacode-linux-x64 @sandbox-agent/gigacode-linux-arm64 @sandbox-agent/gigacode-darwin-arm64 @sandbox-agent/gigacode-darwin-x64 @sandbox-agent/gigacode-win32-x64 +gigacode --help +``` + +**npx** + +```bash +npx @sandbox-agent/gigacode --help +``` + +**bunx** + +```bash +bunx @sandbox-agent/gigacode --help +``` + +> **Note:** Windows is unsupported. Please use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). + +## Usage + +**TUI** + +Launch the OpenCode TUI with any coding agent: + +```bash +gigacode +``` + +**Web UI** + +Use the [OpenCode Web UI](https://sandboxagent.dev/docs/opencode-compatibility) to control any coding agent from the browser. + +**OpenCode SDK** + +Use the [`@opencode-ai/sdk`](https://sandboxagent.dev/docs/opencode-compatibility) to programmatically control any coding agent. diff --git a/gigacode/src/main.rs b/gigacode/src/main.rs new file mode 100644 index 0000000..87e93aa --- /dev/null +++ b/gigacode/src/main.rs @@ -0,0 +1,28 @@ +use clap::Parser; +use sandbox_agent::cli::{ + init_logging, run_command, CliConfig, CliError, Command, GigacodeCli, OpencodeArgs, +}; + +fn main() { + if let Err(err) = run() { + tracing::error!(error = %err, "gigacode failed"); + std::process::exit(1); + } +} + +fn run() -> Result<(), CliError> { + let cli = GigacodeCli::parse(); + let config = CliConfig { + token: cli.token, + no_token: cli.no_token, + gigacode: true, + }; + let command = cli + .command + .unwrap_or_else(|| Command::Opencode(OpencodeArgs::default())); + if let Err(err) = init_logging(&command) { + eprintln!("failed to init logging: {err}"); + return Err(err); + } + run_command(&command, &config) +} diff --git a/justfile b/justfile index f9d4103..714768c 100644 --- a/justfile +++ b/justfile @@ -48,6 +48,33 @@ check: fmt: cargo fmt --all +[group('dev')] +install-fast-sa: + cargo build --release -p sandbox-agent + cp target/release/sandbox-agent ~/.cargo/bin/sandbox-agent + +[group('dev')] +install-fast-gigacode: + cargo build --release -p gigacode + cp target/release/gigacode ~/.cargo/bin/gigacode + [group('dev')] dev-docs: cd docs && pnpm dlx mintlify dev + +install: + pnpm install + pnpm build --filter @sandbox-agent/inspector... + cargo install --path server/packages/sandbox-agent --debug + cargo install --path gigacode --debug + +install-fast: + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path server/packages/sandbox-agent --debug + SANDBOX_AGENT_SKIP_INSPECTOR=1 cargo install --path gigacode --debug + +install-release: + pnpm install + 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 5bfc5da..819556a 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.0)(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.1)(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.20260131.0 + version: 4.20260206.0 '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 '@types/react': specifier: ^18.3.3 version: 18.3.27 @@ -48,32 +48,32 @@ 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.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) + 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)) typescript: specifier: latest version: 5.9.3 vite: specifier: ^6.2.0 - version: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + version: 6.4.1(@types/node@25.2.1)(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.0)(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.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) wrangler: specifier: latest - version: 4.61.1(@cloudflare/workers-types@4.20260131.0) + version: 4.63.0(@cloudflare/workers-types@4.20260206.0) examples/daytona: dependencies: '@daytonaio/sdk': specifier: latest - version: 0.138.0(ws@8.19.0) + version: 0.139.0(ws@8.19.0) '@sandbox-agent/example-shared': specifier: workspace:* version: link:../shared devDependencies: '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 tsx: specifier: latest version: 4.21.0 @@ -95,7 +95,7 @@ importers: version: 4.0.1 '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 tsx: specifier: latest version: 4.21.0 @@ -104,7 +104,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(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.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) examples/e2b: dependencies: @@ -120,7 +120,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 tsx: specifier: latest version: 4.21.0 @@ -129,7 +129,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(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.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) examples/shared: dependencies: @@ -139,7 +139,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 typescript: specifier: latest version: 5.9.3 @@ -158,7 +158,7 @@ importers: devDependencies: '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 tsx: specifier: latest version: 4.21.0 @@ -167,7 +167,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(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.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) frontend/packages/inspector: dependencies: @@ -189,7 +189,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.0)) + version: 4.7.0(vite@5.4.21(@types/node@25.2.1)) sandbox-agent: specifier: workspace:* version: link:../../../sdks/typescript @@ -198,19 +198,19 @@ importers: version: 5.9.3 vite: specifier: ^5.4.7 - version: 5.4.21(@types/node@25.2.0) + version: 5.4.21(@types/node@25.2.1) frontend/packages/website: dependencies: '@astrojs/react': specifier: ^4.2.0 - version: 4.4.2(@types/node@25.2.0)(@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.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/tailwind': specifier: ^6.0.0 - version: 6.0.2(astro@5.16.15(@types/node@25.2.0)(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.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)) astro: specifier: ^5.1.0 - version: 5.16.15(@types/node@25.2.0)(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.1)(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,10 +241,10 @@ importers: dependencies: '@anthropic-ai/claude-code': specifier: latest - version: 2.1.29 + version: 2.1.34 '@openai/codex': specifier: latest - version: 0.94.0 + version: 0.98.0 cheerio: specifier: ^1.0.0 version: 1.2.0 @@ -319,14 +319,14 @@ importers: dependencies: '@daytonaio/sdk': specifier: latest - version: 0.138.0(ws@8.19.0) + version: 0.139.0(ws@8.19.0) '@e2b/code-interpreter': specifier: latest version: 2.3.3 devDependencies: '@types/node': specifier: latest - version: 25.2.0 + version: 25.2.1 tsx: specifier: latest version: 4.21.0 @@ -358,7 +358,7 @@ importers: devDependencies: vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(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.1)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) sdks/cli-shared: devDependencies: @@ -382,6 +382,42 @@ importers: sdks/cli/platforms/win32-x64: {} + sdks/gigacode: + dependencies: + '@sandbox-agent/cli-shared': + specifier: workspace:* + version: link:../cli-shared + optionalDependencies: + '@sandbox-agent/gigacode-darwin-arm64': + specifier: workspace:* + version: link:platforms/darwin-arm64 + '@sandbox-agent/gigacode-darwin-x64': + specifier: workspace:* + version: link:platforms/darwin-x64 + '@sandbox-agent/gigacode-linux-arm64': + specifier: workspace:* + version: link:platforms/linux-arm64 + '@sandbox-agent/gigacode-linux-x64': + specifier: workspace:* + version: link:platforms/linux-x64 + '@sandbox-agent/gigacode-win32-x64': + specifier: workspace:* + version: link:platforms/win32-x64 + 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) + + sdks/gigacode/platforms/darwin-arm64: {} + + sdks/gigacode/platforms/darwin-x64: {} + + sdks/gigacode/platforms/linux-arm64: {} + + sdks/gigacode/platforms/linux-x64: {} + + sdks/gigacode/platforms/win32-x64: {} + sdks/typescript: dependencies: '@sandbox-agent/cli-shared': @@ -410,8 +446,8 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@anthropic-ai/claude-code@2.1.29': - resolution: {integrity: sha512-vMHTOXrYdnreGtKUsWdd3Bwx5fKprTyNG7shrvbx3L2/jU9jexkOJrEKmN5loeR5jrE54LSB38QpaIj8pVM6eQ==} + '@anthropic-ai/claude-code@2.1.34': + resolution: {integrity: sha512-uQ3yv41lvCExj2Ju/pCZ1KIKub5d5V3RQyeSKICPoJzk/H2Ktp0zonZeLkD/Q56qa4vPpA8MmvsBmFkAr+Z42w==} engines: {node: '>=18.0.0'} hasBin: true @@ -756,38 +792,38 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260128.0': - resolution: {integrity: sha512-XJN8zWWNG3JwAUqqwMLNKJ9fZfdlQkx/zTTHW/BB8wHat9LjKD6AzxqCu432YmfjR+NxEKCzUOxMu1YOxlVxmg==} + '@cloudflare/workerd-darwin-64@1.20260205.0': + resolution: {integrity: sha512-ToOItqcirmWPwR+PtT+Q4bdjTn/63ZxhJKEfW4FNn7FxMTS1Tw5dml0T0mieOZbCpcvY8BdvPKFCSlJuI8IVHQ==} engines: {node: '>=16'} cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260128.0': - resolution: {integrity: sha512-vKnRcmnm402GQ5DOdfT5H34qeR2m07nhnTtky8mTkNWP+7xmkz32AMdclwMmfO/iX9ncyKwSqmml2wPG32eq/w==} + '@cloudflare/workerd-darwin-arm64@1.20260205.0': + resolution: {integrity: sha512-402ZqLz+LrG0NDXp7Hn7IZbI0DyhjNfjAlVenb0K3yod9KCuux0u3NksNBvqJx0mIGHvVR4K05h+jfT5BTHqGA==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260128.0': - resolution: {integrity: sha512-RiaR+Qugof/c6oI5SagD2J5wJmIfI8wQWaV2Y9905Raj6sAYOFaEKfzkKnoLLLNYb4NlXicBrffJi1j7R/ypUA==} + '@cloudflare/workerd-linux-64@1.20260205.0': + resolution: {integrity: sha512-rz9jBzazIA18RHY+osa19hvsPfr0LZI1AJzIjC6UqkKKphcTpHBEQ25Xt8cIA34ivMIqeENpYnnmpDFesLkfcQ==} engines: {node: '>=16'} cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260128.0': - resolution: {integrity: sha512-U39U9vcXLXYDbrJ112Q7D0LDUUnM54oXfAxPgrL2goBwio7Z6RnsM25TRvm+Q06F4+FeDOC4D51JXlFHb9t1OA==} + '@cloudflare/workerd-linux-arm64@1.20260205.0': + resolution: {integrity: sha512-jr6cKpMM/DBEbL+ATJ9rYue758CKp0SfA/nXt5vR32iINVJrb396ye9iat2y9Moa/PgPKnTrFgmT6urUmG3IUg==} engines: {node: '>=16'} cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260128.0': - resolution: {integrity: sha512-fdJwSqRkJsAJFJ7+jy0th2uMO6fwaDA8Ny6+iFCssfzlNkc4dP/twXo+3F66FMLMe/6NIqjzVts0cpiv7ERYbQ==} + '@cloudflare/workerd-windows-64@1.20260205.0': + resolution: {integrity: sha512-SMPW5jCZYOG7XFIglSlsgN8ivcl0pCrSAYxCwxtWvZ88whhcDB/aISNtiQiDZujPH8tIo2hE5dEkxW7tGEwc3A==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workers-types@4.20260131.0': - resolution: {integrity: sha512-ELgvb2mp68Al50p+FmpgCO2hgU5o4tmz8pi7kShN+cRXc0UZoEdxpDIikR0CeT7b3tV7wlnEnsUzd0UoJLS0oQ==} + '@cloudflare/workers-types@4.20260206.0': + resolution: {integrity: sha512-rHbE1XM3mfwzoyOiKm1oFRTp00Cv4U5UiuMDQwmu/pc79yOA3nDiOC0lue8aOpobBrP4tPHQqsPcWG606Zrw/w==} '@connectrpc/connect-web@2.0.0-rc.3': resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} @@ -804,14 +840,14 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@daytonaio/api-client@0.138.0': - resolution: {integrity: sha512-mKO3Aqk2aCnOw4ej+UxvKE+Z1ixmo9OKTAFElkvRb6UOwb5zioudqTyqEfijkA2tXUXO8yPGhQDPaICLgpPopA==} + '@daytonaio/api-client@0.139.0': + resolution: {integrity: sha512-Xmjrkq7MW/DaZLQEf0HfT7Y38N8SesXvWqAKSEjdf3ifGVQVx37aOB8El1jOvfrndqzTcSbumSxro2nhKK5K5A==} - '@daytonaio/sdk@0.138.0': - resolution: {integrity: sha512-cnbsflZYJ1NA4pQ2uX2lLN4w4ZQsO/xqdGDnpmwSu/LIW5F+O5gA8z4mfuWdIRcFFT4UhIpTzMuh3zRwxH7dIw==} + '@daytonaio/sdk@0.139.0': + resolution: {integrity: sha512-67NSkhnl9NiUgBfheN5AtkH0/T5U+WTZmGlY2k+ujAAl/ntpyA/T/q+Pznk44oCJyM1O39OEWt/ugmAEyqRWLg==} - '@daytonaio/toolbox-api-client@0.138.0': - resolution: {integrity: sha512-unM9e7MOQiyDXdY8hCW1uTctYbxpo/TGZ6L71ZXyS/j2Cnz9/ud4VWBLcQP2VzlC+lrBP2YMrhT90zSSvcNfmA==} + '@daytonaio/toolbox-api-client@0.139.0': + resolution: {integrity: sha512-zLonkWHsdmrwT2qCZ/zBt4dpWNJ7N08eGYjCk30Bihzk4JY0afNBPDqd+pdGdXKtuJ44yCxR4iJyjfWR11J9PA==} '@e2b/code-interpreter@2.3.3': resolution: {integrity: sha512-WOpSwc1WpvxyOijf6WMbR76BUuvd2O9ddXgCHHi65lkuy6YgQGq7oyd8PNsT331O9Tqbccjy6uF4xanSdLX1UA==} @@ -1717,8 +1753,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openai/codex@0.94.0': - resolution: {integrity: sha512-GKOU2ty3NXls2aeiFSCnSSB6zQBtENqC5OnPa8s79Z576YP1r2DIfUrhQZzVDKmFei852E1SG4TNljFL/081gg==} + '@openai/codex@0.98.0': + resolution: {integrity: sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==} engines: {node: '>=16'} hasBin: true @@ -2203,8 +2239,8 @@ packages: '@types/node@24.10.9': resolution: {integrity: sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==} - '@types/node@25.2.0': - resolution: {integrity: sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==} + '@types/node@25.2.1': + resolution: {integrity: sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==} '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -2948,11 +2984,13 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true gopd@1.2.0: @@ -3347,8 +3385,8 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} - miniflare@4.20260128.0: - resolution: {integrity: sha512-AVCn3vDRY+YXu1sP4mRn81ssno6VUqxo29uY2QVfgxXU2TMLvhRIoGwm7RglJ3Gzfuidit5R86CMQ6AvdFTGAw==} + miniflare@4.20260205.0: + resolution: {integrity: sha512-jG1TknEDeFqcq/z5gsOm1rKeg4cNG7ruWxEuiPxl3pnQumavxo8kFpeQC6XKVpAhh2PI9ODGyIYlgd77sTHl5g==} engines: {node: '>=18.0.0'} hasBin: true @@ -3937,7 +3975,7 @@ packages: tar@7.5.6: resolution: {integrity: sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} @@ -4381,17 +4419,17 @@ packages: resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} engines: {node: '>=18'} - workerd@1.20260128.0: - resolution: {integrity: sha512-EhLJGptSGFi8AEErLiamO3PoGpbRqL+v4Ve36H2B38VxmDgFOSmDhfepBnA14sCQzGf1AEaoZX2DCwZsmO74yQ==} + workerd@1.20260205.0: + resolution: {integrity: sha512-CcMH5clHwrH8VlY7yWS9C/G/C8g9czIz1yU3akMSP9Z3CkEMFSoC3GGdj5G7Alw/PHEeez1+1IrlYger4pwu+w==} engines: {node: '>=16'} hasBin: true - wrangler@4.61.1: - resolution: {integrity: sha512-hfYQ16VLPkNi8xE1/V3052S2stM5e+vq3Idpt83sXoDC3R7R1CLgMkK6M6+Qp3G+9GVDNyHCkvohMPdfFTaD4Q==} + wrangler@4.63.0: + resolution: {integrity: sha512-+R04jF7Eb8K3KRMSgoXpcIdLb8GC62eoSGusYh1pyrSMm/10E0hbKkd7phMJO4HxXc6R7mOHC5SSoX9eof30Uw==} engines: {node: '>=20.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260128.0 + '@cloudflare/workers-types': ^4.20260205.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -4512,7 +4550,7 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@anthropic-ai/claude-code@2.1.29': + '@anthropic-ai/claude-code@2.1.34': optionalDependencies: '@img/sharp-darwin-arm64': 0.33.5 '@img/sharp-darwin-x64': 0.33.5 @@ -4557,15 +4595,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@4.4.2(@types/node@25.2.0)(@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.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)': 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.0)(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.1)(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.0)(jiti@1.21.7)(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) transitivePeerDependencies: - '@types/node' - jiti @@ -4580,9 +4618,9 @@ snapshots: - tsx - yaml - '@astrojs/tailwind@6.0.2(astro@5.16.15(@types/node@25.2.0)(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.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))': dependencies: - astro: 5.16.15(@types/node@25.2.0)(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.1)(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) @@ -5274,28 +5312,28 @@ snapshots: dependencies: '@cloudflare/containers': 0.0.30 - '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0)': + '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0)': dependencies: unenv: 2.0.0-rc.24 optionalDependencies: - workerd: 1.20260128.0 + workerd: 1.20260205.0 - '@cloudflare/workerd-darwin-64@1.20260128.0': + '@cloudflare/workerd-darwin-64@1.20260205.0': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260128.0': + '@cloudflare/workerd-darwin-arm64@1.20260205.0': optional: true - '@cloudflare/workerd-linux-64@1.20260128.0': + '@cloudflare/workerd-linux-64@1.20260205.0': optional: true - '@cloudflare/workerd-linux-arm64@1.20260128.0': + '@cloudflare/workerd-linux-arm64@1.20260205.0': optional: true - '@cloudflare/workerd-windows-64@1.20260128.0': + '@cloudflare/workerd-windows-64@1.20260205.0': optional: true - '@cloudflare/workers-types@4.20260131.0': {} + '@cloudflare/workers-types@4.20260206.0': {} '@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: @@ -5310,18 +5348,18 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@daytonaio/api-client@0.138.0': + '@daytonaio/api-client@0.139.0': dependencies: axios: 1.13.4 transitivePeerDependencies: - debug - '@daytonaio/sdk@0.138.0(ws@8.19.0)': + '@daytonaio/sdk@0.139.0(ws@8.19.0)': dependencies: '@aws-sdk/client-s3': 3.975.0 '@aws-sdk/lib-storage': 3.975.0(@aws-sdk/client-s3@3.975.0) - '@daytonaio/api-client': 0.138.0 - '@daytonaio/toolbox-api-client': 0.138.0 + '@daytonaio/api-client': 0.139.0 + '@daytonaio/toolbox-api-client': 0.139.0 '@iarna/toml': 2.2.5 axios: 1.13.4 busboy: 1.6.0 @@ -5338,7 +5376,7 @@ snapshots: - debug - ws - '@daytonaio/toolbox-api-client@0.138.0': + '@daytonaio/toolbox-api-client@0.139.0': dependencies: axios: 1.13.4 transitivePeerDependencies: @@ -5891,7 +5929,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@openai/codex@0.94.0': {} + '@openai/codex@0.98.0': {} '@oslojs/encoding@1.1.0': {} @@ -6427,13 +6465,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.1 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.2.0 + '@types/node': 25.2.1 '@types/ssh2': 1.15.5 '@types/estree@1.0.8': {} @@ -6466,7 +6504,7 @@ snapshots: dependencies: undici-types: 7.16.0 - '@types/node@25.2.0': + '@types/node@25.2.1': dependencies: undici-types: 7.16.0 @@ -6508,7 +6546,7 @@ snapshots: - bare-abort-controller - react-native-b4a - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.0))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.2.1))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -6516,11 +6554,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.0) + vite: 5.4.21(@types/node@25.2.1) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.2.0)(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.1)(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) @@ -6528,7 +6566,7 @@ 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.0)(jiti@1.21.7)(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) transitivePeerDependencies: - supports-color @@ -6540,13 +6578,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.2.0))': + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.2.1))': 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.0) + vite: 5.4.21(@types/node@25.2.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -6613,7 +6651,7 @@ snapshots: assertion-error@2.0.1: {} - astro@5.16.15(@types/node@25.2.0)(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.1)(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 @@ -6670,8 +6708,8 @@ snapshots: unist-util-visit: 5.1.0 unstorage: 1.17.4 vfile: 6.0.3 - vite: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - vitefu: 1.1.1(vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(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) + 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)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -8016,12 +8054,12 @@ snapshots: mimic-fn@4.0.0: {} - miniflare@4.20260128.0: + miniflare@4.20260205.0: dependencies: '@cspotcode/source-map-support': 0.8.1 sharp: 0.34.5 undici: 7.18.2 - workerd: 1.20260128.0 + workerd: 1.20260205.0 ws: 8.18.0 youch: 4.1.0-beta.10 transitivePeerDependencies: @@ -8287,7 +8325,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.2.0 + '@types/node': 25.2.1 long: 5.3.2 proxy-from-env@1.1.0: {} @@ -9000,13 +9038,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.2.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite-node@3.2.4(@types/node@25.2.1)(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.0)(jiti@1.21.7)(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) transitivePeerDependencies: - '@types/node' - jiti @@ -9030,13 +9068,13 @@ snapshots: '@types/node': 22.19.7 fsevents: 2.3.3 - vite@5.4.21(@types/node@25.2.0): + vite@5.4.21(@types/node@25.2.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 rollup: 4.56.0 optionalDependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.1 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): @@ -9054,7 +9092,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@6.4.1(@types/node@25.2.0)(jiti@1.21.7)(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): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -9063,21 +9101,21 @@ snapshots: rollup: 4.56.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.2.0 + '@types/node': 25.2.1 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.0)(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)): optionalDependencies: - vite: 6.4.1(@types/node@25.2.0)(jiti@1.21.7)(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) 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.0)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -9115,11 +9153,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.0)(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.1)(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.0)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.2.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -9137,12 +9175,12 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 5.4.21(@types/node@25.2.0) - vite-node: 3.2.4(@types/node@25.2.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + 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) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 - '@types/node': 25.2.0 + '@types/node': 25.2.1 transitivePeerDependencies: - jiti - less @@ -9184,26 +9222,26 @@ snapshots: dependencies: string-width: 7.2.0 - workerd@1.20260128.0: + workerd@1.20260205.0: optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260128.0 - '@cloudflare/workerd-darwin-arm64': 1.20260128.0 - '@cloudflare/workerd-linux-64': 1.20260128.0 - '@cloudflare/workerd-linux-arm64': 1.20260128.0 - '@cloudflare/workerd-windows-64': 1.20260128.0 + '@cloudflare/workerd-darwin-64': 1.20260205.0 + '@cloudflare/workerd-darwin-arm64': 1.20260205.0 + '@cloudflare/workerd-linux-64': 1.20260205.0 + '@cloudflare/workerd-linux-arm64': 1.20260205.0 + '@cloudflare/workerd-windows-64': 1.20260205.0 - wrangler@4.61.1(@cloudflare/workers-types@4.20260131.0): + wrangler@4.63.0(@cloudflare/workers-types@4.20260206.0): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 - '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260128.0) + '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260205.0) blake3-wasm: 2.1.5 esbuild: 0.27.0 - miniflare: 4.20260128.0 + miniflare: 4.20260205.0 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260128.0 + workerd: 1.20260205.0 optionalDependencies: - '@cloudflare/workers-types': 4.20260131.0 + '@cloudflare/workers-types': 4.20260206.0 fsevents: 2.3.3 transitivePeerDependencies: - bufferutil diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dda7e8c..f60a64a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,8 @@ packages: - "sdks/*" - "sdks/cli" - "sdks/cli/platforms/*" + - "sdks/gigacode" + - "sdks/gigacode/platforms/*" - "resources/agent-schemas" - "resources/vercel-ai-sdk-schemas" - "scripts/release" diff --git a/research/agents/amp.md b/research/agents/amp.md index f6b800f..ff314dd 100644 --- a/research/agents/amp.md +++ b/research/agents/amp.md @@ -4,11 +4,13 @@ Research notes on Sourcegraph Amp's configuration, credential discovery, and run ## Overview -- **Provider**: Anthropic (via Sourcegraph) +- **Provider**: Anthropic (via Sourcegraph, proxied through ampcode.com) - **Execution Method**: CLI subprocess (`amp` command) - **Session Persistence**: Session ID (string) - **SDK**: `@sourcegraph/amp-sdk` (closed source) +- **Binary**: Bun-bundled JS application (ELF wrapping Bun runtime + embedded JS) - **Binary Location**: `/usr/local/bin/amp` +- **Backend**: `https://ampcode.com/` (server-side proxy for all LLM requests) ## CLI Usage @@ -208,6 +210,211 @@ curl -fsSL "https://storage.googleapis.com/amp-public-assets-prod-0/cli/${VERSIO - Default timeout: 5 minutes (300,000 ms) - Process killed with `SIGTERM` on timeout +## Model Discovery + +**No model discovery mechanism exists.** Amp uses a server-side proxy architecture where model selection is abstracted behind "modes". + +### Architecture (Reverse Engineered) + +Amp is **NOT a Go binary** as previously thought — it is a **Bun-bundled JavaScript application** (ELF binary wrapping Bun runtime + embedded JS). The CLI logs confirm: `"argv":["bun","/$bunfs/root/amp-linux-x64",...]`. + +**Amp is a server-side proxy.** All LLM requests go through `https://ampcode.com/`: +1. CLI authenticates via `AMP_API_KEY` env var or browser-based OAuth to `https://ampcode.com/auth/cli-login` +2. On startup, calls `getUserInfo` against `https://ampcode.com/` +3. Model selection is handled **server-side**, not client-side + +### Modes Instead of Models + +Amp uses **modes** (`--mode` / `-m` flag) instead of direct model selection. Each mode bundles a model, system prompt, and tool selection together server-side. + +#### Agent Modes + +| Mode | Primary Model | Description | +|------|---------------|-------------| +| `smart` | Claude Opus 4.6 | Default. Unconstrained state-of-the-art model use, maximum capability and autonomy | +| `rush` | Claude Haiku 4.5 | Faster and cheaper, suitable for small, well-defined tasks | +| `deep` | GPT-5.2 Codex | Deep reasoning with extended thinking for complex problems. Requires `amp.experimental.modes: ["deep"]` | +| `free` | Unknown | Free tier (listed in CLI `--help` but not on docs site) | +| `large` | Unknown | Hidden/undocumented mode (referenced in docs but no details) | + +Source: [ampcode.com/manual](https://ampcode.com/manual), [ampcode.com/models](https://ampcode.com/models) + +#### Specialized Models (not user-selectable) + +Amp also uses additional models for specific subtasks: + +| Role | Model | Purpose | +|------|-------|---------| +| Review | Gemini 3 Pro | Code review and bug detection | +| Search subagent | Gemini 3 Flash | Codebase retrieval | +| Oracle subagent | GPT-5.2 | Complex code reasoning | +| Librarian subagent | Claude Sonnet 4.5 | External code research | +| Image/PDF analysis | Gemini 3 Flash | Multimodal input processing | +| Content generation | Gemini 3 Pro Image (Painter) | Image generation | +| Handoff (context) | Gemini 2.5 Flash | Context management | +| Thread categorization | Gemini 2.5 Flash-Lite | Thread organization | +| Title generation | Claude Haiku 4.5 | Thread title generation | + +#### Mode Subsettings + +- **`amp.experimental.modes`** — Array of experimental mode names to enable. Currently only `["deep"]` is documented. +- **`amp.internal.deepReasoningEffort`** — Override reasoning effort for GPT-5.2 Codex in deep mode. Options: `medium`, `high`, `xhigh`. Default: `medium`. Keyboard shortcut `Alt+D` cycles through `deep` → `deep²` → `deep³` (corresponding to medium → high → xhigh). + +#### Switching Modes + +- **CLI flag**: `--mode ` or `-m ` +- **Interactive TUI**: `Ctrl+O` → type "mode" +- **Editor extension**: Mode selector in the prompt field + +#### No Programmatic Mode Listing + +There is no CLI command (`amp modes list`) or API endpoint to list available modes. The modes are: +- Hardcoded in the `--help` text: `deep, free, rush, smart` +- Documented on [ampcode.com/manual](https://ampcode.com/manual) and [ampcode.com/models](https://ampcode.com/models) +- Up-to-date list available at [ampcode.com/manual#agent-modes](https://ampcode.com/manual#agent-modes) + +The `--model` flag also still exists on the CLI but modes are the primary interface. It's unclear if `--model` bypasses mode selection or if it's ignored. + +### Reverse Engineering Methodology + +#### Step 1: CLI help analysis + +```bash +amp --help +``` + +Revealed: +- `-m, --mode ` flag with `deep`, `free`, `rush`, `smart` options (not `--model` for models) +- `AMP_URL` env var defaults to `https://ampcode.com/` +- `AMP_API_KEY` env var for authentication +- Settings at `~/.config/amp/settings.json` +- Logs at `~/.cache/amp/logs/cli.log` + +#### Step 2: Binary analysis + +```bash +file ~/.local/bin/amp # → ELF 64-bit LSB executable, 117MB +ls -lh ~/.local/bin/amp # → 117M +strings ~/.local/bin/amp | grep 'ampcode' # → 43 matches, embedded JS visible +``` + +The `file` command showed an ELF binary, initially suggesting a compiled Go binary. But `strings` revealed embedded JavaScript source code, and the debug logs later confirmed it's actually a **Bun-bundled application** (`argv: ["bun", "/$bunfs/root/amp-linux-x64", ...]`). + +The embedded JS is minified but partially readable via `strings`. Found tool definitions (`edit_file`, `write_file`, `create_file`), skill loading code, and MCP integration code. Did not find hardcoded model lists or mode→model mappings — these are server-side. + +#### Step 3: strace (failed for network, useful for file IO) + +```bash +strace -e trace=connect -f amp --execute "say hello" ... +``` + +**Result: No `AF_INET` connections captured.** Only saw: +- `AF_UNIX` socket to `/tmp/tmux-1000/default` (tmux IPC) +- `socketpair()` for internal IPC between threads + +**Why it failed:** Bun uses `io_uring` for async network IO on Linux, which bypasses traditional `connect()`/`sendto()` syscalls. strace hooks into the syscall layer, but io_uring submits work directly to the kernel via shared memory rings, making it invisible to strace. + +Even with full syscall tracing (`strace -f -s 512` capturing 27,000 lines), zero TCP connections appeared. + +#### Step 4: Process network inspection (partial success) + +```bash +# While amp was running: +ss -tnp | grep amp +cat /proc//net/tcp6 +``` + +From `/proc/net/tcp6`, decoded a connection to port `01BB` (443/HTTPS). Resolved the destination to `34.54.147.251` via: + +```bash +dig ampcode.com +short # → 34.54.147.251 +``` + +Confirmed Amp connects to `ampcode.com:443`. But `ss -tnp` couldn't attribute the connection to the amp process (process had already exited or Bun's process model confused ss). + +#### Step 5: Debug logging (most useful) + +```bash +env AMP_API_KEY=fake-key amp --execute "say hello" --stream-json --log-level debug +# Then read: ~/.cache/amp/logs/cli.log +``` + +The debug log revealed the complete startup sequence and API flow. Key log messages: +- `"Initializing CLI context"` — shows `hasAmpAPIKey`, `hasAmpURL`, `hasSettingsFile` +- `"Resolved Amp URL"` → `https://ampcode.com/` +- `"API key lookup before login"` — `found: true/false` +- `"API request for getUserInfo failed: 401"` — confirms API call to ampcode.com with our fake key +- `"Starting Amp background services"` — proceeds even after auth failure + +#### Step 6: Fake API key to bypass login (success) + +Without `AMP_API_KEY`, Amp hangs indefinitely trying to open a browser for OAuth at `https://ampcode.com/auth/cli-login?authToken=...&callbackPort=...`. Setting `AMP_API_KEY=fake-key` bypasses the browser login flow and reaches the API call stage (where it gets a 401). + +#### Step 7: NODE_DEBUG (failed) + +```bash +env NODE_DEBUG=http,https,net amp ... +``` + +No output — Bun ignores Node.js debug environment variables. + +### What Was NOT Captured + +- **Actual HTTP request/response bodies** — Would require mitmproxy with HTTPS interception (set `amp.proxy` or `HTTPS_PROXY` env var, install custom CA cert). Not attempted. +- **Mode→model mappings** — These are server-side in ampcode.com. The CLI sends a mode name and the server selects the model. +- **Full API schema** — Only saw `getUserInfo` endpoint name in error message. Thread creation, message streaming, and other endpoints are unknown. +- **Whether `--model` bypasses mode selection** — Couldn't test without a valid API key. + +### Future Investigation + +To capture full HTTP traffic, set up mitmproxy: + +```bash +# Install mitmproxy +pip install mitmproxy + +# Start proxy +mitmproxy --listen-port 8080 + +# Run amp through proxy (amp.proxy setting or env var) +# amp respects amp.proxy setting in ~/.config/amp/settings.json: +# { "amp.proxy": "http://localhost:8080" } +# +# Then install mitmproxy's CA cert for TLS interception. +``` + +Alternatively, since amp is a Bun binary, it may respect `HTTPS_PROXY` env var by default (Go's `net/http` does, Bun's `fetch` may as well). + +### API Flow (from debug logs) + +``` +1. "Starting Amp CLI" (version 0.0.1770352274-gd36e02) +2. "Initializing CLI context" (hasAmpAPIKey: true/false) +3. "Resolved Amp URL" → https://ampcode.com/ +4. Skills loading, MCP initialization, toolbox registration +5. "API key lookup before login" +6. getUserInfo API call → https://ampcode.com/ (401 with invalid key) +7. "Starting Amp background services" +8. Thread creation + message streaming via ampcode.com +``` + +### Current Behavior + +The sandbox-agent passes `--model` through to Amp without validation: + +```rust +if let Some(model) = options.model.as_deref() { + command.arg("--model").arg(model); +} +``` + +### Possible Approaches + +1. **Proxy provider APIs** — Not applicable; Amp proxies through ampcode.com, not directly to model providers +2. **Hardcode known modes** — Expose the four modes (`deep`, `free`, `rush`, `smart`) as the available "model" options +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 + ## Notes - Amp is similar to Claude Code (same streaming format) diff --git a/research/agents/claude.md b/research/agents/claude.md index 42a552b..b78f278 100644 --- a/research/agents/claude.md +++ b/research/agents/claude.md @@ -226,6 +226,59 @@ Claude output is converted via `convertClaudeOutput()`: 3. Parse with `ClaudeCliResponseSchema` as fallback 4. Extract `structured_output` as metadata if present +## Model Discovery + +Claude Code's `/models` slash command uses the **standard Anthropic Models API**. + +### API Endpoint + +``` +GET https://api.anthropic.com/v1/models?beta=true +``` + +Found by reverse engineering the CLI bundle at `node_modules/@anthropic-ai/claude-code/cli.js`. + +### API Client + +The CLI contains an internal `Models` class with two methods: + +```javascript +// List all models +GET /v1/models?beta=true + +// Retrieve a single model +GET /v1/models/${modelId}?beta=true +``` + +Uses `this._client.getAPIList()` which handles paginated responses. The `?beta=true` query parameter is hardcoded to include beta/preview models. + +### Authentication + +Uses the same Anthropic API key / OAuth credentials that Claude Code uses for conversations. The request goes to the standard Anthropic API base URL. + +### Hardcoded Context Window Data + +The CLI also contains hardcoded output token limits for certain models (used as fallback): + +```javascript +{ + "claude-opus-4-20250514": 8192, + "claude-opus-4-0": 8192, + "claude-opus-4-1-20250805": 8192, + // ... more entries +} +``` + +### How to Replicate + +Call the Anthropic API directly — no need to go through the Claude CLI: + +``` +GET https://api.anthropic.com/v1/models?beta=true +x-api-key: +anthropic-version: 2023-06-01 +``` + ## Notes - Claude CLI manages its own OAuth refresh internally diff --git a/research/agents/codex.md b/research/agents/codex.md index b0e4098..8d3d970 100644 --- a/research/agents/codex.md +++ b/research/agents/codex.md @@ -318,6 +318,35 @@ fn codex_thread_id_from_server_notification(notification) -> Option { } ``` +## Model Discovery + +Codex exposes a `model/list` JSON-RPC method through its app-server process. + +### JSON-RPC Method + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "method": "model/list", + "params": { + "cursor": null, + "limit": null + } +} +``` + +Supports pagination via `cursor` and `limit` parameters. Defined in `resources/agent-schemas/artifacts/json-schema/codex.json`. + +### How to Replicate + +Requires a running Codex app-server process. Send the JSON-RPC request to the app-server over stdio. The response contains the list of models available to the Codex instance (depends on configured API keys / providers). + +### Limitations + +- Requires an active app-server process (cannot query models without starting one) +- No standalone CLI command like `codex models` + ## Notes - SDK is dynamically imported to reduce bundle size diff --git a/research/agents/opencode.md b/research/agents/opencode.md index 8a19fd2..8708282 100644 --- a/research/agents/opencode.md +++ b/research/agents/opencode.md @@ -509,6 +509,82 @@ const pollInterval = setInterval(async () => { }, 2000); ``` +## Model Discovery + +OpenCode has the richest model discovery support with both CLI and HTTP API. + +### CLI Commands + +```bash +opencode models # List all available models +opencode models # List models for a specific provider +``` + +### HTTP Endpoint + +``` +GET /provider +``` + +### Response Schema + +```json +{ + "all": [ + { + "id": "anthropic", + "name": "Anthropic", + "api": "string", + "env": ["ANTHROPIC_API_KEY"], + "npm": "string", + "models": { + "model-key": { + "id": "string", + "name": "string", + "family": "string", + "release_date": "string", + "attachment": true, + "reasoning": false, + "tool_call": true, + "cost": { + "input": 0.003, + "output": 0.015, + "cache_read": 0.0003, + "cache_write": 0.00375 + }, + "limit": { + "context": 200000, + "input": 200000, + "output": 8192 + }, + "modalities": { + "input": ["text", "image"], + "output": ["text"] + }, + "experimental": false, + "status": "beta" + } + } + } + ], + "default": { + "anthropic": "claude-sonnet-4-20250514" + }, + "connected": ["anthropic"] +} +``` + +### SDK Usage + +```typescript +const client = createOpencodeClient(); +const response = await client.provider.list(); +``` + +### How to Replicate + +When an OpenCode server is running, call `GET /provider` on its HTTP port. Returns full model metadata including capabilities, costs, context limits, and modalities. + ## Notes - OpenCode is the most feature-rich runtime (streaming, questions, permissions) diff --git a/research/opencode-compat/COMPARISON.md b/research/opencode-compat/COMPARISON.md new file mode 100644 index 0000000..08cc040 --- /dev/null +++ b/research/opencode-compat/COMPARISON.md @@ -0,0 +1,70 @@ +# Native OpenCode vs Sandbox-Agent: OpenCode API Comparison + +## Overview + +Captured API output from both native OpenCode server (v1.1.49) and sandbox-agent's +OpenCode compatibility layer, sending identical request patterns: +1. Message 1: Simple text response (echo/text) +2. Message 2: Tool call (ls/mock.search) + +## Bugs Found and Fixed + +### 1. Tool name (`tool` field) changed between events [FIXED] + +**Bug**: The `tool` field in tool part events changed between `pending` and `running`/`completed` +states. In the `pending` event it correctly showed `"mock.search"`, but in subsequent events +(from ToolResult) it showed `"tool"` because `extract_tool_content` doesn't return tool_name +for ToolResult items. + +**Fix**: Added `tool_name_by_call` HashMap to `OpenCodeSessionRuntime` to persist tool names +from ToolCall events and look them up during ToolResult processing. + +### 2. Tool `input` lost on ToolResult events [FIXED] + +**Bug**: When the ToolResult event came in, the tool's input arguments were lost because +ToolResult content only contains `call_id` and `output`, not arguments. + +**Fix**: Added `tool_args_by_call` HashMap to `OpenCodeSessionRuntime` to persist arguments +from ToolCall events and look them up during ToolResult processing. + +### 3. Tool `output` in wrong field (`error` instead of `output`) [FIXED] + +**Bug**: When tool result status was `Failed`, the output text was put in `"error"` field. +Native OpenCode uses `"output"` field for tool output regardless of success/failure. + +**Fix**: Changed the failed tool result JSON to use `"output"` instead of `"error"`. + +### 4. Text doubling in streaming [FIXED] + +**Bug**: During text streaming, `ItemStarted` emitted a text part with full content, then +`ItemDelta` appended delta text, then `ItemCompleted` emitted again, causing doubled text. + +**Fix**: `ItemStarted` now only initializes empty text in runtime without emitting a part event. +`ItemCompleted` emits the final text using accumulated delta text or fallback to content text. + +### 5. Missing `delta` field in text streaming events [FIXED] + +**Bug**: `delta` field was not included in `message.part.updated` events for text streaming. +Native OpenCode includes `delta` on streaming events and omits it on the final event. + +**Fix**: Changed `apply_item_delta` to use `part_event_with_delta` instead of `part_event`. + +### 6. Not bugs (noted for completeness) + +- **Missing `step-start`/`step-finish` parts**: These are OpenCode-specific (git snapshot + tracking) and not expected from sandbox-agent. +- **Missing `time` on text parts**: Minor; could be added in future. +- **Missing `time.completed` on some assistant messages**: Minor timing issue. + +## Verification + +After fixes, all tool events now correctly show: +- `"tool": "mock.search"` across all states (pending, running, error) +- `"input": {"query": "example"}` preserved across all states +- `"output": "mock search results"` on the error event (not `"error"`) +- Text streaming includes `delta` field +- No text doubling + +All 28 OpenCode compat tests pass. +All 10 session snapshot tests pass. +All 3 HTTP endpoint tests pass. diff --git a/research/opencode-compat/capture-native.ts b/research/opencode-compat/capture-native.ts new file mode 100644 index 0000000..07f3336 --- /dev/null +++ b/research/opencode-compat/capture-native.ts @@ -0,0 +1,260 @@ +/** + * Capture native OpenCode server API output for comparison. + * + * Usage: + * npx tsx capture-native.ts + * + * Starts a native OpenCode headless server, creates a Claude session, + * sends 2 messages (one that triggers tool calls), and captures all + * session events and message snapshots. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { createServer, type AddressInfo } from "node:net"; + +const OUTPUT_DIR = new URL("./snapshots/native", import.meta.url).pathname; + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForHealth(baseUrl: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${baseUrl}/global/health`); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error("Timed out waiting for native opencode health"); +} + +function saveJson(name: string, data: unknown) { + if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); + const path = `${OUTPUT_DIR}/${name}.json`; + writeFileSync(path, JSON.stringify(data, null, 2)); + console.log(` [saved] ${path}`); +} + +async function waitForIdle(baseUrl: string, sessionId: string, timeoutMs: number): Promise { + const start = Date.now(); + // Give a small initial delay for the status to change to busy + await new Promise((r) => setTimeout(r, 500)); + while (Date.now() - start < timeoutMs) { + try { + const statusRes = await fetch(`${baseUrl}/session/status`); + const statuses = await statusRes.json(); + const sessionStatus = statuses?.[sessionId]; + if (sessionStatus?.type === "idle" || sessionStatus === undefined) { + return; + } + } catch {} + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error("Timed out waiting for session to become idle"); +} + +async function main() { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + + console.log(`Starting native OpenCode server on port ${port}...`); + + const child: ChildProcess = spawn("opencode", ["serve", "--port", String(port)], { + stdio: "pipe", + env: { ...process.env }, + }); + + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.stdout?.on("data", (chunk) => { + const text = chunk.toString(); + if (text.includes("listening")) console.log(` [opencode] ${text.trim()}`); + }); + + // Track all SSE events in a separate array + const allEvents: any[] = []; + let sseAbort: AbortController | null = null; + let currentBaseUrl = ""; + + try { + await waitForHealth(baseUrl); + currentBaseUrl = baseUrl; + console.log("Native OpenCode server is healthy!"); + + // 1. Capture initial metadata + const [agentRes, configRes] = await Promise.all([ + fetch(`${baseUrl}/agent`).then((r) => r.json()), + fetch(`${baseUrl}/config`).then((r) => r.json()), + ]); + saveJson("metadata-agent", agentRes); + saveJson("metadata-config", configRes); + + // 2. Start SSE event collection + sseAbort = new AbortController(); + const ssePromise = (async () => { + try { + const res = await fetch(`${baseUrl}/event`, { + signal: sseAbort!.signal, + headers: { Accept: "text/event-stream" }, + }); + if (!res.ok || !res.body) { + console.error("SSE connection failed:", res.status); + return; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const parsed = JSON.parse(line.slice(6)); + allEvents.push(parsed); + // Auto-approve permissions + if (parsed.type === "permission.asked" && parsed.properties?.id) { + const permId = parsed.properties.id; + console.log(` [auto-approving permission ${permId}]`); + fetch(`${currentBaseUrl}/permission/${permId}/reply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allow: true }), + }).catch(() => {}); + } + } catch {} + } + } + } + } catch (err: any) { + if (err.name !== "AbortError") { + // Ignore - expected when server closes + } + } + })(); + + // Give SSE time to connect + await new Promise((r) => setTimeout(r, 500)); + + // 3. Create a session + console.log("Creating session..."); + const sessionRes = await fetch(`${baseUrl}/session`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const session = await sessionRes.json(); + saveJson("session-create", session); + const sessionId = session.id; + console.log(` Session ID: ${sessionId}`); + + // Use anthropic provider with a cheap model for testing + const model = { providerID: "anthropic", modelID: "claude-haiku-4-5" }; + + // 4. Send first message (simple text response) - use prompt_async + wait + console.log("Sending message 1 (simple text)..."); + await fetch(`${baseUrl}/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + parts: [{ type: "text", text: "Respond with exactly: 'Hello from OpenCode'. Nothing else." }], + }), + }); + + // Wait for the response to be fully processed + console.log(" Waiting for message 1 to complete..."); + await waitForIdle(baseUrl, sessionId, 60_000); + await new Promise((r) => setTimeout(r, 1000)); + + // 5. Get messages after first request + const messagesAfter1 = await fetch(`${baseUrl}/session/${sessionId}/message`).then((r) => + r.json() + ); + saveJson("messages-after-1", messagesAfter1); + console.log(` Got ${messagesAfter1.length} messages after msg 1`); + + // 6. Send second message (ask for a tool call - file write) - use prompt_async + console.log("Sending message 2 (should trigger tool calls)..."); + await fetch(`${baseUrl}/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + parts: [ + { + type: "text", + text: "List the files in the current directory. Use the list/ls tool. Only list the top-level contents, do not recurse.", + }, + ], + }), + }); + + // Wait for completion (longer timeout for tool calls + permissions) + console.log(" Waiting for message 2 to complete..."); + try { + await waitForIdle(baseUrl, sessionId, 120_000); + } catch (e) { + console.log(" Warning: timed out waiting for idle, capturing what we have..."); + } + await new Promise((r) => setTimeout(r, 2000)); + + // 7. Get messages after second request + const messagesAfter2 = await fetch(`${baseUrl}/session/${sessionId}/message`).then((r) => + r.json() + ); + saveJson("messages-after-2", messagesAfter2); + console.log(` Got ${messagesAfter2.length} messages after msg 2`); + + // 8. Get session details + const sessionDetails = await fetch(`${baseUrl}/session/${sessionId}`).then((r) => r.json()); + saveJson("session-details", sessionDetails); + + // 9. Get session status + const sessionStatus = await fetch(`${baseUrl}/session/status`).then((r) => r.json()); + saveJson("session-status", sessionStatus); + + // 10. Stop SSE and save events + sseAbort.abort(); + await new Promise((r) => setTimeout(r, 500)); + saveJson("all-events", allEvents); + + // Filter events for this session + const sessionEvents = allEvents.filter( + (e) => e.properties?.sessionID === sessionId || + (e.type === "session.created" && e.properties?.info?.id === sessionId) + ); + saveJson("session-events", sessionEvents); + + console.log(`\nCapture complete! ${allEvents.length} total events, ${sessionEvents.length} session events.`); + console.log(`Output saved to: ${OUTPUT_DIR}/`); + } finally { + if (sseAbort) sseAbort.abort(); + child.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 1000)); + if (child.exitCode === null) child.kill("SIGKILL"); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/research/opencode-compat/capture-sandbox-agent.ts b/research/opencode-compat/capture-sandbox-agent.ts new file mode 100644 index 0000000..9d14ea7 --- /dev/null +++ b/research/opencode-compat/capture-sandbox-agent.ts @@ -0,0 +1,249 @@ +/** + * Capture sandbox-agent OpenCode compatibility API output for comparison. + * + * Usage: + * npx tsx capture-sandbox-agent.ts + * + * Starts sandbox-agent with mock agent, creates a session via /opencode API, + * sends 2 messages (text + tool call), and captures all events/messages. + */ +import { spawn, type ChildProcess } from "node:child_process"; +import { writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { createServer, type AddressInfo } from "node:net"; +import { randomBytes } from "node:crypto"; + +const OUTPUT_DIR = new URL("./snapshots/sandbox-agent", import.meta.url).pathname; + +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + server.close(() => resolve(address.port)); + }); + }); +} + +async function waitForHealth(baseUrl: string, token: string, timeoutMs = 30_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${baseUrl}/v1/health`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) return; + } catch {} + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error("Timed out waiting for sandbox-agent health"); +} + +function saveJson(name: string, data: unknown) { + if (!existsSync(OUTPUT_DIR)) mkdirSync(OUTPUT_DIR, { recursive: true }); + const path = `${OUTPUT_DIR}/${name}.json`; + writeFileSync(path, JSON.stringify(data, null, 2)); + console.log(` [saved] ${path}`); +} + +async function main() { + const port = await getFreePort(); + const host = "127.0.0.1"; + const baseUrl = `http://${host}:${port}`; + const opencodeUrl = `${baseUrl}/opencode`; + const token = randomBytes(24).toString("hex"); + + console.log(`Starting sandbox-agent on port ${port}...`); + + // Use the locally built binary, not the installed one + const binaryPath = new URL("../../target/release/sandbox-agent", import.meta.url).pathname; + const child: ChildProcess = spawn( + binaryPath, + ["server", "--host", host, "--port", String(port), "--token", token], + { + stdio: "pipe", + env: { + ...process.env, + SANDBOX_AGENT_SKIP_INSPECTOR: "1", + }, + } + ); + + let stderr = ""; + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + + const allEvents: any[] = []; + let sseAbort: AbortController | null = null; + + try { + await waitForHealth(baseUrl, token); + console.log("sandbox-agent is healthy!"); + + // 1. Capture initial metadata via /opencode routes + const headers = { Authorization: `Bearer ${token}` }; + const [agentRes, configRes] = await Promise.all([ + fetch(`${opencodeUrl}/agent`, { headers }).then((r) => r.json()), + fetch(`${opencodeUrl}/config`, { headers }).then((r) => r.json()), + ]); + saveJson("metadata-agent", agentRes); + saveJson("metadata-config", configRes); + + // 2. Start SSE event collection + sseAbort = new AbortController(); + const ssePromise = (async () => { + try { + const res = await fetch(`${opencodeUrl}/event`, { + signal: sseAbort!.signal, + headers: { ...headers, Accept: "text/event-stream" }, + }); + if (!res.ok || !res.body) { + console.error("SSE connection failed:", res.status, await res.text()); + return; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (line.startsWith("data: ")) { + try { + const parsed = JSON.parse(line.slice(6)); + allEvents.push(parsed); + } catch {} + } + } + } + } catch (err: any) { + if (err.name !== "AbortError") { + // ignore + } + } + })(); + + // Give SSE time to connect + await new Promise((r) => setTimeout(r, 500)); + + // 3. Create a session + console.log("Creating session..."); + const sessionRes = await fetch(`${opencodeUrl}/session`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const session = await sessionRes.json(); + saveJson("session-create", session); + const sessionId = session.id; + console.log(` Session ID: ${sessionId}`); + + // 4. Send first message (simple text response) using mock agent's "echo" command + console.log("Sending message 1 (simple text - echo)..."); + const msg1Res = await fetch(`${opencodeUrl}/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "echo Hello from sandbox-agent" }], + }), + }); + console.log(` prompt_async status: ${msg1Res.status}`); + + // Wait for idle + console.log(" Waiting for message 1 to complete..."); + await waitForIdle(opencodeUrl, sessionId, headers, 30_000); + await new Promise((r) => setTimeout(r, 1000)); + + // 5. Get messages after first request + const messagesAfter1 = await fetch(`${opencodeUrl}/session/${sessionId}/message`, { headers }).then((r) => r.json()); + saveJson("messages-after-1", messagesAfter1); + console.log(` Got ${messagesAfter1.length} messages after msg 1`); + + // 6. Send second message (trigger tool calls) using mock agent's "tool" command + console.log("Sending message 2 (tool calls)..."); + const msg2Res = await fetch(`${opencodeUrl}/session/${sessionId}/prompt_async`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + model: { providerID: "sandbox-agent", modelID: "mock" }, + parts: [{ type: "text", text: "tool" }], + }), + }); + console.log(` prompt_async status: ${msg2Res.status}`); + + // Wait for completion + console.log(" Waiting for message 2 to complete..."); + await waitForIdle(opencodeUrl, sessionId, headers, 30_000); + await new Promise((r) => setTimeout(r, 1000)); + + // 7. Get messages after second request + const messagesAfter2 = await fetch(`${opencodeUrl}/session/${sessionId}/message`, { headers }).then((r) => r.json()); + saveJson("messages-after-2", messagesAfter2); + console.log(` Got ${messagesAfter2.length} messages after msg 2`); + + // 8. Get session details + const sessionDetails = await fetch(`${opencodeUrl}/session/${sessionId}`, { headers }).then((r) => r.json()); + saveJson("session-details", sessionDetails); + + // 9. Get session status + const sessionStatus = await fetch(`${opencodeUrl}/session/status`, { headers }).then((r) => r.json()); + saveJson("session-status", sessionStatus); + + // 10. Stop SSE and save events + sseAbort.abort(); + await new Promise((r) => setTimeout(r, 500)); + saveJson("all-events", allEvents); + + // Filter session events + const sessionEvents = allEvents.filter( + (e) => + e.properties?.sessionID === sessionId || + (e.type === "session.created" && e.properties?.info?.id === sessionId) + ); + saveJson("session-events", sessionEvents); + + console.log(`\nCapture complete! ${allEvents.length} total events, ${sessionEvents.length} session events.`); + console.log(`Output saved to: ${OUTPUT_DIR}/`); + } finally { + if (sseAbort) sseAbort.abort(); + child.kill("SIGTERM"); + await new Promise((r) => setTimeout(r, 1000)); + if (child.exitCode === null) child.kill("SIGKILL"); + } +} + +async function waitForIdle( + opencodeUrl: string, + sessionId: string, + headers: Record, + timeoutMs: number +): Promise { + const start = Date.now(); + await new Promise((r) => setTimeout(r, 500)); + while (Date.now() - start < timeoutMs) { + try { + const statusRes = await fetch(`${opencodeUrl}/session/status`, { headers }); + const statuses = await statusRes.json(); + const sessionStatus = statuses?.[sessionId]; + if (sessionStatus?.type === "idle" || sessionStatus === undefined) { + return; + } + } catch {} + await new Promise((r) => setTimeout(r, 300)); + } + throw new Error("Timed out waiting for session to become idle"); +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); diff --git a/research/opencode-compat/snapshots/native/all-events.json b/research/opencode-compat/snapshots/native/all-events.json new file mode 100644 index 0000000..7156d8e --- /dev/null +++ b/research/opencode-compat/snapshots/native/all-events.json @@ -0,0 +1,1281 @@ +[ + { + "type": "server.connected", + "properties": {} + }, + { + "type": "session.created", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "New session - 2026-02-06T06:56:52.806Z", + "time": { + "created": 1770361012806, + "updated": 1770361012806 + } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "New session - 2026-02-06T06:56:52.806Z", + "time": { + "created": 1770361012806, + "updated": 1770361012806 + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd424c001ICzhibLcSkazYE", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361012812 + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd424c002I8ASgZXZGMnUf2", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd424c001ICzhibLcSkazYE", + "type": "text", + "text": "Respond with exactly: 'Hello from OpenCode'. Nothing else." + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "New session - 2026-02-06T06:56:52.806Z", + "time": { + "created": 1770361012806, + "updated": 1770361012825 + } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4260001o9JzwTa1Ops17t", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361012832 + }, + "parentID": "msg_c31bd424c001ICzhibLcSkazYE", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd424c001ICzhibLcSkazYE", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361012812 + }, + "summary": { + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "New session - 2026-02-06T06:56:52.806Z", + "time": { + "created": 1770361012806, + "updated": 1770361012862 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361012862 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd424c001ICzhibLcSkazYE", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361012812 + }, + "summary": { + "title": "Hello from OpenCode", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4779001QLU4EXzj63WQ4W", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "step-start", + "snapshot": "302d31eedbdd77a5b1eb84e2fbc4e99e8b97e549" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd477c001Gq3CWQQXR1h7fD", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "text", + "text": "Hello from OpenCode", + "time": { + "start": 1770361014140 + } + }, + "delta": "Hello from OpenCode" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd477c001Gq3CWQQXR1h7fD", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "text", + "text": "Hello from OpenCode", + "time": { + "start": 1770361014146, + "end": 1770361014146 + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4793001LdK7WQwiUgzctY", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "step-finish", + "reason": "stop", + "snapshot": "302d31eedbdd77a5b1eb84e2fbc4e99e8b97e549", + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4260001o9JzwTa1Ops17t", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361012832 + }, + "parentID": "msg_c31bd424c001ICzhibLcSkazYE", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + }, + "finish": "stop" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4260001o9JzwTa1Ops17t", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361012832, + "completed": 1770361014171 + }, + "parentID": "msg_c31bd424c001ICzhibLcSkazYE", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + }, + "finish": "stop" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "idle" + } + } + }, + { + "type": "session.idle", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms" + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd424c001ICzhibLcSkazYE", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361012812 + }, + "summary": { + "title": "Hello from OpenCode", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361014177 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361015323 + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4c1b00250iBBBWSNWnE4G", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "type": "text", + "text": "List the files in the current directory. Use the list/ls tool. Only list the top-level contents, do not recurse." + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361015323 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361015324 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361015323 + }, + "summary": { + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361015333 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361015323 + }, + "summary": { + "title": "List directory contents", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4ef7001AOk3Asd0o7j5fD", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "step-start", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4ef8001ovzy76OXAT5Qin", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "tool", + "callID": "toolu_017THj1iZNELroZgmFbqC6Ma", + "tool": "bash", + "state": { + "status": "pending", + "input": {}, + "raw": "" + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4ef8001ovzy76OXAT5Qin", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "tool", + "callID": "toolu_017THj1iZNELroZgmFbqC6Ma", + "tool": "bash", + "state": { + "status": "running", + "input": { + "command": "ls -la", + "description": "List files in current directory" + }, + "time": { + "start": 1770361016309 + } + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4ef8001ovzy76OXAT5Qin", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "tool", + "callID": "toolu_017THj1iZNELroZgmFbqC6Ma", + "tool": "bash", + "state": { + "status": "running", + "input": { + "command": "ls -la", + "description": "List files in current directory" + }, + "metadata": { + "output": "", + "description": "List files in current directory" + }, + "time": { + "start": 1770361016328 + } + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4ef8001ovzy76OXAT5Qin", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "tool", + "callID": "toolu_017THj1iZNELroZgmFbqC6Ma", + "tool": "bash", + "state": { + "status": "running", + "input": { + "command": "ls -la", + "description": "List files in current directory" + }, + "metadata": { + "output": "total 24\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:56 .\ndrwxr-xr-x 5 nathan nathan 4096 Feb 5 22:44 ..\n-rw-r--r-- 1 nathan nathan 9120 Feb 5 22:56 capture-native.ts\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:45 snapshots\n", + "description": "List files in current directory" + }, + "time": { + "start": 1770361016329 + } + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd4ef8001ovzy76OXAT5Qin", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "tool", + "callID": "toolu_017THj1iZNELroZgmFbqC6Ma", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "ls -la", + "description": "List files in current directory" + }, + "output": "total 24\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:56 .\ndrwxr-xr-x 5 nathan nathan 4096 Feb 5 22:44 ..\n-rw-r--r-- 1 nathan nathan 9120 Feb 5 22:56 capture-native.ts\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:45 snapshots\n", + "title": "List files in current directory", + "metadata": { + "output": "total 24\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:56 .\ndrwxr-xr-x 5 nathan nathan 4096 Feb 5 22:44 ..\n-rw-r--r-- 1 nathan nathan 9120 Feb 5 22:56 capture-native.ts\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:45 snapshots\n", + "exit": 0, + "description": "List files in current directory", + "truncated": false + }, + "time": { + "start": 1770361016309, + "end": 1770361016330 + } + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd500e001MnhwCXaWI2pfAw", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3", + "cost": 0, + "tokens": { + "input": 2, + "output": 78, + "reasoning": 0, + "cache": { + "read": 13547, + "write": 31 + } + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361015324 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 78, + "reasoning": 0, + "cache": { + "read": 13547, + "write": 31 + } + }, + "finish": "tool-calls" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361015324, + "completed": 1770361016339 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 78, + "reasoning": 0, + "cache": { + "read": 13547, + "write": 31 + } + }, + "finish": "tool-calls" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd5014001lZUdLPnaNuUzrb", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361016340 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361015323 + }, + "summary": { + "title": "List directory contents", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361016350 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580017z4yaEbtkZX0zx", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "step-start", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here", + "time": { + "start": 1770361017176 + } + }, + "delta": "Here" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top", + "time": { + "start": 1770361017176 + } + }, + "delta": " are the top" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents", + "time": { + "start": 1770361017176 + } + }, + "delta": "-level contents" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n-", + "time": { + "start": 1770361017176 + } + }, + "delta": " of the current directory:\n\n-" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **", + "time": { + "start": 1770361017176 + } + }, + "delta": " **" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **capture-native.ts** - A", + "time": { + "start": 1770361017176 + } + }, + "delta": "capture-native.ts** - A" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **capture-native.ts** - A TypeScript file", + "time": { + "start": 1770361017176 + } + }, + "delta": " TypeScript file" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **capture-native.ts** - A TypeScript file\n- **snapshots/**", + "time": { + "start": 1770361017176 + } + }, + "delta": "\n- **snapshots/**" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **capture-native.ts** - A TypeScript file\n- **snapshots/** - A directory", + "time": { + "start": 1770361017176 + } + }, + "delta": " - A directory" + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **capture-native.ts** - A TypeScript file\n- **snapshots/** - A directory", + "time": { + "start": 1770361017436, + "end": 1770361017436 + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_c31bd546d001cppsZFrg2ZJy7S", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "step-finish", + "reason": "stop", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3", + "cost": 0, + "tokens": { + "input": 5, + "output": 38, + "reasoning": 0, + "cache": { + "read": 13578, + "write": 207 + } + } + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd5014001lZUdLPnaNuUzrb", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361016340 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 5, + "output": 38, + "reasoning": 0, + "cache": { + "read": 13578, + "write": 207 + } + }, + "finish": "stop" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd5014001lZUdLPnaNuUzrb", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361016340, + "completed": 1770361017458 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 5, + "output": 38, + "reasoning": 0, + "cache": { + "read": 13578, + "write": 207 + } + }, + "finish": "stop" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "idle" + } + } + }, + { + "type": "session.idle", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms" + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361015323 + }, + "summary": { + "title": "List directory contents", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + } + } + }, + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361017462 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/message-1-response.json b/research/opencode-compat/snapshots/native/message-1-response.json new file mode 100644 index 0000000..2cfeb88 --- /dev/null +++ b/research/opencode-compat/snapshots/native/message-1-response.json @@ -0,0 +1,69 @@ +{ + "info": { + "id": "msg_c31b8e048001p0fSvme0VWmKR0", + "sessionID": "ses_3ce471fc8ffeS9ZUpB7rVDen7k", + "role": "assistant", + "time": { + "created": 1770360725576, + "completed": 1770360727252 + }, + "parentID": "msg_c31b8e040001pIkL4AzVtzdVRd", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 0, + "write": 13540 + } + }, + "finish": "stop" + }, + "parts": [ + { + "id": "prt_c31b8e6b10012vw5mZVWX51UQm", + "sessionID": "ses_3ce471fc8ffeS9ZUpB7rVDen7k", + "messageID": "msg_c31b8e048001p0fSvme0VWmKR0", + "type": "step-start", + "snapshot": "f93f1b3f790c9b1fe51007d1b4a46bcb2d528a91" + }, + { + "id": "prt_c31b8e6b1002OBaD5M55iOVva6", + "sessionID": "ses_3ce471fc8ffeS9ZUpB7rVDen7k", + "messageID": "msg_c31b8e048001p0fSvme0VWmKR0", + "type": "text", + "text": "Hello from OpenCode", + "time": { + "start": 1770360727218, + "end": 1770360727218 + } + }, + { + "id": "prt_c31b8e6cd0012b1geOngJvrf2q", + "sessionID": "ses_3ce471fc8ffeS9ZUpB7rVDen7k", + "messageID": "msg_c31b8e048001p0fSvme0VWmKR0", + "type": "step-finish", + "reason": "stop", + "snapshot": "f93f1b3f790c9b1fe51007d1b4a46bcb2d528a91", + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 0, + "write": 13540 + } + } + } + ] +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/message-2-response.json b/research/opencode-compat/snapshots/native/message-2-response.json new file mode 100644 index 0000000..19c5b2c --- /dev/null +++ b/research/opencode-compat/snapshots/native/message-2-response.json @@ -0,0 +1,52 @@ +{ + "info": { + "id": "msg_c31b349c3001C3jUz57fj5vRae", + "sessionID": "ses_3ce4cbd76ffeHVa3mLvB00FXDV", + "role": "assistant", + "time": { + "created": 1770360359363, + "completed": 1770360359565 + }, + "error": { + "name": "APIError", + "data": { + "message": "Unauthorized: {\"type\":\"error\",\"error\":{\"type\":\"AuthError\",\"message\":\"Invalid API key.\"}}", + "statusCode": 401, + "isRetryable": false, + "responseHeaders": { + "cf-placement": "local-SJC", + "cf-ray": "9c98b01658355024-SJC", + "connection": "keep-alive", + "content-length": "74", + "content-type": "text/plain;charset=UTF-8", + "date": "Fri, 06 Feb 2026 06:45:59 GMT", + "server": "cloudflare" + }, + "responseBody": "{\"type\":\"error\",\"error\":{\"type\":\"AuthError\",\"message\":\"Invalid API key.\"}}", + "metadata": { + "url": "https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse" + } + } + }, + "parentID": "msg_c31b349c2001kJD7I7MRSAVo57", + "modelID": "gemini-3-pro", + "providerID": "opencode", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { + "read": 0, + "write": 0 + } + } + }, + "parts": [] +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/messages-after-1.json b/research/opencode-compat/snapshots/native/messages-after-1.json new file mode 100644 index 0000000..0daa794 --- /dev/null +++ b/research/opencode-compat/snapshots/native/messages-after-1.json @@ -0,0 +1,99 @@ +[ + { + "info": { + "id": "msg_c31bd424c001ICzhibLcSkazYE", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361012812 + }, + "summary": { + "title": "Hello from OpenCode", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + }, + "parts": [ + { + "id": "prt_c31bd424c002I8ASgZXZGMnUf2", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd424c001ICzhibLcSkazYE", + "type": "text", + "text": "Respond with exactly: 'Hello from OpenCode'. Nothing else." + } + ] + }, + { + "info": { + "id": "msg_c31bd4260001o9JzwTa1Ops17t", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361012832, + "completed": 1770361014171 + }, + "parentID": "msg_c31bd424c001ICzhibLcSkazYE", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + }, + "finish": "stop" + }, + "parts": [ + { + "id": "prt_c31bd4779001QLU4EXzj63WQ4W", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "step-start", + "snapshot": "302d31eedbdd77a5b1eb84e2fbc4e99e8b97e549" + }, + { + "id": "prt_c31bd477c001Gq3CWQQXR1h7fD", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "text", + "text": "Hello from OpenCode", + "time": { + "start": 1770361014146, + "end": 1770361014146 + } + }, + { + "id": "prt_c31bd4793001LdK7WQwiUgzctY", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "step-finish", + "reason": "stop", + "snapshot": "302d31eedbdd77a5b1eb84e2fbc4e99e8b97e549", + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + } + } + ] + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/messages-after-2.json b/research/opencode-compat/snapshots/native/messages-after-2.json new file mode 100644 index 0000000..886ba3d --- /dev/null +++ b/research/opencode-compat/snapshots/native/messages-after-2.json @@ -0,0 +1,281 @@ +[ + { + "info": { + "id": "msg_c31bd424c001ICzhibLcSkazYE", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361012812 + }, + "summary": { + "title": "Hello from OpenCode", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + }, + "parts": [ + { + "id": "prt_c31bd424c002I8ASgZXZGMnUf2", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd424c001ICzhibLcSkazYE", + "type": "text", + "text": "Respond with exactly: 'Hello from OpenCode'. Nothing else." + } + ] + }, + { + "info": { + "id": "msg_c31bd4260001o9JzwTa1Ops17t", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361012832, + "completed": 1770361014171 + }, + "parentID": "msg_c31bd424c001ICzhibLcSkazYE", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + }, + "finish": "stop" + }, + "parts": [ + { + "id": "prt_c31bd4779001QLU4EXzj63WQ4W", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "step-start", + "snapshot": "302d31eedbdd77a5b1eb84e2fbc4e99e8b97e549" + }, + { + "id": "prt_c31bd477c001Gq3CWQQXR1h7fD", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "text", + "text": "Hello from OpenCode", + "time": { + "start": 1770361014146, + "end": 1770361014146 + } + }, + { + "id": "prt_c31bd4793001LdK7WQwiUgzctY", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4260001o9JzwTa1Ops17t", + "type": "step-finish", + "reason": "stop", + "snapshot": "302d31eedbdd77a5b1eb84e2fbc4e99e8b97e549", + "cost": 0, + "tokens": { + "input": 2, + "output": 7, + "reasoning": 0, + "cache": { + "read": 13540, + "write": 0 + } + } + } + ] + }, + { + "info": { + "id": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "user", + "time": { + "created": 1770361015323 + }, + "summary": { + "title": "List directory contents", + "diffs": [] + }, + "agent": "build", + "model": { + "providerID": "anthropic", + "modelID": "claude-haiku-4-5" + } + }, + "parts": [ + { + "id": "prt_c31bd4c1b00250iBBBWSNWnE4G", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "type": "text", + "text": "List the files in the current directory. Use the list/ls tool. Only list the top-level contents, do not recurse." + } + ] + }, + { + "info": { + "id": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361015324, + "completed": 1770361016339 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 2, + "output": 78, + "reasoning": 0, + "cache": { + "read": 13547, + "write": 31 + } + }, + "finish": "tool-calls" + }, + "parts": [ + { + "id": "prt_c31bd4ef7001AOk3Asd0o7j5fD", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "step-start", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3" + }, + { + "id": "prt_c31bd4ef8001ovzy76OXAT5Qin", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "tool", + "callID": "toolu_017THj1iZNELroZgmFbqC6Ma", + "tool": "bash", + "state": { + "status": "completed", + "input": { + "command": "ls -la", + "description": "List files in current directory" + }, + "output": "total 24\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:56 .\ndrwxr-xr-x 5 nathan nathan 4096 Feb 5 22:44 ..\n-rw-r--r-- 1 nathan nathan 9120 Feb 5 22:56 capture-native.ts\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:45 snapshots\n", + "title": "List files in current directory", + "metadata": { + "output": "total 24\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:56 .\ndrwxr-xr-x 5 nathan nathan 4096 Feb 5 22:44 ..\n-rw-r--r-- 1 nathan nathan 9120 Feb 5 22:56 capture-native.ts\ndrwxr-xr-x 3 nathan nathan 4096 Feb 5 22:45 snapshots\n", + "exit": 0, + "description": "List files in current directory", + "truncated": false + }, + "time": { + "start": 1770361016309, + "end": 1770361016330 + } + } + }, + { + "id": "prt_c31bd500e001MnhwCXaWI2pfAw", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd4c1c001iWeGOWfwf5UD2Y", + "type": "step-finish", + "reason": "tool-calls", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3", + "cost": 0, + "tokens": { + "input": 2, + "output": 78, + "reasoning": 0, + "cache": { + "read": 13547, + "write": 31 + } + } + } + ] + }, + { + "info": { + "id": "msg_c31bd5014001lZUdLPnaNuUzrb", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "role": "assistant", + "time": { + "created": 1770361016340, + "completed": 1770361017458 + }, + "parentID": "msg_c31bd4c1b001xWoyoqqLd1Y28L", + "modelID": "claude-haiku-4-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent" + }, + "cost": 0, + "tokens": { + "input": 5, + "output": 38, + "reasoning": 0, + "cache": { + "read": 13578, + "write": 207 + } + }, + "finish": "stop" + }, + "parts": [ + { + "id": "prt_c31bd53580017z4yaEbtkZX0zx", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "step-start", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3" + }, + { + "id": "prt_c31bd53580021848cNw3geDcGF", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "text", + "text": "Here are the top-level contents of the current directory:\n\n- **capture-native.ts** - A TypeScript file\n- **snapshots/** - A directory", + "time": { + "start": 1770361017436, + "end": 1770361017436 + } + }, + { + "id": "prt_c31bd546d001cppsZFrg2ZJy7S", + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "messageID": "msg_c31bd5014001lZUdLPnaNuUzrb", + "type": "step-finish", + "reason": "stop", + "snapshot": "8793fb311ffba7bff79cd1b25f87942c22349ae3", + "cost": 0, + "tokens": { + "input": 5, + "output": 38, + "reasoning": 0, + "cache": { + "read": 13578, + "write": 207 + } + } + } + ] + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/metadata-agent.json b/research/opencode-compat/snapshots/native/metadata-agent.json new file mode 100644 index 0000000..fd13d2a --- /dev/null +++ b/research/opencode-compat/snapshots/native/metadata-agent.json @@ -0,0 +1,605 @@ +[ + { + "name": "build", + "description": "The default agent. Executes tools based on configured permissions.", + "options": {}, + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "question", + "action": "allow", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "allow", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "mode": "primary", + "native": true + }, + { + "name": "plan", + "description": "Plan mode. Disallows all edit tools.", + "options": {}, + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "question", + "action": "allow", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "allow", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/plans/*", + "action": "allow" + }, + { + "permission": "edit", + "pattern": "*", + "action": "deny" + }, + { + "permission": "edit", + "pattern": ".opencode/plans/*.md", + "action": "allow" + }, + { + "permission": "edit", + "pattern": "../.local/share/opencode/plans/*.md", + "action": "allow" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "mode": "primary", + "native": true + }, + { + "name": "general", + "description": "General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.", + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "todoread", + "action": "deny", + "pattern": "*" + }, + { + "permission": "todowrite", + "action": "deny", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "options": {}, + "mode": "subagent", + "native": true + }, + { + "name": "explore", + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "*", + "action": "deny", + "pattern": "*" + }, + { + "permission": "grep", + "action": "allow", + "pattern": "*" + }, + { + "permission": "glob", + "action": "allow", + "pattern": "*" + }, + { + "permission": "list", + "action": "allow", + "pattern": "*" + }, + { + "permission": "bash", + "action": "allow", + "pattern": "*" + }, + { + "permission": "webfetch", + "action": "allow", + "pattern": "*" + }, + { + "permission": "websearch", + "action": "allow", + "pattern": "*" + }, + { + "permission": "codesearch", + "action": "allow", + "pattern": "*" + }, + { + "permission": "read", + "action": "allow", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "description": "Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), search code for keywords (eg. \"API endpoints\"), or answer questions about the codebase (eg. \"how do API endpoints work?\"). When calling this agent, specify the desired thoroughness level: \"quick\" for basic searches, \"medium\" for moderate exploration, or \"very thorough\" for comprehensive analysis across multiple locations and naming conventions.", + "prompt": "You are a file search specialist. You excel at thoroughly navigating and exploring codebases.\n\nYour strengths:\n- Rapidly finding files using glob patterns\n- Searching code and text with powerful regex patterns\n- Reading and analyzing file contents\n\nGuidelines:\n- Use Glob for broad file pattern matching\n- Use Grep for searching file contents with regex\n- Use Read when you know the specific file path you need to read\n- Use Bash for file operations like copying, moving, or listing directory contents\n- Adapt your search approach based on the thoroughness level specified by the caller\n- Return file paths as absolute paths in your final response\n- For clear communication, avoid using emojis\n- Do not create any files, or run bash commands that modify the user's system state in any way\n\nComplete the user's search request efficiently and report your findings clearly.\n", + "options": {}, + "mode": "subagent", + "native": true + }, + { + "name": "compaction", + "mode": "primary", + "native": true, + "hidden": true, + "prompt": "You are a helpful AI assistant tasked with summarizing conversations.\n\nWhen asked to summarize, provide a detailed but concise summary of the conversation. \nFocus on information that would be helpful for continuing the conversation, including:\n- What was done\n- What is currently being worked on\n- Which files are being modified\n- What needs to be done next\n- Key user requests, constraints, or preferences that should persist\n- Important technical decisions and why they were made\n\nYour summary should be comprehensive enough to provide context but concise enough to be quickly understood.\n", + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "*", + "action": "deny", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "options": {} + }, + { + "name": "title", + "mode": "primary", + "options": {}, + "native": true, + "hidden": true, + "temperature": 0.5, + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "*", + "action": "deny", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "prompt": "You are a title generator. You output ONLY a thread title. Nothing else.\n\n\nGenerate a brief title that would help the user find this conversation later.\n\nFollow all rules in \nUse the so you know what a good title looks like.\nYour output must be:\n- A single line\n- ≤50 characters\n- No explanations\n\n\n\n- you MUST use the same language as the user message you are summarizing\n- Title must be grammatically correct and read naturally - no word salad\n- Never include tool names in the title (e.g. \"read tool\", \"bash tool\", \"edit tool\")\n- Focus on the main topic or question the user needs to retrieve\n- Vary your phrasing - avoid repetitive patterns like always starting with \"Analyzing\"\n- When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it\n- Keep exact: technical terms, numbers, filenames, HTTP codes\n- Remove: the, this, my, a, an\n- Never assume tech stack\n- Never use tools\n- NEVER respond to questions, just generate a title for the conversation\n- The title should NEVER include \"summarizing\" or \"generating\" when generating a title\n- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT\n- Always output something meaningful, even if the input is minimal.\n- If the user message is short or conversational (e.g. \"hello\", \"lol\", \"what's up\", \"hey\"):\n → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)\n\n\n\n\"debug 500 errors in production\" → Debugging production 500 errors\n\"refactor user service\" → Refactoring user service\n\"why is app.js failing\" → app.js failure investigation\n\"implement rate limiting\" → Rate limiting implementation\n\"how do I connect postgres to my API\" → Postgres API connection\n\"best practices for React hooks\" → React hooks best practices\n\"@src/auth.ts can you add refresh token support\" → Auth refresh token support\n\"@utils/parser.ts this is broken\" → Parser bug fix\n\"look at @config.json\" → Config review\n\"@App.tsx add dark mode toggle\" → Dark mode toggle in App\n\n" + }, + { + "name": "summary", + "mode": "primary", + "options": {}, + "native": true, + "hidden": true, + "permission": [ + { + "permission": "*", + "action": "allow", + "pattern": "*" + }, + { + "permission": "doom_loop", + "action": "ask", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "*", + "action": "ask" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + }, + { + "permission": "question", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_enter", + "action": "deny", + "pattern": "*" + }, + { + "permission": "plan_exit", + "action": "deny", + "pattern": "*" + }, + { + "permission": "read", + "pattern": "*", + "action": "allow" + }, + { + "permission": "read", + "pattern": "*.env", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.*", + "action": "ask" + }, + { + "permission": "read", + "pattern": "*.env.example", + "action": "allow" + }, + { + "permission": "*", + "action": "deny", + "pattern": "*" + }, + { + "permission": "external_directory", + "pattern": "/home/nathan/.local/share/opencode/tool-output/*", + "action": "allow" + } + ], + "prompt": "Summarize what was done in this conversation. Write like a pull request description.\n\nRules:\n- 2-3 sentences max\n- Describe the changes made, not the process\n- Do not mention running tests, builds, or other validation steps\n- Do not explain what the user asked for\n- Write in first person (I added..., I fixed...)\n- Never ask questions or add new questions\n- If the conversation ends with an unanswered question to the user, preserve that exact question\n- If the conversation ends with an imperative statement or request to the user (e.g. \"Now please run the command and paste the console output\"), always include that exact request in the summary\n" + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/metadata-config.json b/research/opencode-compat/snapshots/native/metadata-config.json new file mode 100644 index 0000000..2170fdb --- /dev/null +++ b/research/opencode-compat/snapshots/native/metadata-config.json @@ -0,0 +1,102 @@ +{ + "agent": {}, + "mode": {}, + "plugin": [], + "command": {}, + "username": "nathan", + "keybinds": { + "leader": "ctrl+x", + "app_exit": "ctrl+c,ctrl+d,q", + "editor_open": "e", + "theme_list": "t", + "sidebar_toggle": "b", + "scrollbar_toggle": "none", + "username_toggle": "none", + "status_view": "s", + "session_export": "x", + "session_new": "n", + "session_list": "l", + "session_timeline": "g", + "session_fork": "none", + "session_rename": "ctrl+r", + "session_delete": "ctrl+d", + "stash_delete": "ctrl+d", + "model_provider_list": "ctrl+a", + "model_favorite_toggle": "ctrl+f", + "session_share": "none", + "session_unshare": "none", + "session_interrupt": "escape", + "session_compact": "c", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", + "messages_half_page_up": "ctrl+alt+u", + "messages_half_page_down": "ctrl+alt+d", + "messages_first": "ctrl+g,home", + "messages_last": "ctrl+alt+g,end", + "messages_next": "none", + "messages_previous": "none", + "messages_last_user": "none", + "messages_copy": "y", + "messages_undo": "u", + "messages_redo": "r", + "messages_toggle_conceal": "h", + "tool_details": "none", + "model_list": "m", + "model_cycle_recent": "f2", + "model_cycle_recent_reverse": "shift+f2", + "model_cycle_favorite": "none", + "model_cycle_favorite_reverse": "none", + "command_list": "ctrl+p", + "agent_list": "a", + "agent_cycle": "tab", + "agent_cycle_reverse": "shift+tab", + "variant_cycle": "ctrl+t", + "input_clear": "ctrl+c", + "input_paste": "ctrl+v", + "input_submit": "return", + "input_newline": "shift+return,ctrl+return,alt+return,ctrl+j", + "input_move_left": "left,ctrl+b", + "input_move_right": "right,ctrl+f", + "input_move_up": "up", + "input_move_down": "down", + "input_select_left": "shift+left", + "input_select_right": "shift+right", + "input_select_up": "shift+up", + "input_select_down": "shift+down", + "input_line_home": "ctrl+a", + "input_line_end": "ctrl+e", + "input_select_line_home": "ctrl+shift+a", + "input_select_line_end": "ctrl+shift+e", + "input_visual_line_home": "alt+a", + "input_visual_line_end": "alt+e", + "input_select_visual_line_home": "alt+shift+a", + "input_select_visual_line_end": "alt+shift+e", + "input_buffer_home": "home", + "input_buffer_end": "end", + "input_select_buffer_home": "shift+home", + "input_select_buffer_end": "shift+end", + "input_delete_line": "ctrl+shift+d", + "input_delete_to_line_end": "ctrl+k", + "input_delete_to_line_start": "ctrl+u", + "input_backspace": "backspace,shift+backspace", + "input_delete": "ctrl+d,delete,shift+delete", + "input_undo": "ctrl+-,super+z", + "input_redo": "ctrl+.,super+shift+z", + "input_word_forward": "alt+f,alt+right,ctrl+right", + "input_word_backward": "alt+b,alt+left,ctrl+left", + "input_select_word_forward": "alt+shift+f,alt+shift+right", + "input_select_word_backward": "alt+shift+b,alt+shift+left", + "input_delete_word_forward": "alt+d,alt+delete,ctrl+delete", + "input_delete_word_backward": "ctrl+w,ctrl+backspace,alt+backspace", + "history_previous": "up", + "history_next": "down", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", + "terminal_suspend": "ctrl+z", + "terminal_title_toggle": "none", + "tips_toggle": "h" + } +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/metadata-providers.json b/research/opencode-compat/snapshots/native/metadata-providers.json new file mode 100644 index 0000000..69e05fc --- /dev/null +++ b/research/opencode-compat/snapshots/native/metadata-providers.json @@ -0,0 +1,3716 @@ +{ + "providers": [ + { + "id": "opencode", + "source": "api", + "name": "OpenCode Zen", + "env": [ + "OPENCODE_API_KEY" + ], + "options": {}, + "models": { + "glm-4.7": { + "id": "glm-4.7", + "providerID": "opencode", + "name": "GLM-4.7", + "family": "glm", + "api": { + "id": "glm-4.7", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.6, + "output": 2.2, + "cache": { + "read": 0.1, + "write": 0 + } + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "release_date": "2025-12-22", + "variants": {} + }, + "qwen3-coder": { + "id": "qwen3-coder", + "providerID": "opencode", + "name": "Qwen3 Coder", + "family": "qwen", + "api": { + "id": "qwen3-coder", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.45, + "output": 1.8, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 65536 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-07-23", + "variants": {} + }, + "claude-opus-4-1": { + "id": "claude-opus-4-1", + "providerID": "opencode", + "name": "Claude Opus 4.1", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-1", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 1.5, + "write": 18.75 + } + }, + "limit": { + "context": 200000, + "output": 32000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-08-05", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 15999 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "kimi-k2": { + "id": "kimi-k2", + "providerID": "opencode", + "name": "Kimi K2", + "family": "kimi", + "api": { + "id": "kimi-k2", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.4, + "output": 2.5, + "cache": { + "read": 0.4, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-09-05", + "variants": {} + }, + "gpt-5.2-codex": { + "id": "gpt-5.2-codex", + "providerID": "opencode", + "name": "GPT-5.2 Codex", + "family": "gpt-codex", + "api": { + "id": "gpt-5.2-codex", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.75, + "output": 14, + "cache": { + "read": 0.175, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2026-01-14", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5.1-codex": { + "id": "gpt-5.1-codex", + "providerID": "opencode", + "name": "GPT-5.1 Codex", + "family": "gpt-codex", + "api": { + "id": "gpt-5.1-codex", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.07, + "output": 8.5, + "cache": { + "read": 0.107, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "claude-haiku-4-5": { + "id": "claude-haiku-4-5", + "providerID": "opencode", + "name": "Claude Haiku 4.5", + "family": "claude-haiku", + "api": { + "id": "claude-haiku-4-5", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1, + "output": 5, + "cache": { + "read": 0.1, + "write": 1.25 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-10-15", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-opus-4-6": { + "id": "claude-opus-4-6", + "providerID": "opencode", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-6", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 5, + "output": 25, + "cache": { + "read": 0.5, + "write": 6.25 + }, + "experimentalOver200K": { + "cache": { + "read": 1, + "write": 12.5 + }, + "input": 10, + "output": 37.5 + } + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2026-02-05", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "trinity-large-preview-free": { + "id": "trinity-large-preview-free", + "providerID": "opencode", + "name": "Trinity Large Preview", + "family": "trinity", + "api": { + "id": "trinity-large-preview-free", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 131072, + "output": 131072 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2026-01-28", + "variants": {} + }, + "claude-opus-4-5": { + "id": "claude-opus-4-5", + "providerID": "opencode", + "name": "Claude Opus 4.5", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-5", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 5, + "output": 25, + "cache": { + "read": 0.5, + "write": 6.25 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-24", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "kimi-k2.5": { + "id": "kimi-k2.5", + "providerID": "opencode", + "name": "Kimi K2.5", + "family": "kimi", + "api": { + "id": "kimi-k2.5", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.6, + "output": 3, + "cache": { + "read": 0.08, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": true, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "release_date": "2026-01-27", + "variants": {} + }, + "gemini-3-pro": { + "id": "gemini-3-pro", + "providerID": "opencode", + "name": "Gemini 3 Pro", + "family": "gemini-pro", + "api": { + "id": "gemini-3-pro", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/google" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 2, + "output": 12, + "cache": { + "read": 0.2, + "write": 0 + }, + "experimentalOver200K": { + "cache": { + "read": 0.4, + "write": 0 + }, + "input": 4, + "output": 18 + } + }, + "limit": { + "context": 1048576, + "output": 65536 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": true, + "image": true, + "video": true, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-18", + "variants": { + "low": { + "includeThoughts": true, + "thinkingLevel": "low" + }, + "high": { + "includeThoughts": true, + "thinkingLevel": "high" + } + } + }, + "claude-sonnet-4-5": { + "id": "claude-sonnet-4-5", + "providerID": "opencode", + "name": "Claude Sonnet 4.5", + "family": "claude-sonnet", + "api": { + "id": "claude-sonnet-4-5", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0.3, + "write": 3.75 + }, + "experimentalOver200K": { + "cache": { + "read": 0.6, + "write": 7.5 + }, + "input": 6, + "output": 22.5 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": true + }, + "release_date": "2025-09-29", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "gpt-5.1-codex-mini": { + "id": "gpt-5.1-codex-mini", + "providerID": "opencode", + "name": "GPT-5.1 Codex Mini", + "family": "gpt-codex", + "api": { + "id": "gpt-5.1-codex-mini", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.25, + "output": 2, + "cache": { + "read": 0.025, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "kimi-k2-thinking": { + "id": "kimi-k2-thinking", + "providerID": "opencode", + "name": "Kimi K2 Thinking", + "family": "kimi-thinking", + "api": { + "id": "kimi-k2-thinking", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.4, + "output": 2.5, + "cache": { + "read": 0.4, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "release_date": "2025-09-05", + "variants": {} + }, + "gpt-5.1": { + "id": "gpt-5.1", + "providerID": "opencode", + "name": "GPT-5.1", + "family": "gpt", + "api": { + "id": "gpt-5.1", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.07, + "output": 8.5, + "cache": { + "read": 0.107, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5-nano": { + "id": "gpt-5-nano", + "providerID": "opencode", + "name": "GPT-5 Nano", + "family": "gpt-nano", + "api": { + "id": "gpt-5-nano", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-08-07", + "variants": { + "minimal": { + "reasoningEffort": "minimal", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5-codex": { + "id": "gpt-5-codex", + "providerID": "opencode", + "name": "GPT-5 Codex", + "family": "gpt-codex", + "api": { + "id": "gpt-5-codex", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.07, + "output": 8.5, + "cache": { + "read": 0.107, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-09-15", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "big-pickle": { + "id": "big-pickle", + "providerID": "opencode", + "name": "Big Pickle", + "family": "big-pickle", + "api": { + "id": "big-pickle", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 128000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-10-17", + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + } + }, + "claude-3-5-haiku": { + "id": "claude-3-5-haiku", + "providerID": "opencode", + "name": "Claude Haiku 3.5", + "family": "claude-haiku", + "api": { + "id": "claude-3-5-haiku", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.8, + "output": 4, + "cache": { + "read": 0.08, + "write": 1 + } + }, + "limit": { + "context": 200000, + "output": 8192 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-10-22", + "variants": {} + }, + "glm-4.6": { + "id": "glm-4.6", + "providerID": "opencode", + "name": "GLM-4.6", + "family": "glm", + "api": { + "id": "glm-4.6", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.6, + "output": 2.2, + "cache": { + "read": 0.1, + "write": 0 + } + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-09-30", + "variants": {} + }, + "glm-4.7-free": { + "id": "glm-4.7-free", + "providerID": "opencode", + "name": "GLM-4.7 Free", + "family": "glm-free", + "api": { + "id": "glm-4.7-free", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "release_date": "2025-12-22", + "variants": {} + }, + "gemini-3-flash": { + "id": "gemini-3-flash", + "providerID": "opencode", + "name": "Gemini 3 Flash", + "family": "gemini-flash", + "api": { + "id": "gemini-3-flash", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/google" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.5, + "output": 3, + "cache": { + "read": 0.05, + "write": 0 + } + }, + "limit": { + "context": 1048576, + "output": 65536 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": true, + "image": true, + "video": true, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-12-17", + "variants": { + "low": { + "includeThoughts": true, + "thinkingLevel": "low" + }, + "high": { + "includeThoughts": true, + "thinkingLevel": "high" + } + } + }, + "gpt-5.1-codex-max": { + "id": "gpt-5.1-codex-max", + "providerID": "opencode", + "name": "GPT-5.1 Codex Max", + "family": "gpt-codex", + "api": { + "id": "gpt-5.1-codex-max", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.25, + "output": 10, + "cache": { + "read": 0.125, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "minimax-m2.1-free": { + "id": "minimax-m2.1-free", + "providerID": "opencode", + "name": "MiniMax M2.1 Free", + "family": "minimax-free", + "api": { + "id": "minimax-m2.1-free", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-12-23", + "variants": {} + }, + "kimi-k2.5-free": { + "id": "kimi-k2.5-free", + "providerID": "opencode", + "name": "Kimi K2.5 Free", + "family": "kimi-free", + "api": { + "id": "kimi-k2.5-free", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 262144 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": true, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "release_date": "2026-01-27", + "variants": {} + }, + "claude-sonnet-4": { + "id": "claude-sonnet-4", + "providerID": "opencode", + "name": "Claude Sonnet 4", + "family": "claude-sonnet", + "api": { + "id": "claude-sonnet-4", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0.3, + "write": 3.75 + }, + "experimentalOver200K": { + "cache": { + "read": 0.6, + "write": 7.5 + }, + "input": 6, + "output": 22.5 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-05-22", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "gpt-5": { + "id": "gpt-5", + "providerID": "opencode", + "name": "GPT-5", + "family": "gpt", + "api": { + "id": "gpt-5", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.07, + "output": 8.5, + "cache": { + "read": 0.107, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-08-07", + "variants": { + "minimal": { + "reasoningEffort": "minimal", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "minimax-m2.1": { + "id": "minimax-m2.1", + "providerID": "opencode", + "name": "MiniMax M2.1", + "family": "minimax", + "api": { + "id": "minimax-m2.1", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai-compatible" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.3, + "output": 1.2, + "cache": { + "read": 0.1, + "write": 0 + } + }, + "limit": { + "context": 204800, + "output": 131072 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "release_date": "2025-12-23", + "variants": {} + }, + "gpt-5.2": { + "id": "gpt-5.2", + "providerID": "opencode", + "name": "GPT-5.2", + "family": "gpt", + "api": { + "id": "gpt-5.2", + "url": "https://opencode.ai/zen/v1", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 1.75, + "output": 14, + "cache": { + "read": 0.175, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-12-11", + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + } + }, + "key": "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" + }, + { + "id": "cerebras", + "source": "api", + "name": "Cerebras", + "env": [ + "CEREBRAS_API_KEY" + ], + "options": { + "headers": { + "X-Cerebras-3rd-Party-Integration": "opencode" + } + }, + "models": { + "zai-glm-4.7": { + "id": "zai-glm-4.7", + "providerID": "cerebras", + "name": "Z.AI GLM-4.7", + "api": { + "id": "zai-glm-4.7", + "npm": "@ai-sdk/cerebras" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 131072, + "output": 40000 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2026-01-10", + "variants": {} + }, + "qwen-3-235b-a22b-instruct-2507": { + "id": "qwen-3-235b-a22b-instruct-2507", + "providerID": "cerebras", + "name": "Qwen 3 235B Instruct", + "family": "qwen", + "api": { + "id": "qwen-3-235b-a22b-instruct-2507", + "npm": "@ai-sdk/cerebras" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.6, + "output": 1.2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 131000, + "output": 32000 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-07-22", + "variants": {} + }, + "gpt-oss-120b": { + "id": "gpt-oss-120b", + "providerID": "cerebras", + "name": "GPT OSS 120B", + "family": "gpt-oss", + "api": { + "id": "gpt-oss-120b", + "npm": "@ai-sdk/cerebras" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0.25, + "output": 0.69, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 131072, + "output": 32768 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-08-05", + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + } + } + }, + "key": "csk-me9n8whxtf2eekkfxe36569ppxpmk3ewh54d9t29cch5wnyd" + }, + { + "id": "openai", + "source": "custom", + "name": "OpenAI", + "env": [ + "OPENAI_API_KEY" + ], + "options": { + "apiKey": "opencode-oauth-dummy-key" + }, + "models": { + "gpt-5.2-codex": { + "id": "gpt-5.2-codex", + "providerID": "openai", + "name": "GPT-5.2 Codex", + "family": "gpt-codex", + "api": { + "id": "gpt-5.2-codex", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-12-11", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5.1-codex": { + "id": "gpt-5.1-codex", + "providerID": "openai", + "name": "GPT-5.1 Codex", + "family": "gpt-codex", + "api": { + "id": "gpt-5.1-codex", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5.1-codex-mini": { + "id": "gpt-5.1-codex-mini", + "providerID": "openai", + "name": "GPT-5.1 Codex mini", + "family": "gpt-codex", + "api": { + "id": "gpt-5.1-codex-mini", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5.1-codex-max": { + "id": "gpt-5.1-codex-max", + "providerID": "openai", + "name": "GPT-5.1 Codex Max", + "family": "gpt-codex", + "api": { + "id": "gpt-5.1-codex-max", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-13", + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + }, + "gpt-5.2": { + "id": "gpt-5.2", + "providerID": "openai", + "name": "GPT-5.2", + "family": "gpt", + "api": { + "id": "gpt-5.2", + "npm": "@ai-sdk/openai" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "input": 272000, + "output": 128000 + }, + "capabilities": { + "temperature": false, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": false + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-12-11", + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "auto", + "include": [ + "reasoning.encrypted_content" + ] + } + } + } + } + }, + { + "id": "anthropic", + "source": "custom", + "name": "Anthropic", + "env": [ + "ANTHROPIC_API_KEY" + ], + "options": { + "apiKey": "", + "headers": { + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + } + }, + "models": { + "claude-opus-4-0": { + "id": "claude-opus-4-0", + "providerID": "anthropic", + "name": "Claude Opus 4 (latest)", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-0", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 32000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-05-22", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 15999 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-5-sonnet-20241022": { + "id": "claude-3-5-sonnet-20241022", + "providerID": "anthropic", + "name": "Claude Sonnet 3.5 v2", + "family": "claude-sonnet", + "api": { + "id": "claude-3-5-sonnet-20241022", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 8192 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-10-22", + "variants": {} + }, + "claude-opus-4-1": { + "id": "claude-opus-4-1", + "providerID": "anthropic", + "name": "Claude Opus 4.1 (latest)", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-1", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 32000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-08-05", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 15999 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-haiku-4-5": { + "id": "claude-haiku-4-5", + "providerID": "anthropic", + "name": "Claude Haiku 4.5 (latest)", + "family": "claude-haiku", + "api": { + "id": "claude-haiku-4-5", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-10-15", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-5-sonnet-20240620": { + "id": "claude-3-5-sonnet-20240620", + "providerID": "anthropic", + "name": "Claude Sonnet 3.5", + "family": "claude-sonnet", + "api": { + "id": "claude-3-5-sonnet-20240620", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 8192 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-06-20", + "variants": {} + }, + "claude-opus-4-6": { + "id": "claude-opus-4-6", + "providerID": "anthropic", + "name": "Claude Opus 4.6", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-6", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 128000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2026-02-05", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-5-haiku-latest": { + "id": "claude-3-5-haiku-latest", + "providerID": "anthropic", + "name": "Claude Haiku 3.5 (latest)", + "family": "claude-haiku", + "api": { + "id": "claude-3-5-haiku-latest", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 8192 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-10-22", + "variants": {} + }, + "claude-opus-4-5": { + "id": "claude-opus-4-5", + "providerID": "anthropic", + "name": "Claude Opus 4.5 (latest)", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-5", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-24", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-opus-20240229": { + "id": "claude-3-opus-20240229", + "providerID": "anthropic", + "name": "Claude Opus 3", + "family": "claude-opus", + "api": { + "id": "claude-3-opus-20240229", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 4096 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-02-29", + "variants": {} + }, + "claude-opus-4-5-20251101": { + "id": "claude-opus-4-5-20251101", + "providerID": "anthropic", + "name": "Claude Opus 4.5", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-5-20251101", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-11-01", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-sonnet-4-5": { + "id": "claude-sonnet-4-5", + "providerID": "anthropic", + "name": "Claude Sonnet 4.5 (latest)", + "family": "claude-sonnet", + "api": { + "id": "claude-sonnet-4-5", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-09-29", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-sonnet-4-5-20250929": { + "id": "claude-sonnet-4-5-20250929", + "providerID": "anthropic", + "name": "Claude Sonnet 4.5", + "family": "claude-sonnet", + "api": { + "id": "claude-sonnet-4-5-20250929", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-09-29", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-sonnet-4-20250514": { + "id": "claude-sonnet-4-20250514", + "providerID": "anthropic", + "name": "Claude Sonnet 4", + "family": "claude-sonnet", + "api": { + "id": "claude-sonnet-4-20250514", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-05-22", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-opus-4-20250514": { + "id": "claude-opus-4-20250514", + "providerID": "anthropic", + "name": "Claude Opus 4", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-20250514", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 32000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-05-22", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 15999 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-5-haiku-20241022": { + "id": "claude-3-5-haiku-20241022", + "providerID": "anthropic", + "name": "Claude Haiku 3.5", + "family": "claude-haiku", + "api": { + "id": "claude-3-5-haiku-20241022", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 8192 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-10-22", + "variants": {} + }, + "claude-3-haiku-20240307": { + "id": "claude-3-haiku-20240307", + "providerID": "anthropic", + "name": "Claude Haiku 3", + "family": "claude-haiku", + "api": { + "id": "claude-3-haiku-20240307", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 4096 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-03-13", + "variants": {} + }, + "claude-3-7-sonnet-20250219": { + "id": "claude-3-7-sonnet-20250219", + "providerID": "anthropic", + "name": "Claude Sonnet 3.7", + "family": "claude-sonnet", + "api": { + "id": "claude-3-7-sonnet-20250219", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-02-19", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-7-sonnet-latest": { + "id": "claude-3-7-sonnet-latest", + "providerID": "anthropic", + "name": "Claude Sonnet 3.7 (latest)", + "family": "claude-sonnet", + "api": { + "id": "claude-3-7-sonnet-latest", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-02-19", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-sonnet-4-0": { + "id": "claude-sonnet-4-0", + "providerID": "anthropic", + "name": "Claude Sonnet 4 (latest)", + "family": "claude-sonnet", + "api": { + "id": "claude-sonnet-4-0", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-05-22", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-opus-4-1-20250805": { + "id": "claude-opus-4-1-20250805", + "providerID": "anthropic", + "name": "Claude Opus 4.1", + "family": "claude-opus", + "api": { + "id": "claude-opus-4-1-20250805", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 32000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-08-05", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 15999 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + }, + "claude-3-sonnet-20240229": { + "id": "claude-3-sonnet-20240229", + "providerID": "anthropic", + "name": "Claude Sonnet 3", + "family": "claude-sonnet", + "api": { + "id": "claude-3-sonnet-20240229", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 4096 + }, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2024-03-04", + "variants": {} + }, + "claude-haiku-4-5-20251001": { + "id": "claude-haiku-4-5-20251001", + "providerID": "anthropic", + "name": "Claude Haiku 4.5", + "family": "claude-haiku", + "api": { + "id": "claude-haiku-4-5-20251001", + "npm": "@ai-sdk/anthropic" + }, + "status": "active", + "headers": {}, + "options": {}, + "cost": { + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 64000 + }, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "audio": false, + "image": true, + "video": false, + "pdf": true + }, + "output": { + "text": true, + "audio": false, + "image": false, + "video": false, + "pdf": false + }, + "interleaved": false + }, + "release_date": "2025-10-15", + "variants": { + "high": { + "thinking": { + "type": "enabled", + "budgetTokens": 16000 + } + }, + "max": { + "thinking": { + "type": "enabled", + "budgetTokens": 31999 + } + } + } + } + } + } + ], + "default": { + "opencode": "gemini-3-pro", + "cerebras": "zai-glm-4.7", + "openai": "gpt-5.2-codex", + "anthropic": "claude-sonnet-4-5-20250929" + } +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/session-create.json b/research/opencode-compat/snapshots/native/session-create.json new file mode 100644 index 0000000..eb38538 --- /dev/null +++ b/research/opencode-compat/snapshots/native/session-create.json @@ -0,0 +1,12 @@ +{ + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "New session - 2026-02-06T06:56:52.806Z", + "time": { + "created": 1770361012806, + "updated": 1770361012806 + } +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/session-details.json b/research/opencode-compat/snapshots/native/session-details.json new file mode 100644 index 0000000..f3e4a40 --- /dev/null +++ b/research/opencode-compat/snapshots/native/session-details.json @@ -0,0 +1,17 @@ +{ + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "Hello from OpenCode", + "time": { + "created": 1770361012806, + "updated": 1770361017462 + }, + "summary": { + "additions": 0, + "deletions": 0, + "files": 0 + } +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/session-events.json b/research/opencode-compat/snapshots/native/session-events.json new file mode 100644 index 0000000..a6630cc --- /dev/null +++ b/research/opencode-compat/snapshots/native/session-events.json @@ -0,0 +1,156 @@ +[ + { + "type": "session.created", + "properties": { + "info": { + "id": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "slug": "witty-eagle", + "version": "1.1.49", + "projectID": "c4153c5335dc81f0e622888f1f387c4b84dc54d5", + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "title": "New session - 2026-02-06T06:56:52.806Z", + "time": { + "created": 1770361012806, + "updated": 1770361012806 + } + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "idle" + } + } + }, + { + "type": "session.idle", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms" + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "busy" + } + } + }, + { + "type": "session.status", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "status": { + "type": "idle" + } + } + }, + { + "type": "session.idle", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms" + } + }, + { + "type": "session.diff", + "properties": { + "sessionID": "ses_3ce42bdb9ffeEIUUu08AuKTJms", + "diff": [] + } + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/native/session-status.json b/research/opencode-compat/snapshots/native/session-status.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/research/opencode-compat/snapshots/native/session-status.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/all-events.json b/research/opencode-compat/snapshots/sandbox-agent/all-events.json new file mode 100644 index 0000000..1ff6540 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/all-events.json @@ -0,0 +1,682 @@ +[ + { + "properties": {}, + "type": "server.connected" + }, + { + "properties": { + "branch": "main", + "name": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "type": "worktree.ready" + }, + { + "properties": {}, + "type": "server.heartbeat" + }, + { + "properties": { + "info": { + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "id": "ses_1", + "projectID": "proj_1", + "slug": "session-ses_1", + "time": { + "created": 1770362164904, + "updated": 1770362164904 + }, + "title": "Session ses_1", + "version": "0" + } + }, + "type": "session.created" + }, + { + "properties": { + "sessionID": "ses_1", + "status": { + "type": "busy" + } + }, + "type": "session.status" + }, + { + "properties": { + "info": { + "agent": "build", + "id": "msg_1", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362164907, + "created": 1770362164907 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_1", + "part": { + "id": "part_1", + "messageID": "msg_1", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165109 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "delta": "echo Hello from sandbox-agent", + "messageID": "msg_1_assistant", + "part": { + "id": "msg_1_assistant_text", + "messageID": "msg_1_assistant", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165309 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165511 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "delta": "Hello from sandbox-agent", + "messageID": "msg_1_assistant_00000000000000000005", + "part": { + "id": "msg_1_assistant_00000000000000000005_text", + "messageID": "msg_1_assistant_00000000000000000005", + "sessionID": "ses_1", + "text": "Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362165511, + "created": 1770362165511 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_1_assistant_00000000000000000005", + "part": { + "id": "msg_1_assistant_00000000000000000005_text", + "messageID": "msg_1_assistant_00000000000000000005", + "sessionID": "ses_1", + "text": "Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1", + "status": { + "type": "idle" + } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1" + }, + "type": "session.idle" + }, + { + "properties": { + "sessionID": "ses_1", + "status": { + "type": "busy" + } + }, + "type": "session.status" + }, + { + "properties": { + "info": { + "agent": "build", + "id": "msg_2", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362166412, + "created": 1770362166412 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2", + "part": { + "id": "part_2", + "messageID": "msg_2", + "sessionID": "ses_1", + "text": "tool", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362166614 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "delta": "tool", + "messageID": "msg_2_assistant", + "part": { + "id": "msg_2_assistant_text", + "messageID": "msg_2_assistant", + "sessionID": "ses_1", + "text": "tool", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362166815 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "raw": "{\"query\":\"example\"}", + "status": "pending" + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362167016, + "created": 1770362167016 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "status": "running", + "time": { + "start": 1770362167016 + } + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362167218 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "status": "running", + "time": { + "start": 1770362167218 + } + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362167418, + "created": 1770362167418 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "filename": "mock_2/readme.md", + "id": "part_4", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/plain", + "sessionID": "ses_1", + "type": "file", + "url": "file://mock_2/readme.md" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "filename": "mock_2/output.txt", + "id": "part_5", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/plain", + "sessionID": "ses_1", + "source": { + "path": "mock_2/output.txt", + "text": { + "end": 13, + "start": 0, + "value": "+mock output\n" + }, + "type": "file" + }, + "type": "file", + "url": "file://mock_2/output.txt" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "file": "mock_2/output.txt" + }, + "type": "file.edited" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "filename": "mock_2/patch.txt", + "id": "part_6", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/x-diff", + "sessionID": "ses_1", + "source": { + "path": "mock_2/patch.txt", + "text": { + "end": 26, + "start": 0, + "value": "@@ -1,1 +1,1 @@\n-old\n+new\n" + }, + "type": "file" + }, + "type": "file", + "url": "file://mock_2/patch.txt" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "file": "mock_2/patch.txt" + }, + "type": "file.edited" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "metadata": {}, + "output": "mock search results", + "status": "error", + "time": { + "end": 1770362167418, + "start": 1770362167418 + } + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/messages-after-1.json b/research/opencode-compat/snapshots/sandbox-agent/messages-after-1.json new file mode 100644 index 0000000..432dfe8 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/messages-after-1.json @@ -0,0 +1,106 @@ +[ + { + "info": { + "agent": "build", + "id": "msg_1", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362164907, + "created": 1770362164907 + } + }, + "parts": [ + { + "id": "part_1", + "messageID": "msg_1", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165109 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "parts": [ + { + "id": "msg_1_assistant_text", + "messageID": "msg_1_assistant", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362165511, + "created": 1770362165511 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "parts": [ + { + "id": "msg_1_assistant_00000000000000000005_text", + "messageID": "msg_1_assistant_00000000000000000005", + "sessionID": "ses_1", + "text": "Hello from sandbox-agent", + "type": "text" + } + ] + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/messages-after-2.json b/research/opencode-compat/snapshots/sandbox-agent/messages-after-2.json new file mode 100644 index 0000000..778c702 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/messages-after-2.json @@ -0,0 +1,269 @@ +[ + { + "info": { + "agent": "build", + "id": "msg_1", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362164907, + "created": 1770362164907 + } + }, + "parts": [ + { + "id": "part_1", + "messageID": "msg_1", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165109 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "parts": [ + { + "id": "msg_1_assistant_text", + "messageID": "msg_1_assistant", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362165511, + "created": 1770362165511 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "parts": [ + { + "id": "msg_1_assistant_00000000000000000005_text", + "messageID": "msg_1_assistant_00000000000000000005", + "sessionID": "ses_1", + "text": "Hello from sandbox-agent", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "id": "msg_2", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362166412, + "created": 1770362166412 + } + }, + "parts": [ + { + "id": "part_2", + "messageID": "msg_2", + "sessionID": "ses_1", + "text": "tool", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362166614 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "parts": [ + { + "id": "msg_2_assistant_text", + "messageID": "msg_2_assistant", + "sessionID": "ses_1", + "text": "tool", + "type": "text" + } + ] + }, + { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362167418, + "created": 1770362167418 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "parts": [ + { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "metadata": {}, + "output": "mock search results", + "status": "error", + "time": { + "end": 1770362167418, + "start": 1770362167418 + } + }, + "tool": "mock.search", + "type": "tool" + }, + { + "filename": "mock_2/readme.md", + "id": "part_4", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/plain", + "sessionID": "ses_1", + "type": "file", + "url": "file://mock_2/readme.md" + }, + { + "filename": "mock_2/output.txt", + "id": "part_5", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/plain", + "sessionID": "ses_1", + "source": { + "path": "mock_2/output.txt", + "text": { + "end": 13, + "start": 0, + "value": "+mock output\n" + }, + "type": "file" + }, + "type": "file", + "url": "file://mock_2/output.txt" + }, + { + "filename": "mock_2/patch.txt", + "id": "part_6", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/x-diff", + "sessionID": "ses_1", + "source": { + "path": "mock_2/patch.txt", + "text": { + "end": 26, + "start": 0, + "value": "@@ -1,1 +1,1 @@\n-old\n+new\n" + }, + "type": "file" + }, + "type": "file", + "url": "file://mock_2/patch.txt" + } + ] + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/metadata-agent.json b/research/opencode-compat/snapshots/sandbox-agent/metadata-agent.json new file mode 100644 index 0000000..fc8b612 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/metadata-agent.json @@ -0,0 +1,11 @@ +[ + { + "description": "Sandbox Agent compatibility layer", + "hidden": false, + "mode": "all", + "name": "Sandbox Agent", + "native": false, + "options": {}, + "permission": [] + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/metadata-config.json b/research/opencode-compat/snapshots/sandbox-agent/metadata-config.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/metadata-config.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/session-create.json b/research/opencode-compat/snapshots/sandbox-agent/session-create.json new file mode 100644 index 0000000..36e0784 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/session-create.json @@ -0,0 +1,12 @@ +{ + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "id": "ses_1", + "projectID": "proj_1", + "slug": "session-ses_1", + "time": { + "created": 1770362164904, + "updated": 1770362164904 + }, + "title": "Session ses_1", + "version": "0" +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/session-details.json b/research/opencode-compat/snapshots/sandbox-agent/session-details.json new file mode 100644 index 0000000..36e0784 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/session-details.json @@ -0,0 +1,12 @@ +{ + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "id": "ses_1", + "projectID": "proj_1", + "slug": "session-ses_1", + "time": { + "created": 1770362164904, + "updated": 1770362164904 + }, + "title": "Session ses_1", + "version": "0" +} \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/session-events.json b/research/opencode-compat/snapshots/sandbox-agent/session-events.json new file mode 100644 index 0000000..66b86ab --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/session-events.json @@ -0,0 +1,655 @@ +[ + { + "properties": { + "info": { + "directory": "/home/nathan/sandbox-agent/research/opencode-compat", + "id": "ses_1", + "projectID": "proj_1", + "slug": "session-ses_1", + "time": { + "created": 1770362164904, + "updated": 1770362164904 + }, + "title": "Session ses_1", + "version": "0" + } + }, + "type": "session.created" + }, + { + "properties": { + "sessionID": "ses_1", + "status": { + "type": "busy" + } + }, + "type": "session.status" + }, + { + "properties": { + "info": { + "agent": "build", + "id": "msg_1", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362164907, + "created": 1770362164907 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_1", + "part": { + "id": "part_1", + "messageID": "msg_1", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165109 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "delta": "echo Hello from sandbox-agent", + "messageID": "msg_1_assistant", + "part": { + "id": "msg_1_assistant_text", + "messageID": "msg_1_assistant", + "sessionID": "ses_1", + "text": "echo Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165309 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362165511 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "delta": "Hello from sandbox-agent", + "messageID": "msg_1_assistant_00000000000000000005", + "part": { + "id": "msg_1_assistant_00000000000000000005_text", + "messageID": "msg_1_assistant_00000000000000000005", + "sessionID": "ses_1", + "text": "Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_1_assistant_00000000000000000005", + "mode": "default", + "modelID": "mock", + "parentID": "msg_1", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362165511, + "created": 1770362165511 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_1_assistant_00000000000000000005", + "part": { + "id": "msg_1_assistant_00000000000000000005_text", + "messageID": "msg_1_assistant_00000000000000000005", + "sessionID": "ses_1", + "text": "Hello from sandbox-agent", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "sessionID": "ses_1", + "status": { + "type": "idle" + } + }, + "type": "session.status" + }, + { + "properties": { + "sessionID": "ses_1" + }, + "type": "session.idle" + }, + { + "properties": { + "sessionID": "ses_1", + "status": { + "type": "busy" + } + }, + "type": "session.status" + }, + { + "properties": { + "info": { + "agent": "build", + "id": "msg_2", + "model": { + "modelID": "mock", + "providerID": "sandbox-agent" + }, + "role": "user", + "sessionID": "ses_1", + "time": { + "completed": 1770362166412, + "created": 1770362166412 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2", + "part": { + "id": "part_2", + "messageID": "msg_2", + "sessionID": "ses_1", + "text": "tool", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362166614 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "delta": "tool", + "messageID": "msg_2_assistant", + "part": { + "id": "msg_2_assistant_text", + "messageID": "msg_2_assistant", + "sessionID": "ses_1", + "text": "tool", + "type": "text" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362166815 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "raw": "{\"query\":\"example\"}", + "status": "pending" + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362167016, + "created": 1770362167016 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "status": "running", + "time": { + "start": 1770362167016 + } + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "created": 1770362167218 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "status": "running", + "time": { + "start": 1770362167218 + } + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "info": { + "agent": "build", + "cost": 0, + "finish": "stop", + "id": "msg_2_assistant_00000000000000000011", + "mode": "default", + "modelID": "mock", + "parentID": "msg_2", + "path": { + "cwd": "/home/nathan/sandbox-agent/research/opencode-compat", + "root": "/home/nathan/sandbox-agent/research/opencode-compat" + }, + "providerID": "sandbox-agent", + "role": "assistant", + "sessionID": "ses_1", + "time": { + "completed": 1770362167418, + "created": 1770362167418 + }, + "tokens": { + "cache": { + "read": 0, + "write": 0 + }, + "input": 0, + "output": 0, + "reasoning": 0 + } + }, + "sessionID": "ses_1" + }, + "type": "message.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "filename": "mock_2/readme.md", + "id": "part_4", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/plain", + "sessionID": "ses_1", + "type": "file", + "url": "file://mock_2/readme.md" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "filename": "mock_2/output.txt", + "id": "part_5", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/plain", + "sessionID": "ses_1", + "source": { + "path": "mock_2/output.txt", + "text": { + "end": 13, + "start": 0, + "value": "+mock output\n" + }, + "type": "file" + }, + "type": "file", + "url": "file://mock_2/output.txt" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "filename": "mock_2/patch.txt", + "id": "part_6", + "messageID": "msg_2_assistant_00000000000000000011", + "mime": "text/x-diff", + "sessionID": "ses_1", + "source": { + "path": "mock_2/patch.txt", + "text": { + "end": 26, + "start": 0, + "value": "@@ -1,1 +1,1 @@\n-old\n+new\n" + }, + "type": "file" + }, + "type": "file", + "url": "file://mock_2/patch.txt" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + }, + { + "properties": { + "messageID": "msg_2_assistant_00000000000000000011", + "part": { + "callID": "mock_2_call", + "id": "part_3", + "messageID": "msg_2_assistant_00000000000000000011", + "metadata": {}, + "sessionID": "ses_1", + "state": { + "input": { + "query": "example" + }, + "metadata": {}, + "output": "mock search results", + "status": "error", + "time": { + "end": 1770362167418, + "start": 1770362167418 + } + }, + "tool": "mock.search", + "type": "tool" + }, + "sessionID": "ses_1" + }, + "type": "message.part.updated" + } +] \ No newline at end of file diff --git a/research/opencode-compat/snapshots/sandbox-agent/session-status.json b/research/opencode-compat/snapshots/sandbox-agent/session-status.json new file mode 100644 index 0000000..8afc386 --- /dev/null +++ b/research/opencode-compat/snapshots/sandbox-agent/session-status.json @@ -0,0 +1,5 @@ +{ + "ses_1": { + "type": "idle" + } +} \ No newline at end of file diff --git a/research/opencode-tmux-test.md b/research/opencode-tmux-test.md index ff553c0..96cc3ab 100644 --- a/research/opencode-tmux-test.md +++ b/research/opencode-tmux-test.md @@ -9,7 +9,8 @@ This plan captures OpenCode TUI output and sends input via tmux so we can valida ## Environment - `SANDBOX_AGENT_LOG_DIR=/path` to set server log dir -- `SANDBOX_AGENT_LOG_STDOUT=1` to keep logs on stdout/stderr +- `SANDBOX_AGENT_LOG_TO_FILE=1` to redirect logs to files +- `SANDBOX_AGENT_LOG_STDOUT=1` to force logs on stdout/stderr - `SANDBOX_AGENT_LOG_HTTP=0` to disable request logs - `SANDBOX_AGENT_LOG_HTTP_HEADERS=1` to include request headers (Authorization redacted) - `RUST_LOG=...` for trace filtering @@ -42,7 +43,7 @@ This plan captures OpenCode TUI output and sends input via tmux so we can valida ```bash tmux capture-pane -pt opencode:0.0 -S -200 > /tmp/opencode-screen.txt ``` -6. Inspect server logs for requests: +6. Inspect server logs for requests (when log-to-file is enabled): ```bash tail -n 200 ~/.local/share/sandbox-agent/logs/log-$(date +%m-%d-%y) ``` diff --git a/scripts/release/promote-artifacts.ts b/scripts/release/promote-artifacts.ts index 9cfdff2..6d98692 100644 --- a/scripts/release/promote-artifacts.ts +++ b/scripts/release/promote-artifacts.ts @@ -35,6 +35,12 @@ export async function promoteArtifacts(opts: ReleaseOpts) { if (opts.latest) { await uploadInstallScripts(opts, "latest"); } + + // Upload gigacode install scripts + await uploadGigacodeInstallScripts(opts, opts.version); + if (opts.latest) { + await uploadGigacodeInstallScripts(opts, "latest"); + } } @@ -55,6 +61,23 @@ async function uploadInstallScripts(opts: ReleaseOpts, version: string) { } } +async function uploadGigacodeInstallScripts(opts: ReleaseOpts, version: string) { + const installScriptPaths = [ + path.resolve(opts.root, "scripts/release/static/gigacode-install.sh"), + path.resolve(opts.root, "scripts/release/static/gigacode-install.ps1"), + ]; + + for (const scriptPath of installScriptPaths) { + let scriptContent = await fs.readFile(scriptPath, "utf-8"); + scriptContent = scriptContent.replace(/__VERSION__/g, version); + + const uploadKey = `${PREFIX}/${version}/${scriptPath.split("/").pop() ?? ""}`; + + console.log(`Uploading gigacode install script: ${uploadKey}`); + await uploadContentToReleases(scriptContent, uploadKey); + } +} + async function copyPath(sourcePrefix: string, targetPrefix: string) { console.log(`Copying ${sourcePrefix} -> ${targetPrefix}`); await deleteReleasesPath(targetPrefix); diff --git a/scripts/release/sdk.ts b/scripts/release/sdk.ts index 78f3e6a..5248440 100644 --- a/scripts/release/sdk.ts +++ b/scripts/release/sdk.ts @@ -12,6 +12,7 @@ const CRATES = [ "universal-agent-schema", "agent-management", "sandbox-agent", + "gigacode", ] as const; // NPM CLI packages @@ -22,15 +23,69 @@ const CLI_PACKAGES = [ "@sandbox-agent/cli-win32-x64", "@sandbox-agent/cli-darwin-x64", "@sandbox-agent/cli-darwin-arm64", + "@sandbox-agent/gigacode", + "@sandbox-agent/gigacode-linux-x64", + "@sandbox-agent/gigacode-linux-arm64", + "@sandbox-agent/gigacode-win32-x64", + "@sandbox-agent/gigacode-darwin-x64", + "@sandbox-agent/gigacode-darwin-arm64", ] as const; // Mapping from npm package name to Rust target and binary extension -const CLI_PLATFORM_MAP: Record = { - "@sandbox-agent/cli-linux-x64": { target: "x86_64-unknown-linux-musl", binaryExt: "" }, - "@sandbox-agent/cli-linux-arm64": { target: "aarch64-unknown-linux-musl", binaryExt: "" }, - "@sandbox-agent/cli-win32-x64": { target: "x86_64-pc-windows-gnu", binaryExt: ".exe" }, - "@sandbox-agent/cli-darwin-x64": { target: "x86_64-apple-darwin", binaryExt: "" }, - "@sandbox-agent/cli-darwin-arm64": { target: "aarch64-apple-darwin", binaryExt: "" }, +const CLI_PLATFORM_MAP: Record< + string, + { target: string; binaryExt: string; binaryName: string } +> = { + "@sandbox-agent/cli-linux-x64": { + target: "x86_64-unknown-linux-musl", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-linux-arm64": { + target: "aarch64-unknown-linux-musl", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-win32-x64": { + target: "x86_64-pc-windows-gnu", + binaryExt: ".exe", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-darwin-x64": { + target: "x86_64-apple-darwin", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/cli-darwin-arm64": { + target: "aarch64-apple-darwin", + binaryExt: "", + binaryName: "sandbox-agent", + }, + "@sandbox-agent/gigacode-linux-x64": { + target: "x86_64-unknown-linux-musl", + binaryExt: "", + binaryName: "gigacode", + }, + "@sandbox-agent/gigacode-linux-arm64": { + target: "aarch64-unknown-linux-musl", + binaryExt: "", + binaryName: "gigacode", + }, + "@sandbox-agent/gigacode-win32-x64": { + target: "x86_64-pc-windows-gnu", + binaryExt: ".exe", + binaryName: "gigacode", + }, + "@sandbox-agent/gigacode-darwin-x64": { + target: "x86_64-apple-darwin", + binaryExt: "", + binaryName: "gigacode", + }, + "@sandbox-agent/gigacode-darwin-arm64": { + target: "aarch64-apple-darwin", + binaryExt: "", + binaryName: "gigacode", + }, }; async function npmVersionExists( @@ -92,7 +147,9 @@ export async function publishCrates(opts: ReleaseOpts) { console.log("==> Publishing crates to crates.io"); for (const crate of CRATES) { - const cratePath = join(opts.root, "server/packages", crate); + const cratePath = crate === "gigacode" + ? join(opts.root, "gigacode") + : join(opts.root, "server/packages", crate); // Read Cargo.toml to get the actual crate name const cargoTomlPath = join(cratePath, "Cargo.toml"); @@ -246,33 +303,41 @@ export async function publishNpmCli(opts: ReleaseOpts) { let packagePath: string; if (packageName === "@sandbox-agent/cli") { packagePath = join(opts.root, "sdks/cli"); - } else { + } else if (packageName === "@sandbox-agent/gigacode") { + packagePath = join(opts.root, "sdks/gigacode"); + } else if (packageName.startsWith("@sandbox-agent/cli-")) { // Platform-specific packages: @sandbox-agent/cli-linux-x64 -> sdks/cli/platforms/linux-x64 const platform = packageName.replace("@sandbox-agent/cli-", ""); packagePath = join(opts.root, "sdks/cli/platforms", platform); + } else if (packageName.startsWith("@sandbox-agent/gigacode-")) { + // Platform-specific packages: @sandbox-agent/gigacode-linux-x64 -> sdks/gigacode/platforms/linux-x64 + const platform = packageName.replace("@sandbox-agent/gigacode-", ""); + packagePath = join(opts.root, "sdks/gigacode/platforms", platform); + } else { + throw new Error(`Unknown CLI package: ${packageName}`); + } - // Download binary from R2 for platform-specific packages - const platformInfo = CLI_PLATFORM_MAP[packageName]; - if (platformInfo) { - const binDir = join(packagePath, "bin"); - const binaryName = `sandbox-agent${platformInfo.binaryExt}`; - const localBinaryPath = join(binDir, binaryName); - const remoteBinaryPath = `${PREFIX}/${sourceCommit}/binaries/sandbox-agent-${platformInfo.target}${platformInfo.binaryExt}`; + // Download binary from R2 for platform-specific packages + const platformInfo = CLI_PLATFORM_MAP[packageName]; + if (platformInfo) { + const binDir = join(packagePath, "bin"); + const binaryName = `${platformInfo.binaryName}${platformInfo.binaryExt}`; + const localBinaryPath = join(binDir, binaryName); + const remoteBinaryPath = `${PREFIX}/${sourceCommit}/binaries/${platformInfo.binaryName}-${platformInfo.target}${platformInfo.binaryExt}`; - console.log(`==> Downloading binary for ${packageName}`); - console.log(` From: ${remoteBinaryPath}`); - console.log(` To: ${localBinaryPath}`); + console.log(`==> Downloading binary for ${packageName}`); + console.log(` From: ${remoteBinaryPath}`); + console.log(` To: ${localBinaryPath}`); - // Create bin directory - await fs.mkdir(binDir, { recursive: true }); + // Create bin directory + await fs.mkdir(binDir, { recursive: true }); - // Download binary - await downloadFromReleases(remoteBinaryPath, localBinaryPath); + // Download binary + await downloadFromReleases(remoteBinaryPath, localBinaryPath); - // Make binary executable (not needed on Windows) - if (!platformInfo.binaryExt) { - await fs.chmod(localBinaryPath, 0o755); - } + // Make binary executable (not needed on Windows) + if (!platformInfo.binaryExt) { + await fs.chmod(localBinaryPath, 0o755); } } diff --git a/scripts/release/static/gigacode-install.ps1 b/scripts/release/static/gigacode-install.ps1 new file mode 100644 index 0000000..0b3e6c1 --- /dev/null +++ b/scripts/release/static/gigacode-install.ps1 @@ -0,0 +1,51 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = 'Stop' + +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +# Create bin directory for gigacode +$BinDir = $env:BIN_DIR +$GigacodeInstall = if ($BinDir) { + $BinDir +} else { + "${Home}\.gigacode\bin" +} + +if (!(Test-Path $GigacodeInstall)) { + New-Item $GigacodeInstall -ItemType Directory | Out-Null +} + +$GigacodeExe = "$GigacodeInstall\gigacode.exe" +$Version = '__VERSION__' +$FileName = 'gigacode-x86_64-pc-windows-gnu.exe' + +Write-Host +Write-Host "> Installing gigacode ${Version}" + +# Download binary +$DownloadUrl = "https://releases.rivet.dev/sandbox-agent/${Version}/binaries/${FileName}" +Write-Host +Write-Host "> Downloading ${DownloadUrl}" +Invoke-WebRequest $DownloadUrl -OutFile $GigacodeExe -UseBasicParsing + +# Install to PATH +Write-Host +Write-Host "> Installing gigacode" +$User = [System.EnvironmentVariableTarget]::User +$Path = [System.Environment]::GetEnvironmentVariable('Path', $User) +if (!(";${Path};".ToLower() -like "*;${GigacodeInstall};*".ToLower())) { + [System.Environment]::SetEnvironmentVariable('Path', "${Path};${GigacodeInstall}", $User) + $Env:Path += ";${GigacodeInstall}" + Write-Host "Please restart your PowerShell session or run the following command to refresh the environment variables:" + Write-Host "[System.Environment]::SetEnvironmentVariable('Path', '${Path};${GigacodeInstall}', [System.EnvironmentVariableTarget]::Process)" +} + +Write-Host +Write-Host "> Checking installation" +gigacode.exe --version + +Write-Host +Write-Host "gigacode was installed successfully to ${GigacodeExe}." +Write-Host "Run 'gigacode --help' to get started." +Write-Host diff --git a/scripts/release/static/gigacode-install.sh b/scripts/release/static/gigacode-install.sh new file mode 100644 index 0000000..4c4110c --- /dev/null +++ b/scripts/release/static/gigacode-install.sh @@ -0,0 +1,103 @@ +#!/bin/sh +# shellcheck enable=add-default-case +# shellcheck enable=avoid-nullary-conditions +# shellcheck enable=check-unassigned-uppercase +# shellcheck enable=deprecate-which +# shellcheck enable=quote-safe-variables +# shellcheck enable=require-variable-braces +set -eu + +rm -rf /tmp/gigacode_install +mkdir /tmp/gigacode_install +cd /tmp/gigacode_install + +GIGACODE_VERSION="${GIGACODE_VERSION:-__VERSION__}" +UNAME="$(uname -s)" +ARCH="$(uname -m)" + +# Find asset suffix +if [ "$(printf '%s' "$UNAME" | cut -c 1-6)" = "Darwin" ]; then + echo + echo "> Detected macOS" + + if [ "$ARCH" = "x86_64" ]; then + FILE_NAME="gigacode-x86_64-apple-darwin" + elif [ "$ARCH" = "arm64" ]; then + FILE_NAME="gigacode-aarch64-apple-darwin" + else + echo "Unknown arch $ARCH" 1>&2 + exit 1 + fi +elif [ "$(printf '%s' "$UNAME" | cut -c 1-5)" = "Linux" ]; then + echo + echo "> Detected Linux ($(getconf LONG_BIT) bit)" + + FILE_NAME="gigacode-x86_64-unknown-linux-musl" +else + echo "Unable to determine platform" 1>&2 + exit 1 +fi + +# Determine install location +set +u +if [ -z "$BIN_DIR" ]; then + BIN_DIR="/usr/local/bin" +fi +set -u +INSTALL_PATH="$BIN_DIR/gigacode" + +if [ ! -d "$BIN_DIR" ]; then + # Find the base parent directory. We're using mkdir -p, which recursively creates directories, so we can't rely on `dirname`. + CHECK_DIR="$BIN_DIR" + while [ ! -d "$CHECK_DIR" ] && [ "$CHECK_DIR" != "/" ]; do + CHECK_DIR=$(dirname "$CHECK_DIR") + done + + # Check if the directory is writable + if [ ! -w "$CHECK_DIR" ]; then + echo + echo "> Creating directory $BIN_DIR (requires sudo)" + sudo mkdir -p "$BIN_DIR" + else + echo + echo "> Creating directory $BIN_DIR" + mkdir -p "$BIN_DIR" + fi + +fi + +# Download binary +URL="https://releases.rivet.dev/sandbox-agent/${GIGACODE_VERSION}/binaries/${FILE_NAME}" +echo +echo "> Downloading $URL" +curl -fsSL "$URL" -o gigacode +chmod +x gigacode + +# Move binary +if [ ! -w "$BIN_DIR" ]; then + echo + echo "> Installing gigacode to $INSTALL_PATH (requires sudo)" + sudo mv ./gigacode "$INSTALL_PATH" +else + echo + echo "> Installing gigacode to $INSTALL_PATH" + mv ./gigacode "$INSTALL_PATH" +fi + +# Check if path may be incorrect +case ":$PATH:" in + *:$BIN_DIR:*) ;; + *) + echo "WARNING: $BIN_DIR is not in \$PATH" + echo "For instructions on how to add it to your PATH, visit:" + echo "https://opensource.com/article/17/6/set-path-linux" + ;; +esac + +echo +echo "> Checking installation" +"$BIN_DIR/gigacode" --version + +echo +echo "gigacode was installed successfully." +echo "Run 'gigacode --help' to get started." diff --git a/scripts/release/update_version.ts b/scripts/release/update_version.ts index e8c4afc..c7c1c60 100644 --- a/scripts/release/update_version.ts +++ b/scripts/release/update_version.ts @@ -32,11 +32,21 @@ export async function updateVersion(opts: ReleaseOpts) { find: /"version": ".*"/, replace: `"version": "${opts.version}"`, }, + { + path: "sdks/gigacode/package.json", + find: /"version": ".*"/, + replace: `"version": "${opts.version}"`, + }, { path: "sdks/cli/platforms/*/package.json", find: /"version": ".*"/, replace: `"version": "${opts.version}"`, }, + { + path: "sdks/gigacode/platforms/*/package.json", + find: /"version": ".*"/, + replace: `"version": "${opts.version}"`, + }, ]; // Update internal crate versions in workspace dependencies diff --git a/sdks/cli-shared/package.json b/sdks/cli-shared/package.json index 0c041d1..d342db9 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.6", + "version": "0.1.7", "description": "Shared helpers for sandbox-agent CLI and SDK", "license": "Apache-2.0", "repository": { diff --git a/sdks/cli-shared/src/index.ts b/sdks/cli-shared/src/index.ts index da2773f..c80125e 100644 --- a/sdks/cli-shared/src/index.ts +++ b/sdks/cli-shared/src/index.ts @@ -8,6 +8,7 @@ export type NonExecutableBinaryMessageOptions = { trustPackages: string; bunInstallBlocks: InstallCommandBlock[]; genericInstallCommands?: string[]; + binaryName?: string; }; export type FsSubset = { @@ -63,10 +64,16 @@ export function assertExecutable(binPath: string, fs: FsSubset): boolean { export function formatNonExecutableBinaryMessage( options: NonExecutableBinaryMessageOptions, ): string { - const { binPath, trustPackages, bunInstallBlocks, genericInstallCommands } = - options; + const { + binPath, + trustPackages, + bunInstallBlocks, + genericInstallCommands, + binaryName, + } = options; - const lines = [`sandbox-agent binary is not executable: ${binPath}`]; + const label = binaryName ?? "sandbox-agent"; + const lines = [`${label} binary is not executable: ${binPath}`]; if (isBunRuntime()) { lines.push( diff --git a/sdks/cli/package.json b/sdks/cli/package.json index fde9b8b..c617a76 100644 --- a/sdks/cli/package.json +++ b/sdks/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sandbox-agent/cli", - "version": "0.1.6", + "version": "0.1.7", "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 cfff424..9c07b51 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.6", + "version": "0.1.7", "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 8fa6330..dafe8e9 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.6", + "version": "0.1.7", "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 41db961..58cad6a 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.6", + "version": "0.1.7", "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 28e3b13..1f6c35b 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.6", + "version": "0.1.7", "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 e1f3001..726e4aa 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.6", + "version": "0.1.7", "description": "sandbox-agent CLI binary for Windows x64", "license": "Apache-2.0", "repository": { diff --git a/sdks/gigacode/bin/gigacode b/sdks/gigacode/bin/gigacode new file mode 100644 index 0000000..0fd87cc --- /dev/null +++ b/sdks/gigacode/bin/gigacode @@ -0,0 +1,66 @@ +#!/usr/bin/env node +const { execFileSync } = require("child_process"); +const { + assertExecutable, + formatNonExecutableBinaryMessage, +} = require("@sandbox-agent/cli-shared"); +const fs = require("fs"); +const path = require("path"); + +const TRUST_PACKAGES = + "@sandbox-agent/gigacode-linux-x64 @sandbox-agent/gigacode-linux-arm64 @sandbox-agent/gigacode-darwin-arm64 @sandbox-agent/gigacode-darwin-x64 @sandbox-agent/gigacode-win32-x64"; + +function formatHint(binPath) { + return formatNonExecutableBinaryMessage({ + binPath, + binaryName: "gigacode", + trustPackages: TRUST_PACKAGES, + bunInstallBlocks: [ + { + label: "Project install", + commands: [ + `bun pm trust ${TRUST_PACKAGES}`, + "bun add @sandbox-agent/gigacode", + ], + }, + { + label: "Global install", + commands: [ + `bun pm -g trust ${TRUST_PACKAGES}`, + "bun add -g @sandbox-agent/gigacode", + ], + }, + ], + }); +} + +const PLATFORMS = { + "darwin-arm64": "@sandbox-agent/gigacode-darwin-arm64", + "darwin-x64": "@sandbox-agent/gigacode-darwin-x64", + "linux-x64": "@sandbox-agent/gigacode-linux-x64", + "linux-arm64": "@sandbox-agent/gigacode-linux-arm64", + "win32-x64": "@sandbox-agent/gigacode-win32-x64", +}; + +const key = `${process.platform}-${process.arch}`; +const pkg = PLATFORMS[key]; +if (!pkg) { + console.error(`Unsupported platform: ${key}`); + process.exit(1); +} + +try { + const pkgPath = require.resolve(`${pkg}/package.json`); + const bin = process.platform === "win32" ? "gigacode.exe" : "gigacode"; + const binPath = path.join(path.dirname(pkgPath), "bin", bin); + + if (!assertExecutable(binPath, fs)) { + console.error(formatHint(binPath)); + process.exit(1); + } + + execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" }); +} catch (e) { + if (e.status !== undefined) process.exit(e.status); + throw e; +} diff --git a/sdks/gigacode/package.json b/sdks/gigacode/package.json new file mode 100644 index 0000000..9f4b0a7 --- /dev/null +++ b/sdks/gigacode/package.json @@ -0,0 +1,32 @@ +{ + "name": "@sandbox-agent/gigacode", + "version": "0.1.7", + "description": "Gigacode CLI (sandbox-agent with OpenCode attach by default)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "bin": { + "gigacode": "bin/gigacode" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@sandbox-agent/cli-shared": "workspace:*" + }, + "devDependencies": { + "vitest": "^3.0.0" + }, + "optionalDependencies": { + "@sandbox-agent/gigacode-linux-x64": "workspace:*", + "@sandbox-agent/gigacode-linux-arm64": "workspace:*", + "@sandbox-agent/gigacode-darwin-arm64": "workspace:*", + "@sandbox-agent/gigacode-darwin-x64": "workspace:*", + "@sandbox-agent/gigacode-win32-x64": "workspace:*" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/darwin-arm64/package.json b/sdks/gigacode/platforms/darwin-arm64/package.json new file mode 100644 index 0000000..49ec4e9 --- /dev/null +++ b/sdks/gigacode/platforms/darwin-arm64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox-agent/gigacode-darwin-arm64", + "version": "0.1.7", + "description": "gigacode CLI binary for macOS arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/darwin-x64/package.json b/sdks/gigacode/platforms/darwin-x64/package.json new file mode 100644 index 0000000..95104af --- /dev/null +++ b/sdks/gigacode/platforms/darwin-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox-agent/gigacode-darwin-x64", + "version": "0.1.7", + "description": "gigacode CLI binary for macOS x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/linux-arm64/package.json b/sdks/gigacode/platforms/linux-arm64/package.json new file mode 100644 index 0000000..29d9acb --- /dev/null +++ b/sdks/gigacode/platforms/linux-arm64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox-agent/gigacode-linux-arm64", + "version": "0.1.7", + "description": "gigacode CLI binary for Linux arm64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/linux-x64/package.json b/sdks/gigacode/platforms/linux-x64/package.json new file mode 100644 index 0000000..b3b3298 --- /dev/null +++ b/sdks/gigacode/platforms/linux-x64/package.json @@ -0,0 +1,22 @@ +{ + "name": "@sandbox-agent/gigacode-linux-x64", + "version": "0.1.7", + "description": "gigacode CLI binary for Linux x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "scripts": { + "postinstall": "chmod +x bin/gigacode || true" + }, + "files": [ + "bin" + ] +} diff --git a/sdks/gigacode/platforms/win32-x64/package.json b/sdks/gigacode/platforms/win32-x64/package.json new file mode 100644 index 0000000..cec1c0c --- /dev/null +++ b/sdks/gigacode/platforms/win32-x64/package.json @@ -0,0 +1,19 @@ +{ + "name": "@sandbox-agent/gigacode-win32-x64", + "version": "0.1.7", + "description": "gigacode CLI binary for Windows x64", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin" + ] +} diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json index 8b135bf..fe9845c 100644 --- a/sdks/typescript/package.json +++ b/sdks/typescript/package.json @@ -1,6 +1,6 @@ { "name": "sandbox-agent", - "version": "0.1.6", + "version": "0.1.7", "description": "Universal API for automatic coding agents in sandboxes. Supprots Claude Code, Codex, OpenCode, and Amp.", "license": "Apache-2.0", "repository": { diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 7f9ad95..f290406 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -2,6 +2,7 @@ import type { SandboxAgentSpawnHandle, SandboxAgentSpawnOptions } from "./spawn. import type { AgentInstallRequest, AgentListResponse, + AgentModelsResponse, AgentModesResponse, CreateSessionRequest, CreateSessionResponse, @@ -113,6 +114,10 @@ export class SandboxAgent { return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}/modes`); } + async getAgentModels(agent: string): Promise { + return this.requestJson("GET", `${API_PREFIX}/agents/${encodeURIComponent(agent)}/models`); + } + async createSession(sessionId: string, request: CreateSessionRequest): Promise { return this.requestJson("POST", `${API_PREFIX}/sessions/${encodeURIComponent(sessionId)}`, { body: request, diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 52816ad..1e3239e 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -11,6 +11,9 @@ export interface paths { "/v1/agents/{agent}/install": { post: operations["install_agent"]; }; + "/v1/agents/{agent}/models": { + get: operations["get_agent_models"]; + }; "/v1/agents/{agent}/modes": { get: operations["get_agent_modes"]; }; @@ -73,6 +76,7 @@ export interface components { textMessages: boolean; toolCalls: boolean; toolResults: boolean; + variants: boolean; }; AgentError: { agent?: string | null; @@ -100,6 +104,16 @@ export interface components { id: string; name: string; }; + AgentModelInfo: { + defaultVariant?: string | null; + id: string; + name?: string | null; + variants?: string[] | null; + }; + AgentModelsResponse: { + defaultModel?: string | null; + models: components["schemas"]["AgentModelInfo"][]; + }; AgentModesResponse: { modes: components["schemas"]["AgentModeInfo"][]; }; @@ -383,6 +397,26 @@ export interface operations { }; }; }; + get_agent_models: { + parameters: { + path: { + /** @description Agent id */ + agent: string; + }; + }; + responses: { + 200: { + content: { + "application/json": components["schemas"]["AgentModelsResponse"]; + }; + }; + 400: { + content: { + "application/json": components["schemas"]["ProblemDetails"]; + }; + }; + }; + }; get_agent_modes: { parameters: { path: { diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts index db8b4eb..1d5d349 100644 --- a/sdks/typescript/src/index.ts +++ b/sdks/typescript/src/index.ts @@ -10,6 +10,8 @@ export type { AgentInfo, AgentInstallRequest, AgentListResponse, + AgentModelInfo, + AgentModelsResponse, AgentModeInfo, AgentModesResponse, AgentUnparsedData, diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index e0c43df..350df6b 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -6,6 +6,8 @@ export type AgentCapabilities = S["AgentCapabilities"]; export type AgentInfo = S["AgentInfo"]; export type AgentInstallRequest = S["AgentInstallRequest"]; export type AgentListResponse = S["AgentListResponse"]; +export type AgentModelInfo = S["AgentModelInfo"]; +export type AgentModelsResponse = S["AgentModelsResponse"]; export type AgentModeInfo = S["AgentModeInfo"]; export type AgentModesResponse = S["AgentModesResponse"]; export type AgentUnparsedData = S["AgentUnparsedData"]; diff --git a/server/packages/sandbox-agent/Cargo.toml b/server/packages/sandbox-agent/Cargo.toml index 703c87c..850f4b6 100644 --- a/server/packages/sandbox-agent/Cargo.toml +++ b/server/packages/sandbox-agent/Cargo.toml @@ -42,7 +42,7 @@ tempfile = { workspace = true, optional = true } libc = "0.2" [target.'cfg(windows)'.dependencies] -windows = { version = "0.52", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] } +windows = { version = "0.52", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Threading"] } [dev-dependencies] http-body-util.workspace = true diff --git a/server/packages/sandbox-agent/build.rs b/server/packages/sandbox-agent/build.rs index 170c05a..515a20c 100644 --- a/server/packages/sandbox-agent/build.rs +++ b/server/packages/sandbox-agent/build.rs @@ -19,9 +19,22 @@ fn main() { println!("cargo:rerun-if-env-changed=SANDBOX_AGENT_VERSION"); println!("cargo:rerun-if-changed={}", dist_dir.display()); + // Rebuild when the git HEAD changes so BUILD_ID stays current. + let git_head = manifest_dir.join(".git/HEAD"); + if git_head.exists() { + println!("cargo:rerun-if-changed={}", git_head.display()); + } else { + // In a workspace the .git dir lives at the repo root. + let root_git_head = root_dir.join(".git/HEAD"); + if root_git_head.exists() { + println!("cargo:rerun-if-changed={}", root_git_head.display()); + } + } + // Generate version constant from environment variable or fallback to Cargo.toml version let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); generate_version(&out_dir); + generate_build_id(&out_dir); let skip = env::var("SANDBOX_AGENT_SKIP_INSPECTOR").is_ok(); let out_file = out_dir.join("inspector_assets.rs"); @@ -81,3 +94,33 @@ fn generate_version(out_dir: &Path) { fs::write(&out_file, contents).expect("write version.rs"); } + +fn generate_build_id(out_dir: &Path) { + use std::process::Command; + + let build_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}") + }); + + 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\ + pub const BUILD_ID: &str = \"{}\";\n", + build_id + ); + + fs::write(&out_file, contents).expect("write build_id.rs"); +} diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs new file mode 100644 index 0000000..a838a65 --- /dev/null +++ b/server/packages/sandbox-agent/src/cli.rs @@ -0,0 +1,1369 @@ +use std::collections::HashMap; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command as ProcessCommand, Stdio}; +use std::sync::Arc; +use std::time::Duration; + +use clap::{Args, Parser, Subcommand}; + +// Include the generated version constant +mod build_version { + include!(concat!(env!("OUT_DIR"), "/version.rs")); +} +use crate::router::{build_router_with_state, shutdown_servers}; +use crate::router::{ + AgentInstallRequest, AppState, AuthConfig, BrandingMode, CreateSessionRequest, MessageRequest, + PermissionReply, PermissionReplyRequest, QuestionReplyRequest, +}; +use crate::router::{ + AgentListResponse, AgentModelsResponse, AgentModesResponse, CreateSessionResponse, + EventsResponse, SessionListResponse, +}; +use crate::server_logs::ServerLogs; +use crate::telemetry; +use crate::ui; +use reqwest::blocking::Client as HttpClient; +use reqwest::Method; +use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; +use sandbox_agent_agent_management::credentials::{ + extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, + ProviderCredentials, +}; +use serde::Serialize; +use serde_json::{json, Value}; +use thiserror::Error; +use tower_http::cors::{Any, CorsLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + +const API_PREFIX: &str = "/v1"; +const DEFAULT_HOST: &str = "127.0.0.1"; +const DEFAULT_PORT: u16 = 2468; +const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +#[derive(Parser, Debug)] +#[command(name = "sandbox-agent", bin_name = "sandbox-agent")] +#[command(about = "https://sandboxagent.dev", version = build_version::VERSION)] +#[command(arg_required_else_help = true)] +pub struct SandboxAgentCli { + #[command(subcommand)] + command: Command, + + #[arg(long, short = 't', global = true)] + token: Option, + + #[arg(long, short = 'n', global = true)] + no_token: bool, +} + +#[derive(Parser, Debug)] +#[command(name = "gigacode", bin_name = "gigacode")] +#[command(about = "https://sandboxagent.dev", version = build_version::VERSION)] +pub struct GigacodeCli { + #[command(subcommand)] + pub command: Option, + + #[arg(long, short = 't', global = true)] + pub token: Option, + + #[arg(long, short = 'n', global = true)] + pub no_token: bool, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Run the sandbox agent HTTP server. + Server(ServerArgs), + /// Call the HTTP API without writing client code. + Api(ApiArgs), + /// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session. + Opencode(OpencodeArgs), + /// Manage the sandbox-agent background daemon. + Daemon(DaemonArgs), + /// Install or reinstall an agent without running the server. + InstallAgent(InstallAgentArgs), + /// Inspect locally discovered credentials. + Credentials(CredentialsArgs), +} + +#[derive(Args, Debug)] +pub struct ServerArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, + + #[arg(long = "cors-allow-origin", short = 'O')] + cors_allow_origin: Vec, + + #[arg(long = "cors-allow-method", short = 'M')] + cors_allow_method: Vec, + + #[arg(long = "cors-allow-header", short = 'A')] + cors_allow_header: Vec, + + #[arg(long = "cors-allow-credentials", short = 'C')] + cors_allow_credentials: bool, + + #[arg(long = "no-telemetry")] + no_telemetry: bool, +} + +#[derive(Args, Debug)] +pub struct ApiArgs { + #[command(subcommand)] + command: ApiCommand, +} + +#[derive(Args, Debug)] +pub struct OpencodeArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, + + #[arg(long)] + session_title: Option, + + #[arg(long)] + opencode_bin: Option, +} + +impl Default for OpencodeArgs { + fn default() -> Self { + Self { + host: DEFAULT_HOST.to_string(), + port: DEFAULT_PORT, + session_title: None, + opencode_bin: None, + } + } +} + +#[derive(Args, Debug)] +pub struct CredentialsArgs { + #[command(subcommand)] + command: CredentialsCommand, +} + +#[derive(Args, Debug)] +pub struct DaemonArgs { + #[command(subcommand)] + command: DaemonCommand, +} + +#[derive(Subcommand, Debug)] +pub enum DaemonCommand { + /// Start the daemon in the background. + Start(DaemonStartArgs), + /// Stop a running daemon. + Stop(DaemonStopArgs), + /// Show daemon status. + Status(DaemonStatusArgs), +} + +#[derive(Args, Debug)] +pub struct DaemonStartArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, +} + +#[derive(Args, Debug)] +pub struct DaemonStopArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, +} + +#[derive(Args, Debug)] +pub struct DaemonStatusArgs { + #[arg(long, short = 'H', default_value = DEFAULT_HOST)] + host: String, + + #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] + port: u16, +} + +#[derive(Subcommand, Debug)] +pub enum ApiCommand { + /// Manage installed agents and their modes. + Agents(AgentsArgs), + /// Create sessions and interact with session events. + Sessions(SessionsArgs), +} + +#[derive(Subcommand, Debug)] +pub enum CredentialsCommand { + /// Extract credentials using local discovery rules. + Extract(CredentialsExtractArgs), + /// Output credentials as environment variable assignments. + #[command(name = "extract-env")] + ExtractEnv(CredentialsExtractEnvArgs), +} + +#[derive(Args, Debug)] +pub struct AgentsArgs { + #[command(subcommand)] + command: AgentsCommand, +} + +#[derive(Args, Debug)] +pub struct SessionsArgs { + #[command(subcommand)] + command: SessionsCommand, +} + +#[derive(Subcommand, Debug)] +pub enum AgentsCommand { + /// List all agents and install status. + List(ClientArgs), + /// Install or reinstall an agent. + Install(ApiInstallAgentArgs), + /// Show available modes for an agent. + Modes(AgentModesArgs), + /// Show available models for an agent. + Models(AgentModelsArgs), +} + +#[derive(Subcommand, Debug)] +pub enum SessionsCommand { + /// List active sessions. + List(ClientArgs), + /// Create a new session for an agent. + Create(CreateSessionArgs), + #[command(name = "send-message")] + /// Send a message to an existing session. + SendMessage(SessionMessageArgs), + #[command(name = "send-message-stream")] + /// Send a message and stream the response for one turn. + SendMessageStream(SessionMessageStreamArgs), + #[command(name = "terminate")] + /// Terminate a session. + Terminate(SessionTerminateArgs), + #[command(name = "get-messages")] + /// Alias for events; returns session events. + GetMessages(SessionEventsArgs), + #[command(name = "events")] + /// Fetch session events with offset/limit. + Events(SessionEventsArgs), + #[command(name = "events-sse")] + /// Stream session events over SSE. + EventsSse(SessionEventsSseArgs), + #[command(name = "reply-question")] + /// Reply to a question event. + ReplyQuestion(QuestionReplyArgs), + #[command(name = "reject-question")] + /// Reject a question event. + RejectQuestion(QuestionRejectArgs), + #[command(name = "reply-permission")] + /// Reply to a permission request. + ReplyPermission(PermissionReplyArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct ClientArgs { + #[arg(long, short = 'e')] + endpoint: Option, +} + +#[derive(Args, Debug)] +pub struct ApiInstallAgentArgs { + agent: String, + #[arg(long, short = 'r')] + reinstall: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct InstallAgentArgs { + agent: String, + #[arg(long, short = 'r')] + reinstall: bool, +} + +#[derive(Args, Debug)] +pub struct AgentModesArgs { + agent: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct AgentModelsArgs { + agent: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct CreateSessionArgs { + session_id: String, + #[arg(long, short = 'a')] + agent: String, + #[arg(long, short = 'g')] + agent_mode: Option, + #[arg(long, short = 'p')] + permission_mode: Option, + #[arg(long, short = 'm')] + model: Option, + #[arg(long, short = 'v')] + variant: Option, + #[arg(long, short = 'A')] + agent_version: Option, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionMessageArgs { + session_id: String, + #[arg(long, short = 'm')] + message: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionMessageStreamArgs { + session_id: String, + #[arg(long, short = 'm')] + message: String, + #[arg(long)] + include_raw: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionEventsArgs { + session_id: String, + #[arg(long, short = 'o')] + offset: Option, + #[arg(long, short = 'l')] + limit: Option, + #[arg(long)] + include_raw: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionEventsSseArgs { + session_id: String, + #[arg(long, short = 'o')] + offset: Option, + #[arg(long)] + include_raw: bool, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct SessionTerminateArgs { + session_id: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct QuestionReplyArgs { + session_id: String, + question_id: String, + #[arg(long, short = 'a')] + answers: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct QuestionRejectArgs { + session_id: String, + question_id: String, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct PermissionReplyArgs { + session_id: String, + permission_id: String, + #[arg(long, short = 'r')] + reply: PermissionReply, + #[command(flatten)] + client: ClientArgs, +} + +#[derive(Args, Debug)] +pub struct CredentialsExtractArgs { + #[arg(long, short = 'a', value_enum)] + agent: Option, + #[arg(long, short = 'p')] + provider: Option, + #[arg(long, short = 'd')] + home_dir: Option, + #[arg(long)] + no_oauth: bool, + #[arg(long, short = 'r')] + reveal: bool, +} + +#[derive(Args, Debug)] +pub struct CredentialsExtractEnvArgs { + /// Prefix each line with "export " for shell sourcing. + #[arg(long, short = 'e')] + export: bool, + #[arg(long, short = 'd')] + home_dir: Option, + #[arg(long)] + no_oauth: bool, +} + +#[derive(Debug, Error)] +pub enum CliError { + #[error("invalid cors origin: {0}")] + InvalidCorsOrigin(String), + #[error("invalid cors method: {0}")] + InvalidCorsMethod(String), + #[error("invalid cors header: {0}")] + InvalidCorsHeader(String), + #[error("http error: {0}")] + Http(#[from] reqwest::Error), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json error: {0}")] + Json(#[from] serde_json::Error), + #[error("server error: {0}")] + Server(String), + #[error("unexpected http status: {0}")] + HttpStatus(reqwest::StatusCode), +} + +pub struct CliConfig { + pub token: Option, + pub no_token: bool, + pub gigacode: bool, +} + +pub fn run_sandbox_agent() -> Result<(), CliError> { + let cli = SandboxAgentCli::parse(); + let SandboxAgentCli { + command, + token, + no_token, + } = cli; + let config = CliConfig { + token, + no_token, + gigacode: false, + }; + if let Err(err) = init_logging(&command) { + eprintln!("failed to init logging: {err}"); + return Err(err); + } + run_command(&command, &config) +} + +pub fn init_logging(command: &Command) -> Result<(), CliError> { + if matches!(command, Command::Server(_)) { + maybe_redirect_server_logs(); + } + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::registry() + .with(filter) + .with( + tracing_logfmt::builder() + .layer() + .with_writer(std::io::stderr), + ) + .init(); + Ok(()) +} + +pub fn run_command(command: &Command, cli: &CliConfig) -> Result<(), CliError> { + match command { + Command::Server(args) => run_server(cli, args), + Command::Api(subcommand) => run_api(&subcommand.command, cli), + Command::Opencode(args) => run_opencode(cli, args), + Command::Daemon(subcommand) => run_daemon(&subcommand.command, cli), + Command::InstallAgent(args) => install_agent_local(args), + Command::Credentials(subcommand) => run_credentials(&subcommand.command), + } +} + +fn run_server(cli: &CliConfig, server: &ServerArgs) -> Result<(), CliError> { + let auth = if let Some(token) = cli.token.clone() { + AuthConfig::with_token(token) + } else { + AuthConfig::disabled() + }; + + let branding = if cli.gigacode { + BrandingMode::Gigacode + } else { + BrandingMode::SandboxAgent + }; + let agent_manager = AgentManager::new(default_install_dir()) + .map_err(|err| CliError::Server(err.to_string()))?; + let state = Arc::new(AppState::with_branding(auth, agent_manager, branding)); + let (mut router, state) = build_router_with_state(state); + + let cors = build_cors_layer(server)?; + router = router.layer(cors); + + let addr = format!("{}:{}", server.host, server.port); + let display_host = match server.host.as_str() { + "0.0.0.0" | "::" => "localhost", + other => other, + }; + let inspector_url = format!("http://{}:{}/ui", display_host, server.port); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|err| CliError::Server(err.to_string()))?; + + let telemetry_enabled = telemetry::telemetry_enabled(server.no_telemetry); + + runtime.block_on(async move { + if telemetry_enabled { + telemetry::log_enabled_message(); + telemetry::spawn_telemetry_task(); + } + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!(addr = %addr, "server listening"); + if ui::is_enabled() { + tracing::info!(url = %inspector_url, "inspector ui available"); + } else { + tracing::info!("inspector ui not embedded; set SANDBOX_AGENT_SKIP_INSPECTOR=1 to skip embedding during builds"); + } + let shutdown_state = state.clone(); + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = tokio::signal::ctrl_c().await; + shutdown_servers(&shutdown_state).await; + }) + .await + .map_err(|err| CliError::Server(err.to_string())) + }) +} + +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 default_server_log_dir() -> PathBuf { + if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") { + return PathBuf::from(dir); + } + dirs::data_dir() + .map(|dir| dir.join("sandbox-agent").join("logs")) + .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs")) +} + +fn maybe_redirect_server_logs() { + if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() { + return; + } + + let log_dir = default_server_log_dir(); + if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() { + eprintln!("failed to redirect logs: {err}"); + } +} + +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), + } +} + +fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> { + let name = if cli.gigacode { + "Gigacode" + } else { + "OpenCode command" + }; + 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 token = cli.token.clone(); + + let base_url = format!("http://{}:{}", args.host, args.port); + crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?; + + 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_url = format!("{base_url}/opencode"); + let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref())?; + 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(token) = token.as_deref() { + opencode_cmd.arg("--password").arg(token); + } + + let status = opencode_cmd + .status() + .map_err(|err| CliError::Server(format!("failed to start opencode: {err}")))?; + + if !status.success() { + return Err(CliError::Server(format!( + "opencode exited with status {status}" + ))); + } + + Ok(()) +} + +fn run_daemon(command: &DaemonCommand, cli: &CliConfig) -> Result<(), CliError> { + let token = cli.token.as_deref(); + match command { + 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) => { + let st = crate::daemon::status(&args.host, args.port, token)?; + write_stderr_line(&st.to_string())?; + Ok(()) + } + } +} + +fn run_agents(command: &AgentsCommand, cli: &CliConfig) -> Result<(), CliError> { + match command { + AgentsCommand::List(args) => { + let ctx = ClientContext::new(cli, args)?; + let response = ctx.get(&format!("{API_PREFIX}/agents"))?; + print_json_response::(response) + } + AgentsCommand::Install(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = AgentInstallRequest { + reinstall: if args.reinstall { Some(true) } else { None }, + }; + let path = format!("{API_PREFIX}/agents/{}/install", args.agent); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + AgentsCommand::Modes(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/agents/{}/modes", args.agent); + let response = ctx.get(&path)?; + print_json_response::(response) + } + AgentsCommand::Models(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/agents/{}/models", args.agent); + let response = ctx.get(&path)?; + print_json_response::(response) + } + } +} + +fn run_sessions(command: &SessionsCommand, cli: &CliConfig) -> Result<(), CliError> { + match command { + SessionsCommand::List(args) => { + let ctx = ClientContext::new(cli, args)?; + let response = ctx.get(&format!("{API_PREFIX}/sessions"))?; + print_json_response::(response) + } + SessionsCommand::Create(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = CreateSessionRequest { + agent: args.agent.clone(), + agent_mode: args.agent_mode.clone(), + permission_mode: args.permission_mode.clone(), + model: args.model.clone(), + variant: args.variant.clone(), + agent_version: args.agent_version.clone(), + }; + let path = format!("{API_PREFIX}/sessions/{}", args.session_id); + let response = ctx.post(&path, &body)?; + print_json_response::(response) + } + SessionsCommand::SendMessage(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = MessageRequest { + message: args.message.clone(), + }; + let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + SessionsCommand::SendMessageStream(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = MessageRequest { + message: args.message.clone(), + }; + let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id); + let response = ctx.post_with_query( + &path, + &body, + &[( + "include_raw", + if args.include_raw { + Some("true".to_string()) + } else { + None + }, + )], + )?; + print_text_response(response) + } + SessionsCommand::Terminate(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/sessions/{}/terminate", args.session_id); + let response = ctx.post_empty(&path)?; + print_empty_response(response) + } + SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id); + let response = ctx.get_with_query( + &path, + &[ + ("offset", args.offset.map(|v| v.to_string())), + ("limit", args.limit.map(|v| v.to_string())), + ( + "include_raw", + if args.include_raw { + Some("true".to_string()) + } else { + None + }, + ), + ], + )?; + print_json_response::(response) + } + SessionsCommand::EventsSse(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id); + let response = ctx.get_with_query( + &path, + &[ + ("offset", args.offset.map(|v| v.to_string())), + ( + "include_raw", + if args.include_raw { + Some("true".to_string()) + } else { + None + }, + ), + ], + )?; + print_text_response(response) + } + SessionsCommand::ReplyQuestion(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let answers: Vec> = serde_json::from_str(&args.answers)?; + let body = QuestionReplyRequest { answers }; + let path = format!( + "{API_PREFIX}/sessions/{}/questions/{}/reply", + args.session_id, args.question_id + ); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + SessionsCommand::RejectQuestion(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let path = format!( + "{API_PREFIX}/sessions/{}/questions/{}/reject", + args.session_id, args.question_id + ); + let response = ctx.post_empty(&path)?; + print_empty_response(response) + } + SessionsCommand::ReplyPermission(args) => { + let ctx = ClientContext::new(cli, &args.client)?; + let body = PermissionReplyRequest { + reply: args.reply.clone(), + }; + let path = format!( + "{API_PREFIX}/sessions/{}/permissions/{}/reply", + args.session_id, args.permission_id + ); + let response = ctx.post(&path, &body)?; + print_empty_response(response) + } + } +} + +fn create_opencode_session( + base_url: &str, + token: Option<&str>, + title: Option<&str>, +) -> Result { + let client = HttpClient::builder().build()?; + let url = format!("{base_url}/opencode/session"); + let body = if let Some(title) = title { + json!({ "title": title }) + } else { + json!({}) + }; + let mut request = client.post(&url).json(&body); + if let Ok(directory) = std::env::current_dir() { + request = request.header( + "x-opencode-directory", + directory.to_string_lossy().to_string(), + ); + } + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send()?; + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + let body: Value = serde_json::from_str(&text)?; + let session_id = body + .get("id") + .and_then(|value| value.as_str()) + .ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?; + Ok(session_id.to_string()) +} + +fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> Result { + 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); + } + + 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) + } + } +} + +fn find_in_path(binary_name: &str) -> Option { + 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) => { + let mut options = CredentialExtractionOptions::new(); + if let Some(home_dir) = args.home_dir.clone() { + options.home_dir = Some(home_dir); + } + if args.no_oauth { + options.include_oauth = false; + } + + let credentials = extract_all_credentials(&options); + if let Some(agent) = args.agent.clone() { + let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?; + write_stdout_line(&token)?; + return Ok(()); + } + if let Some(provider) = args.provider.as_deref() { + let token = select_token_for_provider(&credentials, provider)?; + write_stdout_line(&token)?; + return Ok(()); + } + + let output = credentials_to_output(credentials, args.reveal); + let pretty = serde_json::to_string_pretty(&output)?; + write_stdout_line(&pretty)?; + Ok(()) + } + CredentialsCommand::ExtractEnv(args) => { + let mut options = CredentialExtractionOptions::new(); + if let Some(home_dir) = args.home_dir.clone() { + options.home_dir = Some(home_dir); + } + if args.no_oauth { + options.include_oauth = false; + } + + let credentials = extract_all_credentials(&options); + let prefix = if args.export { "export " } else { "" }; + + if let Some(cred) = &credentials.anthropic { + write_stdout_line(&format!("{}ANTHROPIC_API_KEY={}", prefix, cred.api_key))?; + write_stdout_line(&format!("{}CLAUDE_API_KEY={}", prefix, cred.api_key))?; + } + if let Some(cred) = &credentials.openai { + write_stdout_line(&format!("{}OPENAI_API_KEY={}", prefix, cred.api_key))?; + write_stdout_line(&format!("{}CODEX_API_KEY={}", prefix, cred.api_key))?; + } + for (provider, cred) in &credentials.other { + let var_name = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_")); + write_stdout_line(&format!("{}{}={}", prefix, var_name, cred.api_key))?; + } + + Ok(()) + } + } +} + +#[derive(Serialize)] +struct CredentialsOutput { + anthropic: Option, + openai: Option, + other: HashMap, +} + +#[derive(Serialize)] +struct CredentialSummary { + provider: String, + source: String, + auth_type: String, + api_key: String, + redacted: bool, +} + +#[derive(clap::ValueEnum, Clone, Debug)] +enum CredentialAgent { + Claude, + Codex, + Opencode, + Amp, + Pi, +} + +fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput { + CredentialsOutput { + anthropic: credentials + .anthropic + .map(|cred| summarize_credential(&cred, reveal)), + openai: credentials + .openai + .map(|cred| summarize_credential(&cred, reveal)), + other: credentials + .other + .into_iter() + .map(|(key, cred)| (key, summarize_credential(&cred, reveal))) + .collect(), + } +} + +fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary { + let api_key = if reveal { + credential.api_key.clone() + } else { + redact_key(&credential.api_key) + }; + CredentialSummary { + provider: credential.provider.clone(), + source: credential.source.clone(), + auth_type: match credential.auth_type { + AuthType::ApiKey => "api_key".to_string(), + AuthType::Oauth => "oauth".to_string(), + }, + api_key, + redacted: !reveal, + } +} + +fn redact_key(key: &str) -> String { + let trimmed = key.trim(); + let len = trimmed.len(); + if len <= 8 { + return "****".to_string(); + } + let prefix = &trimmed[..4]; + let suffix = &trimmed[len - 4..]; + format!("{prefix}...{suffix}") +} + +fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { + let agent_id = AgentId::parse(&args.agent) + .ok_or_else(|| CliError::Server(format!("unsupported agent: {}", args.agent)))?; + let manager = AgentManager::new(default_install_dir()) + .map_err(|err| CliError::Server(err.to_string()))?; + manager + .install( + agent_id, + InstallOptions { + reinstall: args.reinstall, + version: None, + }, + ) + .map_err(|err| CliError::Server(err.to_string()))?; + Ok(()) +} + +fn select_token_for_agent( + credentials: &ExtractedCredentials, + agent: CredentialAgent, + provider: Option<&str>, +) -> Result { + match agent { + CredentialAgent::Claude | CredentialAgent::Amp => { + if let Some(provider) = provider { + if provider != "anthropic" { + return Err(CliError::Server(format!( + "agent {:?} only supports provider anthropic", + agent + ))); + } + } + select_token_for_provider(credentials, "anthropic") + } + CredentialAgent::Codex => { + if let Some(provider) = provider { + if provider != "openai" { + return Err(CliError::Server(format!( + "agent {:?} only supports provider openai", + agent + ))); + } + } + select_token_for_provider(credentials, "openai") + } + CredentialAgent::Opencode => { + if let Some(provider) = provider { + return select_token_for_provider(credentials, provider); + } + if let Some(openai) = credentials.openai.as_ref() { + return Ok(openai.api_key.clone()); + } + if let Some(anthropic) = credentials.anthropic.as_ref() { + return Ok(anthropic.api_key.clone()); + } + if credentials.other.len() == 1 { + if let Some((_, cred)) = credentials.other.iter().next() { + return Ok(cred.api_key.clone()); + } + } + let available = available_providers(credentials); + if available.is_empty() { + Err(CliError::Server( + "no credentials found for opencode".to_string(), + )) + } else { + Err(CliError::Server(format!( + "multiple providers available for opencode: {} (use --provider)", + available.join(", ") + ))) + } + } + CredentialAgent::Pi => { + if let Some(provider) = provider { + return select_token_for_provider(credentials, provider); + } + if let Some(openai) = credentials.openai.as_ref() { + return Ok(openai.api_key.clone()); + } + if let Some(anthropic) = credentials.anthropic.as_ref() { + return Ok(anthropic.api_key.clone()); + } + if credentials.other.len() == 1 { + if let Some((_, cred)) = credentials.other.iter().next() { + return Ok(cred.api_key.clone()); + } + } + let available = available_providers(credentials); + if available.is_empty() { + Err(CliError::Server("no credentials found for pi".to_string())) + } else { + Err(CliError::Server(format!( + "multiple providers available for pi: {} (use --provider)", + available.join(", ") + ))) + } + } + } +} + +fn select_token_for_provider( + credentials: &ExtractedCredentials, + provider: &str, +) -> Result { + if let Some(cred) = provider_credential(credentials, provider) { + Ok(cred.api_key.clone()) + } else { + Err(CliError::Server(format!( + "no credentials found for provider {provider}" + ))) + } +} + +fn provider_credential<'a>( + credentials: &'a ExtractedCredentials, + provider: &str, +) -> Option<&'a ProviderCredentials> { + match provider { + "openai" => credentials.openai.as_ref(), + "anthropic" => credentials.anthropic.as_ref(), + _ => credentials.other.get(provider), + } +} + +fn available_providers(credentials: &ExtractedCredentials) -> Vec { + let mut providers = Vec::new(); + if credentials.openai.is_some() { + providers.push("openai".to_string()); + } + if credentials.anthropic.is_some() { + providers.push("anthropic".to_string()); + } + for key in credentials.other.keys() { + providers.push(key.clone()); + } + providers.sort(); + providers.dedup(); + providers +} + +fn build_cors_layer(server: &ServerArgs) -> Result { + let mut cors = CorsLayer::new(); + + // Build origins list from provided origins + let mut origins = Vec::new(); + for origin in &server.cors_allow_origin { + let value = origin + .parse() + .map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?; + origins.push(value); + } + if origins.is_empty() { + // No origins allowed - use permissive CORS with no origins (effectively disabled) + cors = cors.allow_origin(tower_http::cors::AllowOrigin::predicate(|_, _| false)); + } else { + cors = cors.allow_origin(origins); + } + + // Methods: allow any if not specified, otherwise use provided list + if server.cors_allow_method.is_empty() { + cors = cors.allow_methods(Any); + } else { + let mut methods = Vec::new(); + for method in &server.cors_allow_method { + let parsed = method + .parse() + .map_err(|_| CliError::InvalidCorsMethod(method.clone()))?; + methods.push(parsed); + } + cors = cors.allow_methods(methods); + } + + // Headers: allow any if not specified, otherwise use provided list + if server.cors_allow_header.is_empty() { + cors = cors.allow_headers(Any); + } else { + let mut headers = Vec::new(); + for header in &server.cors_allow_header { + let parsed = header + .parse() + .map_err(|_| CliError::InvalidCorsHeader(header.clone()))?; + headers.push(parsed); + } + cors = cors.allow_headers(headers); + } + + if server.cors_allow_credentials { + cors = cors.allow_credentials(true); + } + + Ok(cors) +} + +struct ClientContext { + endpoint: String, + token: Option, + client: HttpClient, +} + +impl ClientContext { + fn new(cli: &CliConfig, args: &ClientArgs) -> Result { + let endpoint = args + .endpoint + .clone() + .unwrap_or_else(|| format!("http://{}:{}", DEFAULT_HOST, DEFAULT_PORT)); + let token = if cli.no_token { + None + } else { + cli.token.clone() + }; + let client = HttpClient::builder().build()?; + Ok(Self { + endpoint, + token, + client, + }) + } + + fn url(&self, path: &str) -> String { + format!("{}{}", self.endpoint.trim_end_matches('/'), path) + } + + fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder { + let url = self.url(path); + let mut builder = self.client.request(method, url); + if let Some(token) = &self.token { + builder = builder.bearer_auth(token); + } + builder + } + + fn get(&self, path: &str) -> Result { + Ok(self.request(Method::GET, path).send()?) + } + + fn get_with_query( + &self, + path: &str, + query: &[(&str, Option)], + ) -> Result { + let mut request = self.request(Method::GET, path); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.send()?) + } + + fn post( + &self, + path: &str, + body: &T, + ) -> Result { + Ok(self.request(Method::POST, path).json(body).send()?) + } + + fn post_with_query( + &self, + path: &str, + body: &T, + query: &[(&str, Option)], + ) -> Result { + let mut request = self.request(Method::POST, path).json(body); + for (key, value) in query { + if let Some(value) = value { + request = request.query(&[(key, value)]); + } + } + Ok(request.send()?) + } + + fn post_empty(&self, path: &str) -> Result { + Ok(self.request(Method::POST, path).send()?) + } +} + +fn print_json_response( + response: reqwest::blocking::Response, +) -> Result<(), CliError> { + let status = response.status(); + let text = response.text()?; + + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + + let parsed: T = serde_json::from_str(&text)?; + let pretty = serde_json::to_string_pretty(&parsed)?; + write_stdout_line(&pretty)?; + Ok(()) +} + +fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliError> { + let status = response.status(); + let text = response.text()?; + + if !status.is_success() { + print_error_body(&text)?; + return Err(CliError::HttpStatus(status)); + } + + write_stdout(&text)?; + Ok(()) +} + +fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> { + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let text = response.text()?; + print_error_body(&text)?; + Err(CliError::HttpStatus(status)) +} + +fn print_error_body(text: &str) -> Result<(), CliError> { + if let Ok(json) = serde_json::from_str::(text) { + let pretty = serde_json::to_string_pretty(&json)?; + write_stderr_line(&pretty)?; + } else { + write_stderr_line(text)?; + } + Ok(()) +} + +fn write_stdout(text: &str) -> Result<(), CliError> { + let mut out = std::io::stdout(); + out.write_all(text.as_bytes())?; + out.flush()?; + Ok(()) +} + +fn write_stdout_line(text: &str) -> Result<(), CliError> { + let mut out = std::io::stdout(); + out.write_all(text.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + Ok(()) +} + +fn write_stderr_line(text: &str) -> Result<(), CliError> { + let mut out = std::io::stderr(); + out.write_all(text.as_bytes())?; + out.write_all(b"\n")?; + out.flush()?; + Ok(()) +} diff --git a/server/packages/sandbox-agent/src/daemon.rs b/server/packages/sandbox-agent/src/daemon.rs new file mode 100644 index 0000000..58bf6b4 --- /dev/null +++ b/server/packages/sandbox-agent/src/daemon.rs @@ -0,0 +1,476 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command as ProcessCommand, Stdio}; +use std::time::{Duration, Instant}; + +use reqwest::blocking::Client as HttpClient; + +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); + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +pub fn daemon_state_dir() -> PathBuf { + dirs::data_dir() + .map(|dir| dir.join("sandbox-agent").join("daemon")) + .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("daemon")) +} + +pub fn sanitize_host(host: &str) -> String { + host.chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect() +} + +pub fn daemon_pid_path(host: &str, port: u16) -> PathBuf { + let name = format!("daemon-{}-{}.pid", sanitize_host(host), port); + daemon_state_dir().join(name) +} + +pub fn daemon_log_path(host: &str, port: u16) -> PathBuf { + let name = format!("daemon-{}-{}.log", sanitize_host(host), port); + daemon_state_dir().join(name) +} + +pub fn daemon_version_path(host: &str, port: u16) -> PathBuf { + let name = format!("daemon-{}-{}.version", sanitize_host(host), port); + daemon_state_dir().join(name) +} + +// --------------------------------------------------------------------------- +// PID helpers +// --------------------------------------------------------------------------- + +pub fn read_pid(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + text.trim().parse::().ok() +} + +pub fn write_pid(path: &Path, pid: u32) -> Result<(), CliError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, pid.to_string())?; + Ok(()) +} + +pub fn remove_pid(path: &Path) -> Result<(), CliError> { + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Version helpers +// --------------------------------------------------------------------------- + +pub fn read_daemon_version(host: &str, port: u16) -> Option { + let path = daemon_version_path(host, port); + let text = fs::read_to_string(path).ok()?; + Some(text.trim().to_string()) +} + +pub fn write_daemon_version(host: &str, port: u16) -> Result<(), CliError> { + let path = daemon_version_path(host, port); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, BUILD_ID)?; + Ok(()) +} + +pub fn remove_version_file(host: &str, port: u16) -> Result<(), CliError> { + let path = daemon_version_path(host, port); + if path.exists() { + fs::remove_file(path)?; + } + Ok(()) +} + +pub fn is_version_current(host: &str, port: u16) -> bool { + match read_daemon_version(host, port) { + Some(v) => v == BUILD_ID, + None => false, + } +} + +// --------------------------------------------------------------------------- +// Process helpers +// --------------------------------------------------------------------------- + +#[cfg(unix)] +pub fn is_process_running(pid: u32) -> bool { + let result = unsafe { libc::kill(pid as i32, 0) }; + if result == 0 { + return true; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(code) if code == libc::EPERM => true, + _ => false, + } +} + +#[cfg(windows)] +pub fn is_process_running(pid: u32) -> bool { + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + unsafe { + let handle = match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) { + Ok(h) => h, + Err(_) => return false, + }; + let mut exit_code = 0u32; + let ok = GetExitCodeProcess(handle, &mut exit_code).is_ok(); + let _ = CloseHandle(handle); + ok && exit_code == 259 + } +} + +// --------------------------------------------------------------------------- +// Health checks +// --------------------------------------------------------------------------- + +pub fn check_health(base_url: &str, token: Option<&str>) -> Result { + let client = HttpClient::builder().build()?; + let url = format!("{base_url}/v1/health"); + 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), + } +} + +pub fn wait_for_health( + mut server_child: Option<&mut Child>, + base_url: &str, + token: Option<&str>, + timeout: Duration, +) -> Result<(), CliError> { + let client = HttpClient::builder().build()?; + let deadline = Instant::now() + timeout; + + while Instant::now() < deadline { + if let Some(child) = server_child.as_mut() { + if let Some(status) = child.try_wait()? { + return Err(CliError::Server(format!( + "sandbox-agent exited before becoming healthy ({status})" + ))); + } + } + + let url = format!("{base_url}/v1/health"); + 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() => return Ok(()), + _ => { + std::thread::sleep(Duration::from_millis(200)); + } + } + } + + Err(CliError::Server( + "timed out waiting for sandbox-agent health".to_string(), + )) +} + +// --------------------------------------------------------------------------- +// Spawn +// --------------------------------------------------------------------------- + +pub fn spawn_sandbox_agent_daemon( + cli: &CliConfig, + host: &str, + port: u16, + token: Option<&str>, + log_path: &Path, +) -> Result { + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent)?; + } + let log_file = fs::File::create(log_path)?; + let log_file_err = log_file.try_clone()?; + + let exe = std::env::current_exe()?; + let mut cmd = ProcessCommand::new(exe); + cmd.arg("server") + .arg("--host") + .arg(host) + .arg("--port") + .arg(port.to_string()) + .env("SANDBOX_AGENT_LOG_STDOUT", "1") + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(log_file_err)); + + if let Some(token) = token { + cmd.arg("--token").arg(token); + } + + cmd.spawn().map_err(CliError::from) +} + +// --------------------------------------------------------------------------- +// DaemonStatus +// --------------------------------------------------------------------------- + +#[derive(Debug)] +pub enum DaemonStatus { + Running { + pid: u32, + version: Option, + version_current: bool, + log_path: PathBuf, + }, + NotRunning, +} + +impl std::fmt::Display for DaemonStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DaemonStatus::Running { + pid, + version, + version_current, + log_path, + } => { + let version_str = version.as_deref().unwrap_or("unknown"); + let outdated = if *version_current { + "" + } else { + " [outdated, restart recommended]" + }; + write!( + f, + "Daemon running (PID {pid}, build {version_str}, logs: {}){}", + log_path.display(), + outdated + ) + } + DaemonStatus::NotRunning => write!(f, "Daemon not running"), + } + } +} + +// --------------------------------------------------------------------------- +// High-level commands +// --------------------------------------------------------------------------- + +pub fn status(host: &str, port: u16, token: Option<&str>) -> Result { + let pid_path = daemon_pid_path(host, port); + let log_path = daemon_log_path(host, port); + + if let Some(pid) = read_pid(&pid_path) { + if is_process_running(pid) { + let version = read_daemon_version(host, port); + let version_current = is_version_current(host, port); + return Ok(DaemonStatus::Running { + pid, + version, + version_current, + log_path, + }); + } + // Stale PID file + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + } + + // Also try a health check in case the daemon is running but we lost the PID file + let base_url = format!("http://{host}:{port}"); + if check_health(&base_url, token)? { + return Ok(DaemonStatus::Running { + pid: 0, + version: read_daemon_version(host, port), + version_current: is_version_current(host, port), + log_path, + }); + } + + Ok(DaemonStatus::NotRunning) +} + +pub fn start(cli: &CliConfig, host: &str, port: u16, token: Option<&str>) -> Result<(), CliError> { + let base_url = format!("http://{host}:{port}"); + let pid_path = daemon_pid_path(host, port); + let log_path = daemon_log_path(host, port); + + // Already healthy? + if check_health(&base_url, token)? { + eprintln!("daemon already running at {base_url}"); + return Ok(()); + } + + // Stale PID? + if let Some(pid) = read_pid(&pid_path) { + if is_process_running(pid) { + eprintln!("daemon process {pid} exists; waiting for health"); + return wait_for_health(None, &base_url, token, DAEMON_HEALTH_TIMEOUT); + } + let _ = remove_pid(&pid_path); + } + + eprintln!( + "starting daemon at {base_url} (logs: {})", + log_path.display() + ); + + let mut child = spawn_sandbox_agent_daemon(cli, host, port, token, &log_path)?; + let pid = child.id(); + write_pid(&pid_path, pid)?; + write_daemon_version(host, port)?; + + let result = wait_for_health(Some(&mut child), &base_url, token, DAEMON_HEALTH_TIMEOUT); + if result.is_err() { + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + return result; + } + + eprintln!("daemon started (PID {pid}, logs: {})", log_path.display()); + Ok(()) +} + +#[cfg(unix)] +pub fn stop(host: &str, port: u16) -> Result<(), CliError> { + let pid_path = daemon_pid_path(host, port); + + 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(()); + } + + eprintln!("stopping daemon (PID {pid})..."); + + // SIGTERM + unsafe { + libc::kill(pid as i32, libc::SIGTERM); + } + + // Wait up to 5 seconds for graceful exit + for _ in 0..50 { + std::thread::sleep(Duration::from_millis(100)); + if !is_process_running(pid) { + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + eprintln!("daemon stopped"); + return Ok(()); + } + } + + // SIGKILL + eprintln!("daemon did not stop gracefully, sending SIGKILL..."); + unsafe { + libc::kill(pid as i32, libc::SIGKILL); + } + std::thread::sleep(Duration::from_millis(100)); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + eprintln!("daemon killed"); + Ok(()) +} + +#[cfg(windows)] +pub fn stop(host: &str, port: u16) -> Result<(), CliError> { + let pid_path = daemon_pid_path(host, port); + + 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(()); + } + + eprintln!("stopping daemon (PID {pid})..."); + + // Use taskkill on Windows + let _ = ProcessCommand::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .status(); + + std::thread::sleep(Duration::from_millis(500)); + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + eprintln!("daemon stopped"); + Ok(()) +} + +pub fn ensure_running( + cli: &CliConfig, + host: &str, + port: u16, + token: Option<&str>, +) -> Result<(), CliError> { + let base_url = format!("http://{host}:{port}"); + let pid_path = daemon_pid_path(host, port); + + // 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..."); + stop(host, port)?; + return start(cli, host, port, token); + } + let log_path = daemon_log_path(host, port); + if let Some(pid) = read_pid(&pid_path) { + eprintln!( + "daemon already running at {base_url} (PID {pid}, logs: {})", + log_path.display() + ); + } else { + eprintln!("daemon already running at {base_url}"); + } + return Ok(()); + } + + // Not healthy — check for stale PID + if let Some(pid) = read_pid(&pid_path) { + if is_process_running(pid) { + eprintln!("daemon process {pid} running; waiting for health"); + return wait_for_health(None, &base_url, token, DAEMON_HEALTH_TIMEOUT); + } + let _ = remove_pid(&pid_path); + let _ = remove_version_file(host, port); + } + + start(cli, host, port, token) +} diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index 81dd9ab..9000924 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -1,7 +1,9 @@ //! Sandbox agent core utilities. mod agent_server_logs; +pub mod cli; pub mod credentials; +pub mod daemon; pub mod http_client; pub mod opencode_compat; pub mod router; diff --git a/server/packages/sandbox-agent/src/main.rs b/server/packages/sandbox-agent/src/main.rs index 52cc24d..4169fca 100644 --- a/server/packages/sandbox-agent/src/main.rs +++ b/server/packages/sandbox-agent/src/main.rs @@ -1,1295 +1,6 @@ -use std::collections::HashMap; -use std::io::Write; -use std::path::PathBuf; -use std::process::{Child, Command as ProcessCommand, Stdio}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use clap::{Args, Parser, Subcommand}; - -// Include the generated version constant -mod build_version { - include!(concat!(env!("OUT_DIR"), "/version.rs")); -} -use reqwest::blocking::Client as HttpClient; -use reqwest::Method; -use sandbox_agent::http_client; -use sandbox_agent::router::{build_router_with_state, shutdown_servers}; -use sandbox_agent::router::{ - AgentInstallRequest, AppState, AuthConfig, CreateSessionRequest, MessageRequest, - PermissionReply, PermissionReplyRequest, QuestionReplyRequest, -}; -use sandbox_agent::router::{ - AgentListResponse, AgentModesResponse, CreateSessionResponse, EventsResponse, - SessionListResponse, -}; -use sandbox_agent::server_logs::ServerLogs; -use sandbox_agent::telemetry; -use sandbox_agent::ui; -use sandbox_agent_agent_management::agents::{AgentId, AgentManager, InstallOptions}; -use sandbox_agent_agent_management::credentials::{ - extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, - ProviderCredentials, -}; -use serde::Serialize; -use serde_json::{json, Value}; -use thiserror::Error; -use tower_http::cors::{Any, CorsLayer}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; - -const API_PREFIX: &str = "/v1"; -const DEFAULT_HOST: &str = "127.0.0.1"; -const DEFAULT_PORT: u16 = 2468; -const LOGS_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); - -#[derive(Parser, Debug)] -#[command(name = "sandbox-agent", bin_name = "sandbox-agent")] -#[command(about = "https://sandboxagent.dev", version = build_version::VERSION)] -#[command(arg_required_else_help = true)] -struct Cli { - #[command(subcommand)] - command: Command, - - #[arg(long, short = 't', global = true)] - token: Option, - - #[arg(long, short = 'n', global = true)] - no_token: bool, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Run the sandbox agent HTTP server. - Server(ServerArgs), - /// Call the HTTP API without writing client code. - Api(ApiArgs), - /// EXPERIMENTAL: Start a sandbox-agent server and attach an OpenCode session. - Opencode(OpencodeArgs), - /// Install or reinstall an agent without running the server. - InstallAgent(InstallAgentArgs), - /// Inspect locally discovered credentials. - Credentials(CredentialsArgs), -} - -#[derive(Args, Debug)] -struct ServerArgs { - #[arg(long, short = 'H', default_value = DEFAULT_HOST)] - host: String, - - #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] - port: u16, - - #[arg(long = "cors-allow-origin", short = 'O')] - cors_allow_origin: Vec, - - #[arg(long = "cors-allow-method", short = 'M')] - cors_allow_method: Vec, - - #[arg(long = "cors-allow-header", short = 'A')] - cors_allow_header: Vec, - - #[arg(long = "cors-allow-credentials", short = 'C')] - cors_allow_credentials: bool, - - #[arg(long = "no-telemetry")] - no_telemetry: bool, -} - -#[derive(Args, Debug)] -struct ApiArgs { - #[command(subcommand)] - command: ApiCommand, -} - -#[derive(Args, Debug)] -struct OpencodeArgs { - #[arg(long, short = 'H', default_value = DEFAULT_HOST)] - host: String, - - #[arg(long, short = 'p', default_value_t = DEFAULT_PORT)] - port: u16, - - #[arg(long)] - session_title: Option, - - #[arg(long)] - opencode_bin: Option, -} - -#[derive(Args, Debug)] -struct CredentialsArgs { - #[command(subcommand)] - command: CredentialsCommand, -} - -#[derive(Subcommand, Debug)] -enum ApiCommand { - /// Manage installed agents and their modes. - Agents(AgentsArgs), - /// Create sessions and interact with session events. - Sessions(SessionsArgs), -} - -#[derive(Subcommand, Debug)] -enum CredentialsCommand { - /// Extract credentials using local discovery rules. - Extract(CredentialsExtractArgs), - /// Output credentials as environment variable assignments. - #[command(name = "extract-env")] - ExtractEnv(CredentialsExtractEnvArgs), -} - -#[derive(Args, Debug)] -struct AgentsArgs { - #[command(subcommand)] - command: AgentsCommand, -} - -#[derive(Args, Debug)] -struct SessionsArgs { - #[command(subcommand)] - command: SessionsCommand, -} - -#[derive(Subcommand, Debug)] -enum AgentsCommand { - /// List all agents and install status. - List(ClientArgs), - /// Install or reinstall an agent. - Install(ApiInstallAgentArgs), - /// Show available modes for an agent. - Modes(AgentModesArgs), -} - -#[derive(Subcommand, Debug)] -enum SessionsCommand { - /// List active sessions. - List(ClientArgs), - /// Create a new session for an agent. - Create(CreateSessionArgs), - #[command(name = "send-message")] - /// Send a message to an existing session. - SendMessage(SessionMessageArgs), - #[command(name = "send-message-stream")] - /// Send a message and stream the response for one turn. - SendMessageStream(SessionMessageStreamArgs), - #[command(name = "terminate")] - /// Terminate a session. - Terminate(SessionTerminateArgs), - #[command(name = "get-messages")] - /// Alias for events; returns session events. - GetMessages(SessionEventsArgs), - #[command(name = "events")] - /// Fetch session events with offset/limit. - Events(SessionEventsArgs), - #[command(name = "events-sse")] - /// Stream session events over SSE. - EventsSse(SessionEventsSseArgs), - #[command(name = "reply-question")] - /// Reply to a question event. - ReplyQuestion(QuestionReplyArgs), - #[command(name = "reject-question")] - /// Reject a question event. - RejectQuestion(QuestionRejectArgs), - #[command(name = "reply-permission")] - /// Reply to a permission request. - ReplyPermission(PermissionReplyArgs), -} - -#[derive(Args, Debug, Clone)] -struct ClientArgs { - #[arg(long, short = 'e')] - endpoint: Option, -} - -#[derive(Args, Debug)] -struct ApiInstallAgentArgs { - agent: String, - #[arg(long, short = 'r')] - reinstall: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct InstallAgentArgs { - agent: String, - #[arg(long, short = 'r')] - reinstall: bool, -} - -#[derive(Args, Debug)] -struct AgentModesArgs { - agent: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct CreateSessionArgs { - session_id: String, - #[arg(long, short = 'a')] - agent: String, - #[arg(long, short = 'g')] - agent_mode: Option, - #[arg(long, short = 'p')] - permission_mode: Option, - #[arg(long, short = 'm')] - model: Option, - #[arg(long, short = 'v')] - variant: Option, - #[arg(long, short = 'A')] - agent_version: Option, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionMessageArgs { - session_id: String, - #[arg(long, short = 'm')] - message: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionMessageStreamArgs { - session_id: String, - #[arg(long, short = 'm')] - message: String, - #[arg(long)] - include_raw: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionEventsArgs { - session_id: String, - #[arg(long, short = 'o')] - offset: Option, - #[arg(long, short = 'l')] - limit: Option, - #[arg(long)] - include_raw: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionEventsSseArgs { - session_id: String, - #[arg(long, short = 'o')] - offset: Option, - #[arg(long)] - include_raw: bool, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct SessionTerminateArgs { - session_id: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct QuestionReplyArgs { - session_id: String, - question_id: String, - #[arg(long, short = 'a')] - answers: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct QuestionRejectArgs { - session_id: String, - question_id: String, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct PermissionReplyArgs { - session_id: String, - permission_id: String, - #[arg(long, short = 'r')] - reply: PermissionReply, - #[command(flatten)] - client: ClientArgs, -} - -#[derive(Args, Debug)] -struct CredentialsExtractArgs { - #[arg(long, short = 'a', value_enum)] - agent: Option, - #[arg(long, short = 'p')] - provider: Option, - #[arg(long, short = 'd')] - home_dir: Option, - #[arg(long)] - no_oauth: bool, - #[arg(long, short = 'r')] - reveal: bool, -} - -#[derive(Args, Debug)] -struct CredentialsExtractEnvArgs { - /// Prefix each line with "export " for shell sourcing. - #[arg(long, short = 'e')] - export: bool, - #[arg(long, short = 'd')] - home_dir: Option, - #[arg(long)] - no_oauth: bool, -} - -#[derive(Debug, Error)] -enum CliError { - #[error("missing --token or --no-token for server mode")] - MissingToken, - #[error("invalid cors origin: {0}")] - InvalidCorsOrigin(String), - #[error("invalid cors method: {0}")] - InvalidCorsMethod(String), - #[error("invalid cors header: {0}")] - InvalidCorsHeader(String), - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("json error: {0}")] - Json(#[from] serde_json::Error), - #[error("server error: {0}")] - Server(String), - #[error("unexpected http status: {0}")] - HttpStatus(reqwest::StatusCode), -} - fn main() { - let cli = Cli::parse(); - if let Err(err) = init_logging(&cli) { - eprintln!("failed to init logging: {err}"); - std::process::exit(1); - } - - let result = match &cli.command { - Command::Server(args) => run_server(&cli, args), - command => run_client(command, &cli), - }; - - if let Err(err) = result { + if let Err(err) = sandbox_agent::cli::run_sandbox_agent() { tracing::error!(error = %err, "sandbox-agent failed"); std::process::exit(1); } } - -fn init_logging(cli: &Cli) -> Result<(), CliError> { - if matches!(cli.command, Command::Server(_)) { - maybe_redirect_server_logs(); - } - - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); - tracing_subscriber::registry() - .with(filter) - .with( - tracing_logfmt::builder() - .layer() - .with_writer(std::io::stderr), - ) - .init(); - Ok(()) -} - -fn run_server(cli: &Cli, server: &ServerArgs) -> Result<(), CliError> { - let auth = if cli.no_token { - AuthConfig::disabled() - } else if let Some(token) = cli.token.clone() { - AuthConfig::with_token(token) - } else { - return Err(CliError::MissingToken); - }; - - let agent_manager = AgentManager::new(default_install_dir()) - .map_err(|err| CliError::Server(err.to_string()))?; - let state = Arc::new(AppState::new(auth, agent_manager)); - let (mut router, state) = build_router_with_state(state); - - let cors = build_cors_layer(server)?; - router = router.layer(cors); - - let addr = format!("{}:{}", server.host, server.port); - let display_host = match server.host.as_str() { - "0.0.0.0" | "::" => "localhost", - other => other, - }; - let inspector_url = format!("http://{}:{}/ui", display_host, server.port); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(|err| CliError::Server(err.to_string()))?; - - let telemetry_enabled = telemetry::telemetry_enabled(server.no_telemetry); - - runtime.block_on(async move { - if telemetry_enabled { - telemetry::log_enabled_message(); - telemetry::spawn_telemetry_task(); - } - let listener = tokio::net::TcpListener::bind(&addr).await?; - tracing::info!(addr = %addr, "server listening"); - if ui::is_enabled() { - tracing::info!(url = %inspector_url, "inspector ui available"); - } else { - tracing::info!("inspector ui not embedded; set SANDBOX_AGENT_SKIP_INSPECTOR=1 to skip embedding during builds"); - } - let shutdown_state = state.clone(); - axum::serve(listener, router) - .with_graceful_shutdown(async move { - let _ = tokio::signal::ctrl_c().await; - shutdown_servers(&shutdown_state).await; - }) - .await - .map_err(|err| CliError::Server(err.to_string())) - }) -} - -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 default_server_log_dir() -> PathBuf { - if let Ok(dir) = std::env::var("SANDBOX_AGENT_LOG_DIR") { - return PathBuf::from(dir); - } - dirs::data_dir() - .map(|dir| dir.join("sandbox-agent").join("logs")) - .unwrap_or_else(|| PathBuf::from(".").join(".sandbox-agent").join("logs")) -} - -fn maybe_redirect_server_logs() { - if std::env::var("SANDBOX_AGENT_LOG_STDOUT").is_ok() { - return; - } - - let log_dir = default_server_log_dir(); - if let Err(err) = ServerLogs::new(log_dir, LOGS_RETENTION).start_sync() { - eprintln!("failed to redirect logs: {err}"); - } -} - -fn run_client(command: &Command, cli: &Cli) -> Result<(), CliError> { - match command { - Command::Server(_) => Err(CliError::Server( - "server subcommand must be invoked as `sandbox-agent server`".to_string(), - )), - Command::Api(subcommand) => run_api(&subcommand.command, cli), - Command::Opencode(args) => run_opencode(cli, args), - Command::InstallAgent(args) => install_agent_local(args), - Command::Credentials(subcommand) => run_credentials(&subcommand.command), - } -} - -fn run_api(command: &ApiCommand, cli: &Cli) -> Result<(), CliError> { - match command { - ApiCommand::Agents(subcommand) => run_agents(&subcommand.command, cli), - ApiCommand::Sessions(subcommand) => run_sessions(&subcommand.command, cli), - } -} - -fn run_opencode(cli: &Cli, args: &OpencodeArgs) -> Result<(), CliError> { - write_stderr_line("experimental: opencode subcommand may change without notice")?; - - let token = if cli.no_token { - None - } else { - Some(cli.token.clone().ok_or(CliError::MissingToken)?) - }; - - let mut server_child = spawn_sandbox_agent_server(cli, args, token.as_deref())?; - let base_url = format!("http://{}:{}", args.host, args.port); - wait_for_health(&mut server_child, &base_url, token.as_deref())?; - - 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_url = format!("{base_url}/opencode"); - let opencode_bin = resolve_opencode_bin(args.opencode_bin.as_ref()); - 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(token) = token.as_deref() { - opencode_cmd.arg("--password").arg(token); - } - - let status = opencode_cmd.status().map_err(|err| { - terminate_child(&mut server_child); - CliError::Server(format!("failed to start opencode: {err}")) - })?; - - terminate_child(&mut server_child); - - if !status.success() { - return Err(CliError::Server(format!( - "opencode exited with status {status}" - ))); - } - - Ok(()) -} - -fn run_agents(command: &AgentsCommand, cli: &Cli) -> Result<(), CliError> { - match command { - AgentsCommand::List(args) => { - let ctx = ClientContext::new(cli, args)?; - let response = ctx.get(&format!("{API_PREFIX}/agents"))?; - print_json_response::(response) - } - AgentsCommand::Install(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = AgentInstallRequest { - reinstall: if args.reinstall { Some(true) } else { None }, - }; - let path = format!("{API_PREFIX}/agents/{}/install", args.agent); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - AgentsCommand::Modes(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/agents/{}/modes", args.agent); - let response = ctx.get(&path)?; - print_json_response::(response) - } - } -} - -fn run_sessions(command: &SessionsCommand, cli: &Cli) -> Result<(), CliError> { - match command { - SessionsCommand::List(args) => { - let ctx = ClientContext::new(cli, args)?; - let response = ctx.get(&format!("{API_PREFIX}/sessions"))?; - print_json_response::(response) - } - SessionsCommand::Create(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = CreateSessionRequest { - agent: args.agent.clone(), - agent_mode: args.agent_mode.clone(), - permission_mode: args.permission_mode.clone(), - model: args.model.clone(), - variant: args.variant.clone(), - agent_version: args.agent_version.clone(), - }; - let path = format!("{API_PREFIX}/sessions/{}", args.session_id); - let response = ctx.post(&path, &body)?; - print_json_response::(response) - } - SessionsCommand::SendMessage(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = MessageRequest { - message: args.message.clone(), - }; - let path = format!("{API_PREFIX}/sessions/{}/messages", args.session_id); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - SessionsCommand::SendMessageStream(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = MessageRequest { - message: args.message.clone(), - }; - let path = format!("{API_PREFIX}/sessions/{}/messages/stream", args.session_id); - let response = ctx.post_with_query( - &path, - &body, - &[( - "include_raw", - if args.include_raw { - Some("true".to_string()) - } else { - None - }, - )], - )?; - print_text_response(response) - } - SessionsCommand::Terminate(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/sessions/{}/terminate", args.session_id); - let response = ctx.post_empty(&path)?; - print_empty_response(response) - } - SessionsCommand::GetMessages(args) | SessionsCommand::Events(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/sessions/{}/events", args.session_id); - let response = ctx.get_with_query( - &path, - &[ - ("offset", args.offset.map(|v| v.to_string())), - ("limit", args.limit.map(|v| v.to_string())), - ( - "include_raw", - if args.include_raw { - Some("true".to_string()) - } else { - None - }, - ), - ], - )?; - print_json_response::(response) - } - SessionsCommand::EventsSse(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!("{API_PREFIX}/sessions/{}/events/sse", args.session_id); - let response = ctx.get_with_query( - &path, - &[ - ("offset", args.offset.map(|v| v.to_string())), - ( - "include_raw", - if args.include_raw { - Some("true".to_string()) - } else { - None - }, - ), - ], - )?; - print_text_response(response) - } - SessionsCommand::ReplyQuestion(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let answers: Vec> = serde_json::from_str(&args.answers)?; - let body = QuestionReplyRequest { answers }; - let path = format!( - "{API_PREFIX}/sessions/{}/questions/{}/reply", - args.session_id, args.question_id - ); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - SessionsCommand::RejectQuestion(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let path = format!( - "{API_PREFIX}/sessions/{}/questions/{}/reject", - args.session_id, args.question_id - ); - let response = ctx.post_empty(&path)?; - print_empty_response(response) - } - SessionsCommand::ReplyPermission(args) => { - let ctx = ClientContext::new(cli, &args.client)?; - let body = PermissionReplyRequest { - reply: args.reply.clone(), - }; - let path = format!( - "{API_PREFIX}/sessions/{}/permissions/{}/reply", - args.session_id, args.permission_id - ); - let response = ctx.post(&path, &body)?; - print_empty_response(response) - } - } -} - -fn spawn_sandbox_agent_server( - cli: &Cli, - args: &OpencodeArgs, - token: Option<&str>, -) -> Result { - let exe = std::env::current_exe()?; - let mut cmd = ProcessCommand::new(exe); - cmd.arg("server") - .arg("--host") - .arg(&args.host) - .arg("--port") - .arg(args.port.to_string()) - .stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - if cli.no_token { - cmd.arg("--no-token"); - } else if let Some(token) = token { - cmd.arg("--token").arg(token); - } - - cmd.spawn().map_err(CliError::from) -} - -fn wait_for_health( - server_child: &mut Child, - base_url: &str, - token: Option<&str>, -) -> Result<(), CliError> { - let client = HttpClient::builder().build()?; - let deadline = Instant::now() + Duration::from_secs(30); - - while Instant::now() < deadline { - if let Some(status) = server_child.try_wait()? { - return Err(CliError::Server(format!( - "sandbox-agent exited before becoming healthy ({status})" - ))); - } - - let url = format!("{base_url}/v1/health"); - 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() => return Ok(()), - _ => { - std::thread::sleep(Duration::from_millis(200)); - } - } - } - - Err(CliError::Server( - "timed out waiting for sandbox-agent health".to_string(), - )) -} - -fn create_opencode_session( - base_url: &str, - token: Option<&str>, - title: Option<&str>, -) -> Result { - let client = HttpClient::builder().build()?; - let url = format!("{base_url}/opencode/session"); - let body = if let Some(title) = title { - json!({ "title": title }) - } else { - json!({}) - }; - let mut request = client.post(&url).json(&body); - if let Ok(directory) = std::env::current_dir() { - request = request.header( - "x-opencode-directory", - directory.to_string_lossy().to_string(), - ); - } - if let Some(token) = token { - request = request.bearer_auth(token); - } - let response = request.send()?; - let status = response.status(); - let text = response.text()?; - if !status.is_success() { - print_error_body(&text)?; - return Err(CliError::HttpStatus(status)); - } - let body: Value = serde_json::from_str(&text)?; - let session_id = body - .get("id") - .and_then(|value| value.as_str()) - .ok_or_else(|| CliError::Server("opencode session missing id".to_string()))?; - Ok(session_id.to_string()) -} - -fn resolve_opencode_bin(explicit: Option<&PathBuf>) -> PathBuf { - if let Some(path) = explicit { - return path.clone(); - } - if let Ok(path) = std::env::var("OPENCODE_BIN") { - return PathBuf::from(path); - } - PathBuf::from("opencode") -} - -fn terminate_child(child: &mut Child) { - let _ = child.kill(); - let _ = child.wait(); -} - -fn run_credentials(command: &CredentialsCommand) -> Result<(), CliError> { - match command { - CredentialsCommand::Extract(args) => { - let mut options = CredentialExtractionOptions::new(); - if let Some(home_dir) = args.home_dir.clone() { - options.home_dir = Some(home_dir); - } - if args.no_oauth { - options.include_oauth = false; - } - - let credentials = extract_all_credentials(&options); - if let Some(agent) = args.agent.clone() { - let token = select_token_for_agent(&credentials, agent, args.provider.as_deref())?; - write_stdout_line(&token)?; - return Ok(()); - } - if let Some(provider) = args.provider.as_deref() { - let token = select_token_for_provider(&credentials, provider)?; - write_stdout_line(&token)?; - return Ok(()); - } - - let output = credentials_to_output(credentials, args.reveal); - let pretty = serde_json::to_string_pretty(&output)?; - write_stdout_line(&pretty)?; - Ok(()) - } - CredentialsCommand::ExtractEnv(args) => { - let mut options = CredentialExtractionOptions::new(); - if let Some(home_dir) = args.home_dir.clone() { - options.home_dir = Some(home_dir); - } - if args.no_oauth { - options.include_oauth = false; - } - - let credentials = extract_all_credentials(&options); - let prefix = if args.export { "export " } else { "" }; - - if let Some(cred) = &credentials.anthropic { - write_stdout_line(&format!("{}ANTHROPIC_API_KEY={}", prefix, cred.api_key))?; - write_stdout_line(&format!("{}CLAUDE_API_KEY={}", prefix, cred.api_key))?; - } - if let Some(cred) = &credentials.openai { - write_stdout_line(&format!("{}OPENAI_API_KEY={}", prefix, cred.api_key))?; - write_stdout_line(&format!("{}CODEX_API_KEY={}", prefix, cred.api_key))?; - } - for (provider, cred) in &credentials.other { - let var_name = format!("{}_API_KEY", provider.to_uppercase().replace('-', "_")); - write_stdout_line(&format!("{}{}={}", prefix, var_name, cred.api_key))?; - } - - Ok(()) - } - } -} - -#[derive(Serialize)] -struct CredentialsOutput { - anthropic: Option, - openai: Option, - other: HashMap, -} - -#[derive(Serialize)] -struct CredentialSummary { - provider: String, - source: String, - auth_type: String, - api_key: String, - redacted: bool, -} - -#[derive(clap::ValueEnum, Clone, Debug)] -enum CredentialAgent { - Claude, - Codex, - Opencode, - Amp, - Pi, -} - -fn credentials_to_output(credentials: ExtractedCredentials, reveal: bool) -> CredentialsOutput { - CredentialsOutput { - anthropic: credentials - .anthropic - .map(|cred| summarize_credential(&cred, reveal)), - openai: credentials - .openai - .map(|cred| summarize_credential(&cred, reveal)), - other: credentials - .other - .into_iter() - .map(|(key, cred)| (key, summarize_credential(&cred, reveal))) - .collect(), - } -} - -fn summarize_credential(credential: &ProviderCredentials, reveal: bool) -> CredentialSummary { - let api_key = if reveal { - credential.api_key.clone() - } else { - redact_key(&credential.api_key) - }; - CredentialSummary { - provider: credential.provider.clone(), - source: credential.source.clone(), - auth_type: match credential.auth_type { - AuthType::ApiKey => "api_key".to_string(), - AuthType::Oauth => "oauth".to_string(), - }, - api_key, - redacted: !reveal, - } -} - -fn redact_key(key: &str) -> String { - let trimmed = key.trim(); - let len = trimmed.len(); - if len <= 8 { - return "****".to_string(); - } - let prefix = &trimmed[..4]; - let suffix = &trimmed[len - 4..]; - format!("{prefix}...{suffix}") -} - -fn install_agent_local(args: &InstallAgentArgs) -> Result<(), CliError> { - let agent_id = AgentId::parse(&args.agent) - .ok_or_else(|| CliError::Server(format!("unsupported agent: {}", args.agent)))?; - let manager = AgentManager::new(default_install_dir()) - .map_err(|err| CliError::Server(err.to_string()))?; - manager - .install( - agent_id, - InstallOptions { - reinstall: args.reinstall, - version: None, - }, - ) - .map_err(|err| CliError::Server(err.to_string()))?; - Ok(()) -} - -fn select_token_for_agent( - credentials: &ExtractedCredentials, - agent: CredentialAgent, - provider: Option<&str>, -) -> Result { - match agent { - CredentialAgent::Claude | CredentialAgent::Amp => { - if let Some(provider) = provider { - if provider != "anthropic" { - return Err(CliError::Server(format!( - "agent {:?} only supports provider anthropic", - agent - ))); - } - } - select_token_for_provider(credentials, "anthropic") - } - CredentialAgent::Codex => { - if let Some(provider) = provider { - if provider != "openai" { - return Err(CliError::Server(format!( - "agent {:?} only supports provider openai", - agent - ))); - } - } - select_token_for_provider(credentials, "openai") - } - CredentialAgent::Opencode => { - if let Some(provider) = provider { - return select_token_for_provider(credentials, provider); - } - if let Some(openai) = credentials.openai.as_ref() { - return Ok(openai.api_key.clone()); - } - if let Some(anthropic) = credentials.anthropic.as_ref() { - return Ok(anthropic.api_key.clone()); - } - if credentials.other.len() == 1 { - if let Some((_, cred)) = credentials.other.iter().next() { - return Ok(cred.api_key.clone()); - } - } - let available = available_providers(credentials); - if available.is_empty() { - Err(CliError::Server( - "no credentials found for opencode".to_string(), - )) - } else { - Err(CliError::Server(format!( - "multiple providers available for opencode: {} (use --provider)", - available.join(", ") - ))) - } - } - CredentialAgent::Pi => { - if let Some(provider) = provider { - return select_token_for_provider(credentials, provider); - } - if let Some(openai) = credentials.openai.as_ref() { - return Ok(openai.api_key.clone()); - } - if let Some(anthropic) = credentials.anthropic.as_ref() { - return Ok(anthropic.api_key.clone()); - } - if credentials.other.len() == 1 { - if let Some((_, cred)) = credentials.other.iter().next() { - return Ok(cred.api_key.clone()); - } - } - let available = available_providers(credentials); - if available.is_empty() { - Err(CliError::Server("no credentials found for pi".to_string())) - } else { - Err(CliError::Server(format!( - "multiple providers available for pi: {} (use --provider)", - available.join(", ") - ))) - } - } - } -} - -fn select_token_for_provider( - credentials: &ExtractedCredentials, - provider: &str, -) -> Result { - if let Some(cred) = provider_credential(credentials, provider) { - Ok(cred.api_key.clone()) - } else { - Err(CliError::Server(format!( - "no credentials found for provider {provider}" - ))) - } -} - -fn provider_credential<'a>( - credentials: &'a ExtractedCredentials, - provider: &str, -) -> Option<&'a ProviderCredentials> { - match provider { - "openai" => credentials.openai.as_ref(), - "anthropic" => credentials.anthropic.as_ref(), - _ => credentials.other.get(provider), - } -} - -fn available_providers(credentials: &ExtractedCredentials) -> Vec { - let mut providers = Vec::new(); - if credentials.openai.is_some() { - providers.push("openai".to_string()); - } - if credentials.anthropic.is_some() { - providers.push("anthropic".to_string()); - } - for key in credentials.other.keys() { - providers.push(key.clone()); - } - providers.sort(); - providers.dedup(); - providers -} - -fn build_cors_layer(server: &ServerArgs) -> Result { - let mut cors = CorsLayer::new(); - - // Build origins list from provided origins - let mut origins = Vec::new(); - for origin in &server.cors_allow_origin { - let value = origin - .parse() - .map_err(|_| CliError::InvalidCorsOrigin(origin.clone()))?; - origins.push(value); - } - if origins.is_empty() { - // No origins allowed - use permissive CORS with no origins (effectively disabled) - cors = cors.allow_origin(tower_http::cors::AllowOrigin::predicate(|_, _| false)); - } else { - cors = cors.allow_origin(origins); - } - - // Methods: allow any if not specified, otherwise use provided list - if server.cors_allow_method.is_empty() { - cors = cors.allow_methods(Any); - } else { - let mut methods = Vec::new(); - for method in &server.cors_allow_method { - let parsed = method - .parse() - .map_err(|_| CliError::InvalidCorsMethod(method.clone()))?; - methods.push(parsed); - } - cors = cors.allow_methods(methods); - } - - // Headers: allow any if not specified, otherwise use provided list - if server.cors_allow_header.is_empty() { - cors = cors.allow_headers(Any); - } else { - let mut headers = Vec::new(); - for header in &server.cors_allow_header { - let parsed = header - .parse() - .map_err(|_| CliError::InvalidCorsHeader(header.clone()))?; - headers.push(parsed); - } - cors = cors.allow_headers(headers); - } - - if server.cors_allow_credentials { - cors = cors.allow_credentials(true); - } - - Ok(cors) -} - -struct ClientContext { - endpoint: String, - token: Option, - client: HttpClient, -} - -impl ClientContext { - fn new(cli: &Cli, args: &ClientArgs) -> Result { - let endpoint = args - .endpoint - .clone() - .unwrap_or_else(|| format!("http://{}:{}", DEFAULT_HOST, DEFAULT_PORT)); - let token = if cli.no_token { - None - } else { - cli.token.clone() - }; - let client = http_client::blocking_client_builder().build()?; - Ok(Self { - endpoint, - token, - client, - }) - } - - fn url(&self, path: &str) -> String { - format!("{}{}", self.endpoint.trim_end_matches('/'), path) - } - - fn request(&self, method: Method, path: &str) -> reqwest::blocking::RequestBuilder { - let url = self.url(path); - let mut builder = self.client.request(method, url); - if let Some(token) = &self.token { - builder = builder.bearer_auth(token); - } - builder - } - - fn get(&self, path: &str) -> Result { - Ok(self.request(Method::GET, path).send()?) - } - - fn get_with_query( - &self, - path: &str, - query: &[(&str, Option)], - ) -> Result { - let mut request = self.request(Method::GET, path); - for (key, value) in query { - if let Some(value) = value { - request = request.query(&[(key, value)]); - } - } - Ok(request.send()?) - } - - fn post( - &self, - path: &str, - body: &T, - ) -> Result { - Ok(self.request(Method::POST, path).json(body).send()?) - } - - fn post_with_query( - &self, - path: &str, - body: &T, - query: &[(&str, Option)], - ) -> Result { - let mut request = self.request(Method::POST, path).json(body); - for (key, value) in query { - if let Some(value) = value { - request = request.query(&[(key, value)]); - } - } - Ok(request.send()?) - } - - fn post_empty(&self, path: &str) -> Result { - Ok(self.request(Method::POST, path).send()?) - } -} - -fn print_json_response( - response: reqwest::blocking::Response, -) -> Result<(), CliError> { - let status = response.status(); - let text = response.text()?; - - if !status.is_success() { - print_error_body(&text)?; - return Err(CliError::HttpStatus(status)); - } - - let parsed: T = serde_json::from_str(&text)?; - let pretty = serde_json::to_string_pretty(&parsed)?; - write_stdout_line(&pretty)?; - Ok(()) -} - -fn print_text_response(response: reqwest::blocking::Response) -> Result<(), CliError> { - let status = response.status(); - let text = response.text()?; - - if !status.is_success() { - print_error_body(&text)?; - return Err(CliError::HttpStatus(status)); - } - - write_stdout(&text)?; - Ok(()) -} - -fn print_empty_response(response: reqwest::blocking::Response) -> Result<(), CliError> { - let status = response.status(); - if status.is_success() { - return Ok(()); - } - let text = response.text()?; - print_error_body(&text)?; - Err(CliError::HttpStatus(status)) -} - -fn print_error_body(text: &str) -> Result<(), CliError> { - if let Ok(json) = serde_json::from_str::(text) { - let pretty = serde_json::to_string_pretty(&json)?; - write_stderr_line(&pretty)?; - } else { - write_stderr_line(text)?; - } - Ok(()) -} - -fn write_stdout(text: &str) -> Result<(), CliError> { - let mut out = std::io::stdout(); - out.write_all(text.as_bytes())?; - out.flush()?; - Ok(()) -} - -fn write_stdout_line(text: &str) -> Result<(), CliError> { - let mut out = std::io::stdout(); - out.write_all(text.as_bytes())?; - out.write_all(b"\n")?; - out.flush()?; - Ok(()) -} - -fn write_stderr_line(text: &str) -> Result<(), CliError> { - let mut out = std::io::stderr(); - out.write_all(text.as_bytes())?; - out.write_all(b"\n")?; - out.flush()?; - Ok(()) -} diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 31fa62b..1393f94 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -4,11 +4,12 @@ //! stubbed responses with deterministic helpers for snapshot testing. A minimal //! in-memory state tracks sessions/messages/ptys to keep behavior coherent. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::convert::Infallible; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use axum::extract::{Path, Query, State}; use axum::http::{HeaderMap, StatusCode}; @@ -23,7 +24,7 @@ use tokio::sync::{broadcast, Mutex}; use tokio::time::interval; use utoipa::{IntoParams, OpenApi, ToSchema}; -use crate::router::{AppState, CreateSessionRequest, PermissionReply}; +use crate::router::{AgentModelInfo, AppState, CreateSessionRequest, PermissionReply}; use sandbox_agent_agent_management::agents::AgentId; use sandbox_agent_error::SandboxError; use sandbox_agent_universal_agent_schema::{ @@ -37,10 +38,10 @@ 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_PROVIDER_ID: &str = "sandbox-agent"; -const OPENCODE_PROVIDER_NAME: &str = "Sandbox Agent"; 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); #[derive(Clone, Debug)] struct OpenCodeCompatConfig { @@ -212,6 +213,30 @@ struct OpenCodeSessionRuntime { part_id_by_message: HashMap, tool_part_by_call: HashMap, tool_message_by_call: HashMap, + /// Tool name by call_id, persisted from ToolCall for use in ToolResult events + tool_name_by_call: HashMap, + /// Tool arguments by call_id, persisted from ToolCall for use in ToolResult events + tool_args_by_call: HashMap, +} + +#[derive(Clone, Debug)] +struct OpenCodeModelEntry { + model: AgentModelInfo, + group_id: String, + group_name: String, +} + +#[derive(Clone, Debug)] +struct OpenCodeModelCache { + entries: Vec, + model_lookup: HashMap, + group_defaults: HashMap, + group_agents: HashMap, + group_names: HashMap, + default_group: String, + default_model: String, + cached_at: Instant, + had_discovery_errors: bool, } pub struct OpenCodeState { @@ -225,6 +250,7 @@ pub struct OpenCodeState { session_runtime: Mutex>, session_streams: Mutex>, event_broadcaster: broadcast::Sender, + model_cache: Mutex>, } impl OpenCodeState { @@ -242,6 +268,7 @@ impl OpenCodeState { session_runtime: Mutex::new(HashMap::new()), session_streams: Mutex::new(HashMap::new()), event_broadcaster, + model_cache: Mutex::new(None), } } @@ -371,13 +398,17 @@ async fn ensure_backing_session( state: &Arc, session_id: &str, agent: &str, + model: Option, + variant: Option, ) -> Result<(), SandboxError> { + let model = model.filter(|value| !value.trim().is_empty()); + let variant = variant.filter(|value| !value.trim().is_empty()); let request = CreateSessionRequest { agent: agent.to_string(), agent_mode: None, permission_mode: None, - model: None, - variant: None, + model: model.clone(), + variant: variant.clone(), agent_version: None, }; match state @@ -387,7 +418,15 @@ async fn ensure_backing_session( .await { Ok(_) => Ok(()), - Err(SandboxError::SessionAlreadyExists { .. }) => 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(err) => Err(err), } } @@ -587,12 +626,208 @@ fn default_agent_mode() -> &'static str { OPENCODE_DEFAULT_AGENT_MODE } -fn resolve_agent_from_model(provider_id: &str, model_id: &str) -> Option { - if provider_id == OPENCODE_PROVIDER_ID { - AgentId::parse(model_id) - } else { - None +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 + } + }; + + let mut cache = build_opencode_model_cache(state).await; + if let Some(previous_cache) = previous_cache { + if cache.had_discovery_errors + && cache.entries.is_empty() + && !previous_cache.entries.is_empty() + { + cache = previous_cache; + cache.cached_at = Instant::now(); + } } + let mut slot = state.opencode.model_cache.lock().await; + *slot = Some(cache.clone()); + cache +} + +async fn build_opencode_model_cache(state: &OpenCodeAppState) -> OpenCodeModelCache { + let mut entries = Vec::new(); + let mut model_lookup = HashMap::new(); + let mut ambiguous_models = HashSet::new(); + let mut group_defaults: HashMap = HashMap::new(); + let mut group_agents: HashMap = HashMap::new(); + let mut group_names: HashMap = HashMap::new(); + let mut default_model: Option = None; + let mut had_discovery_errors = false; + + for agent in available_agent_ids() { + let response = match state.inner.session_manager().agent_models(agent).await { + 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, + ?err, + "failed to discover models for OpenCode provider" + ); + continue; + } + }; + + if response.models.is_empty() { + 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); + } + + let first_model_id = response.models.first().map(|model| model.id.clone()); + for model in response.models { + let model_id = model.id.clone(); + let (group_id, group_name) = group_for_agent_model(agent, &model_id); + + if response.default_model.as_deref() == Some(model_id.as_str()) { + group_defaults.insert(group_id.clone(), model_id.clone()); + } + + group_agents.entry(group_id.clone()).or_insert(agent); + group_names + .entry(group_id.clone()) + .or_insert_with(|| group_name.clone()); + + if !ambiguous_models.contains(&model_id) { + match model_lookup.get(&model_id) { + None => { + model_lookup.insert(model_id.clone(), agent); + } + Some(existing) if *existing != agent => { + model_lookup.remove(&model_id); + ambiguous_models.insert(model_id.clone()); + } + _ => {} + } + } + + entries.push(OpenCodeModelEntry { + model, + group_id, + group_name, + }); + } + + if default_model.is_none() { + default_model = response.default_model.clone().or(first_model_id); + } + } + + let mut groups: BTreeMap> = BTreeMap::new(); + for entry in &entries { + groups + .entry(entry.group_id.clone()) + .or_default() + .push(entry); + } + for entries in groups.values_mut() { + entries.sort_by(|a, b| a.model.id.cmp(&b.model.id)); + } + + if entries + .iter() + .any(|entry| entry.model.id == OPENCODE_DEFAULT_MODEL_ID) + { + default_model = Some(OPENCODE_DEFAULT_MODEL_ID.to_string()); + } + + let default_model = default_model.unwrap_or_else(|| { + entries + .first() + .map(|entry| entry.model.id.clone()) + .unwrap_or_else(|| OPENCODE_DEFAULT_MODEL_ID.to_string()) + }); + + let mut default_group = entries + .iter() + .find(|entry| entry.model.id == default_model) + .map(|entry| entry.group_id.clone()) + .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string()); + + if !groups.contains_key(&default_group) { + if let Some((group_id, _)) = groups.iter().next() { + default_group = group_id.clone(); + } + } + + for (group_id, entries) in &groups { + if !group_defaults.contains_key(group_id) { + if let Some(entry) = entries.first() { + group_defaults.insert(group_id.clone(), entry.model.id.clone()); + } + } + } + + OpenCodeModelCache { + entries, + model_lookup, + group_defaults, + group_agents, + group_names, + default_group, + default_model, + cached_at: Instant::now(), + had_discovery_errors, + } +} + +fn fallback_group_for_agent(agent: AgentId) -> (String, String) { + if agent == AgentId::Opencode { + return ( + "opencode".to_string(), + agent_display_name(agent).to_string(), + ); + } + ( + agent.as_str().to_string(), + agent_display_name(agent).to_string(), + ) +} + +fn resolve_agent_from_model( + cache: &OpenCodeModelCache, + provider_id: &str, + model_id: &str, +) -> Option { + if let Some(agent) = cache.group_agents.get(provider_id) { + return Some(*agent); + } + if let Some(agent) = cache.model_lookup.get(model_id) { + return Some(*agent); + } + if let Some(agent) = AgentId::parse(model_id) { + return Some(agent); + } + if opencode_group_provider(provider_id).is_some() { + return Some(AgentId::Opencode); + } + if model_id.contains('/') { + return Some(AgentId::Opencode); + } + if model_id.starts_with("claude-") { + return Some(AgentId::Claude); + } + if ["smart", "rush", "deep", "free"].contains(&model_id) { + return Some(AgentId::Amp); + } + if model_id.starts_with("gpt-") || model_id.starts_with('o') { + return Some(AgentId::Codex); + } + None } fn normalize_agent_mode(agent: Option) -> String { @@ -607,19 +842,38 @@ async fn resolve_session_agent( requested_provider: Option<&str>, requested_model: Option<&str>, ) -> (String, String, String) { + let cache = opencode_model_cache(state).await; + let default_model_id = cache.default_model.clone(); let mut provider_id = requested_provider .filter(|value| !value.is_empty()) - .unwrap_or(OPENCODE_PROVIDER_ID) - .to_string(); - let mut model_id = requested_model + .filter(|value| *value != "sandbox-agent") + .map(|value| value.to_string()); + let model_id = requested_model .filter(|value| !value.is_empty()) - .unwrap_or(OPENCODE_DEFAULT_MODEL_ID) - .to_string(); - let mut resolved_agent = resolve_agent_from_model(&provider_id, &model_id); + .map(|value| value.to_string()); + if provider_id.is_none() { + if let Some(model_value) = model_id.as_deref() { + if let Some(entry) = cache + .entries + .iter() + .find(|entry| entry.model.id == model_value) + { + provider_id = Some(entry.group_id.clone()); + } else if let Some(agent) = AgentId::parse(model_value) { + provider_id = Some(agent.as_str().to_string()); + } + } + } + let mut provider_id = provider_id.unwrap_or_else(|| cache.default_group.clone()); + let mut model_id = model_id + .or_else(|| cache.group_defaults.get(&provider_id).cloned()) + .unwrap_or_else(|| default_model_id.clone()); + let mut resolved_agent = resolve_agent_from_model(&cache, &provider_id, &model_id); if resolved_agent.is_none() { - provider_id = OPENCODE_PROVIDER_ID.to_string(); - model_id = OPENCODE_DEFAULT_MODEL_ID.to_string(); - resolved_agent = Some(default_agent_id()); + provider_id = cache.default_group.clone(); + model_id = default_model_id.clone(); + resolved_agent = resolve_agent_from_model(&cache, &provider_id, &model_id) + .or_else(|| Some(default_agent_id())); } let mut resolved_agent_id: Option = None; @@ -650,7 +904,7 @@ async fn resolve_session_agent( fn agent_display_name(agent: AgentId) -> &'static str { match agent { - AgentId::Claude => "Claude", + AgentId::Claude => "Claude Code", AgentId::Codex => "Codex", AgentId::Opencode => "OpenCode", AgentId::Amp => "Amp", @@ -659,17 +913,63 @@ fn agent_display_name(agent: AgentId) -> &'static str { } } -fn model_config_entry(agent: AgentId) -> Value { +fn opencode_model_provider(model_id: &str) -> Option<&str> { + model_id.split_once('/').map(|(provider, _)| provider) +} + +fn opencode_group_provider(group_id: &str) -> Option<&str> { + group_id.strip_prefix("opencode:") +} + +fn group_for_agent_model(agent: AgentId, model_id: &str) -> (String, String) { + if agent == AgentId::Opencode { + let provider = opencode_model_provider(model_id).unwrap_or("unknown"); + return ( + format!("opencode:{provider}"), + format!("OpenCode ({provider})"), + ); + } + let group_id = agent.as_str().to_string(); + let group_name = agent_display_name(agent).to_string(); + (group_id, group_name) +} + +fn backing_model_for_agent(agent: AgentId, provider_id: &str, model_id: &str) -> Option { + if model_id.trim().is_empty() { + return None; + } + if AgentId::parse(model_id).is_some() { + return None; + } + if agent != AgentId::Opencode { + return Some(model_id.to_string()); + } + if model_id.contains('/') { + return Some(model_id.to_string()); + } + if let Some(provider) = opencode_group_provider(provider_id) { + return Some(format!("{provider}/{model_id}")); + } + Some(model_id.to_string()) +} + +fn model_config_entry(entry: &OpenCodeModelEntry) -> Value { + let model_name = entry + .model + .name + .clone() + .unwrap_or_else(|| entry.model.id.clone()); + let variants = model_variants_object(&entry.model); json!({ - "id": agent.as_str(), - "providerID": OPENCODE_PROVIDER_ID, + "id": entry.model.id, + "providerID": entry.group_id, "api": { "id": "sandbox-agent", "url": "http://localhost", "npm": "@sandbox-agent/sdk" }, - "name": agent_display_name(agent), - "family": "sandbox-agent", + "name": model_name, + "family": entry.group_name, "capabilities": { "temperature": true, "reasoning": true, @@ -704,14 +1004,21 @@ fn model_config_entry(agent: AgentId) -> Value { "options": {}, "headers": {}, "release_date": "2024-01-01", - "variants": {} + "variants": variants }) } -fn model_summary_entry(agent: AgentId) -> Value { +fn model_summary_entry(entry: &OpenCodeModelEntry) -> Value { + let model_name = entry + .model + .name + .clone() + .unwrap_or_else(|| entry.model.id.clone()); + let variants = model_variants_object(&entry.model); json!({ - "id": agent.as_str(), - "name": agent_display_name(agent), + "id": entry.model.id, + "name": model_name, + "family": entry.group_name, "release_date": "2024-01-01", "attachment": false, "reasoning": true, @@ -721,10 +1028,22 @@ fn model_summary_entry(agent: AgentId) -> Value { "limit": { "context": 128000, "output": 4096 - } + }, + "variants": variants }) } +fn model_variants_object(model: &AgentModelInfo) -> Value { + let Some(variants) = model.variants.as_ref() else { + return json!({}); + }; + let mut map = serde_json::Map::new(); + for variant in variants { + map.insert(variant.clone(), json!({})); + } + Value::Object(map) +} + fn bad_request(message: &str) -> (StatusCode, Json) { ( StatusCode::BAD_REQUEST, @@ -1296,6 +1615,25 @@ async fn apply_universal_event(state: Arc, event: UniversalEve 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; } } @@ -1540,7 +1878,7 @@ async fn apply_item_event( let provider_id = runtime .last_model_provider .clone() - .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); + .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string()); let model_id = runtime .last_model_id .clone() @@ -1587,25 +1925,48 @@ async fn apply_item_event( .entry(message_id.clone()) .or_insert_with(|| format!("{}_text", message_id)) .clone(); - runtime - .text_by_message - .insert(message_id.clone(), text.clone()); - 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("message.part.updated", &part)); - let _ = state - .opencode - .update_runtime(&session_id, |runtime| { - runtime - .text_by_message - .insert(message_id.clone(), text.clone()); - runtime - .part_id_by_message - .insert(message_id.clone(), part_id.clone()); - }) - .await; + 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. + 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()); + }) + .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; + } } for part in item.content.iter() { @@ -1635,9 +1996,10 @@ async fn apply_item_event( .entry(call_id.clone()) .or_insert_with(|| next_id("part_", &PART_COUNTER)) .clone(); + let input_value = tool_input_from_arguments(Some(arguments.as_str())); let state_value = json!({ "status": "pending", - "input": {"arguments": arguments}, + "input": input_value, "raw": arguments, }); let tool_part = build_tool_part( @@ -1662,6 +2024,12 @@ async fn apply_item_event( runtime .tool_message_by_call .insert(call_id.clone(), message_id.clone()); + runtime + .tool_name_by_call + .insert(call_id.clone(), name.clone()); + runtime + .tool_args_by_call + .insert(call_id.clone(), arguments.clone()); }) .await; } @@ -1671,9 +2039,26 @@ async fn apply_item_event( .entry(call_id.clone()) .or_insert_with(|| next_id("part_", &PART_COUNTER)) .clone(); + // Resolve tool name from stored ToolCall data + let tool_name = runtime + .tool_name_by_call + .get(call_id) + .cloned() + .unwrap_or_else(|| "tool".to_string()); + // Resolve input from stored ToolCall arguments + let input_value = runtime + .tool_args_by_call + .get(call_id) + .and_then(|args| { + tool_input_from_arguments(Some(args.as_str())) + .as_object() + .cloned() + }) + .map(Value::Object) + .unwrap_or_else(|| json!({})); let state_value = json!({ "status": "completed", - "input": {}, + "input": input_value, "output": output, "title": "Tool result", "metadata": {}, @@ -1685,7 +2070,7 @@ async fn apply_item_event( &message_id, &part_id, call_id, - "tool", + &tool_name, state_value, ); upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()) @@ -1736,20 +2121,6 @@ async fn apply_item_event( _ => {} } } - - if event.event_type == UniversalEventType::ItemCompleted { - 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 } - })); - } } async fn apply_tool_item_event( @@ -1821,7 +2192,7 @@ async fn apply_tool_item_event( let provider_id = runtime .last_model_provider .clone() - .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); + .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string()); let model_id = runtime .last_model_id .clone() @@ -1878,12 +2249,19 @@ async fn apply_tool_item_event( .get(&call_id) .cloned() .unwrap_or_else(|| next_id("part_", &PART_COUNTER)); + // Resolve tool name: prefer current event's data, fall back to stored value from ToolCall let tool_name = tool_info .tool_name .clone() + .or_else(|| runtime.tool_name_by_call.get(&call_id).cloned()) .unwrap_or_else(|| "tool".to_string()); - let input_value = tool_input_from_arguments(tool_info.arguments.as_deref()); - let raw_args = tool_info.arguments.clone().unwrap_or_default(); + // Resolve arguments: prefer current event's data, fall back to stored value from ToolCall + let effective_arguments = tool_info + .arguments + .clone() + .or_else(|| runtime.tool_args_by_call.get(&call_id).cloned()); + let input_value = tool_input_from_arguments(effective_arguments.as_deref()); + let raw_args = effective_arguments.clone().unwrap_or_default(); let output_value = tool_info .output .clone() @@ -1911,7 +2289,7 @@ async fn apply_tool_item_event( json!({ "status": "error", "input": input_value, - "error": output_value.unwrap_or_else(|| "Tool failed".to_string()), + "output": output_value.unwrap_or_else(|| "Tool failed".to_string()), "metadata": {}, "time": {"start": now, "end": now}, }) @@ -1963,6 +2341,17 @@ async fn apply_tool_item_event( runtime .tool_message_by_call .insert(call_id.clone(), message_id.clone()); + // Persist tool name and arguments from ToolCall for later ToolResult events + if let Some(name) = tool_info.tool_name.as_ref() { + runtime + .tool_name_by_call + .insert(call_id.clone(), name.clone()); + } + if let Some(args) = tool_info.arguments.as_ref() { + runtime + .tool_args_by_call + .insert(call_id.clone(), args.clone()); + } }) .await; } @@ -2037,7 +2426,7 @@ async fn apply_item_delta( let provider_id = runtime .last_model_provider .clone() - .unwrap_or_else(|| OPENCODE_PROVIDER_ID.to_string()); + .unwrap_or_else(|| OPENCODE_DEFAULT_PROVIDER_ID.to_string()); let model_id = runtime .last_model_id .clone() @@ -2070,9 +2459,11 @@ async fn apply_item_delta( .unwrap_or_else(|| format!("{}_text", message_id)); 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("message.part.updated", &part)); + state.opencode.emit_event(part_event_with_delta( + "message.part.updated", + &part, + Some(&delta), + )); let _ = state .opencode .update_runtime(&session_id, |runtime| { @@ -2238,9 +2629,10 @@ pub fn build_opencode_router(state: Arc) -> Router { tag = "opencode" )] async fn oc_agent_list(State(state): State>) -> impl IntoResponse { + let name = state.inner.branding.product_name(); let agent = json!({ - "name": OPENCODE_PROVIDER_NAME, - "description": "Sandbox Agent compatibility layer", + "name": name, + "description": format!("{name} compatibility layer"), "mode": "all", "native": false, "hidden": false, @@ -2287,26 +2679,46 @@ async fn oc_config_patch(Json(body): Json) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_config_providers() -> impl IntoResponse { - let mut models = serde_json::Map::new(); - for agent in available_agent_ids() { - models.insert(agent.as_str().to_string(), model_config_entry(agent)); +async fn oc_config_providers(State(state): State>) -> impl IntoResponse { + let cache = opencode_model_cache(&state).await; + let mut grouped: BTreeMap> = BTreeMap::new(); + for entry in &cache.entries { + grouped + .entry(entry.group_id.clone()) + .or_default() + .push(entry); + } + for group_id in cache.group_names.keys() { + grouped.entry(group_id.clone()).or_default(); + } + let mut providers = Vec::new(); + let mut defaults = serde_json::Map::new(); + for (group_id, entries) in grouped { + let mut models = serde_json::Map::new(); + for entry in entries { + models.insert(entry.model.id.clone(), model_config_entry(entry)); + } + let name = cache + .group_names + .get(&group_id) + .cloned() + .unwrap_or_else(|| group_id.clone()); + providers.push(json!({ + "id": group_id, + "name": name, + "source": "custom", + "env": [], + "key": "", + "options": {}, + "models": Value::Object(models), + })); + if let Some(default_model) = cache.group_defaults.get(&group_id) { + defaults.insert(group_id, Value::String(default_model.clone())); + } } let providers = json!({ - "providers": [ - { - "id": OPENCODE_PROVIDER_ID, - "name": OPENCODE_PROVIDER_NAME, - "source": "custom", - "env": [], - "key": "", - "options": {}, - "models": Value::Object(models), - } - ], - "default": { - OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID - } + "providers": providers, + "default": Value::Object(defaults), }); (StatusCode::OK, Json(providers)) } @@ -2957,6 +3369,9 @@ async fn oc_session_message_create( .and_then(|v| v.as_str()); 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 backing_variant = body.variant.clone(); let parts_input = body.parts.unwrap_or_default(); if parts_input.is_empty() { @@ -3020,7 +3435,15 @@ async fn oc_session_message_create( }) .await; - if let Err(err) = ensure_backing_session(&state, &session_id, &session_agent).await { + if let Err(err) = ensure_backing_session( + &state, + &session_id, + &session_agent, + backing_model, + backing_variant, + ) + .await + { tracing::warn!( target = "sandbox_agent::opencode", ?err, @@ -3226,7 +3649,7 @@ async fn oc_session_command( &directory, &worktree, &agent, - OPENCODE_PROVIDER_ID, + OPENCODE_DEFAULT_PROVIDER_ID, OPENCODE_DEFAULT_MODEL_ID, ); @@ -3276,7 +3699,7 @@ async fn oc_session_shell( .as_ref() .and_then(|v| v.get("providerID")) .and_then(|v| v.as_str()) - .unwrap_or(OPENCODE_PROVIDER_ID), + .unwrap_or(OPENCODE_DEFAULT_PROVIDER_ID), body.model .as_ref() .and_then(|v| v.get("modelID")) @@ -3584,24 +4007,46 @@ async fn oc_question_reject( responses((status = 200)), tag = "opencode" )] -async fn oc_provider_list() -> impl IntoResponse { - let mut models = serde_json::Map::new(); - for agent in available_agent_ids() { - models.insert(agent.as_str().to_string(), model_summary_entry(agent)); +async fn oc_provider_list(State(state): State>) -> impl IntoResponse { + let cache = opencode_model_cache(&state).await; + let mut grouped: BTreeMap> = BTreeMap::new(); + for entry in &cache.entries { + grouped + .entry(entry.group_id.clone()) + .or_default() + .push(entry); + } + for group_id in cache.group_names.keys() { + grouped.entry(group_id.clone()).or_default(); + } + 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 { + models.insert(entry.model.id.clone(), model_summary_entry(entry)); + } + let name = cache + .group_names + .get(&group_id) + .cloned() + .unwrap_or_else(|| group_id.clone()); + providers.push(json!({ + "id": group_id, + "name": name, + "env": [], + "models": Value::Object(models), + })); + 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); } let providers = json!({ - "all": [ - { - "id": OPENCODE_PROVIDER_ID, - "name": OPENCODE_PROVIDER_NAME, - "env": [], - "models": Value::Object(models), - } - ], - "default": { - OPENCODE_PROVIDER_ID: OPENCODE_DEFAULT_MODEL_ID - }, - "connected": [OPENCODE_PROVIDER_ID] + "all": providers, + "default": Value::Object(defaults), + "connected": connected }); (StatusCode::OK, Json(providers)) } @@ -3612,11 +4057,13 @@ async fn oc_provider_list() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_provider_auth() -> impl IntoResponse { - let auth = json!({ - OPENCODE_PROVIDER_ID: [] - }); - (StatusCode::OK, Json(auth)) +async fn oc_provider_auth(State(state): State>) -> impl IntoResponse { + let cache = opencode_model_cache(&state).await; + let mut auth_map = serde_json::Map::new(); + for group_id in cache.group_names.keys() { + auth_map.insert(group_id.clone(), json!([])); + } + (StatusCode::OK, Json(Value::Object(auth_map))) } #[utoipa::path( diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index db3d3dd..8ab2187 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -1,10 +1,10 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::convert::Infallible; -use std::io::{BufRead, BufReader, Read, Write}; +use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; use std::path::PathBuf; use std::process::Stdio; -use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; @@ -22,12 +22,11 @@ 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, - 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, + convert_pi, pi as pi_schema, turn_completed_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, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -42,26 +41,62 @@ 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, }; use sandbox_agent_agent_management::credentials::{ - extract_all_credentials, CredentialExtractionOptions, ExtractedCredentials, + extract_all_credentials, AuthType, CredentialExtractionOptions, ExtractedCredentials, + ProviderCredentials, }; 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"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum BrandingMode { + #[default] + SandboxAgent, + Gigacode, +} + +impl BrandingMode { + pub fn product_name(&self) -> &'static str { + match self { + BrandingMode::SandboxAgent => "Sandbox Agent", + BrandingMode::Gigacode => "Gigacode", + } + } + + pub fn docs_url(&self) -> &'static str { + match self { + BrandingMode::SandboxAgent => "https://sandboxagent.dev", + BrandingMode::Gigacode => "https://gigacode.dev", + } + } +} #[derive(Debug)] pub struct AppState { auth: AuthConfig, agent_manager: Arc, session_manager: Arc, + pub branding: BrandingMode, } impl AppState { pub fn new(auth: AuthConfig, agent_manager: AgentManager) -> Self { + Self::with_branding(auth, agent_manager, BrandingMode::default()) + } + + pub fn with_branding( + auth: AuthConfig, + agent_manager: AgentManager, + branding: BrandingMode, + ) -> Self { let agent_manager = Arc::new(agent_manager); let session_manager = Arc::new(SessionManager::new(agent_manager.clone())); session_manager @@ -71,6 +106,7 @@ impl AppState { auth, agent_manager, session_manager, + branding, } } @@ -104,6 +140,7 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) .route("/agents", get(list_agents)) .route("/agents/:agent/install", post(install_agent)) .route("/agents/:agent/modes", get(get_agent_modes)) + .route("/agents/:agent/models", get(get_agent_models)) .route("/sessions", get(list_sessions)) .route("/sessions/:session_id", post(create_session)) .route("/sessions/:session_id/messages", post(post_message)) @@ -149,12 +186,15 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) )); } - let mut router = Router::new() + let root_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) - .fallback(not_found); + .merge(opencode_root_router); if ui::is_enabled() { router = router.merge(ui::router()); @@ -210,7 +250,7 @@ pub fn build_router_with_state(shared: Arc) -> (Router, Arc) } pub async fn shutdown_servers(state: &Arc) { - state.session_manager.server_manager.shutdown().await; + state.session_manager.shutdown().await; } #[derive(OpenApi)] @@ -219,6 +259,7 @@ pub async fn shutdown_servers(state: &Arc) { get_health, install_agent, get_agent_modes, + get_agent_models, list_agents, list_sessions, create_session, @@ -236,6 +277,8 @@ pub async fn shutdown_servers(state: &Arc) { AgentInstallRequest, AgentModeInfo, AgentModesResponse, + AgentModelInfo, + AgentModelsResponse, AgentCapabilities, AgentInfo, AgentListResponse, @@ -325,6 +368,7 @@ struct SessionState { model: Option, variant: Option, native_session_id: Option, + pi_runtime: Option>, ended: bool, ended_exit_code: Option, ended_message: Option, @@ -383,6 +427,7 @@ impl SessionState { model: request.model.clone(), variant: request.variant.clone(), native_session_id: None, + pi_runtime: None, ended: false, ended_exit_code: None, ended_message: None, @@ -786,7 +831,6 @@ impl SessionState { enum ManagedServerKind { Http { base_url: String }, StdioCodex { server: Arc }, - StdioPi { server: Arc }, } #[derive(Debug)] @@ -919,93 +963,49 @@ impl CodexServer { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PiRpcDialect { - Mono, - CodingAgent, -} - -fn detect_pi_dialect(path: &PathBuf) -> PiRpcDialect { - let mut file = match std::fs::File::open(path) { - Ok(file) => file, - Err(_) => return PiRpcDialect::Mono, - }; - let mut buffer = [0u8; 256]; - let read = match file.read(&mut buffer) { - Ok(read) => read, - Err(_) => return PiRpcDialect::Mono, - }; - if read == 0 { - return PiRpcDialect::Mono; - } - let sample = &buffer[..read]; - if sample.starts_with(b"#!") { - return PiRpcDialect::CodingAgent; - } - if sample.iter().any(|byte| *byte == 0) { - return PiRpcDialect::Mono; - } - let is_text = sample.iter().all(|byte| byte.is_ascii()); - if is_text { - return PiRpcDialect::CodingAgent; - } - PiRpcDialect::Mono -} - -/// Shared Pi RPC process that multiplexes sessions via newline-delimited JSON. -struct PiServer { - /// Sender for writing to the process stdin +/// Long-lived Pi RPC process dedicated to exactly one daemon session. +struct PiSessionRuntime { + /// Sender for writing to the process stdin. stdin_sender: mpsc::UnboundedSender, - /// Pending RPC requests awaiting responses, keyed by request ID + /// Pending RPC requests awaiting responses, keyed by request ID. pending_requests: std::sync::Mutex>>, - /// Next request ID for RPC + /// Next request ID for RPC. next_id: AtomicI64, - /// Mapping from native session ID to daemon session ID - session_map: std::sync::Mutex>, - /// Per-session conversion state (partial tool results, reasoning buffers) - converters: std::sync::Mutex>, - /// RPC dialect used by the Pi binary - dialect: PiRpcDialect, - /// Current daemon session id for coding-agent (no session id in events) - current_session_id: std::sync::Mutex>, - /// Current native session id for coding-agent (for metadata) - current_native_session_id: std::sync::Mutex>, + /// Per-session conversion state (partial tool results, reasoning buffers). + converter: std::sync::Mutex, + /// Child process handle for lifecycle management. + child: Arc>>, + /// True when daemon-initiated shutdown/terminate requested. + shutdown_requested: AtomicBool, } -impl std::fmt::Debug for PiServer { +impl std::fmt::Debug for PiSessionRuntime { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PiServer") + f.debug_struct("PiSessionRuntime") .field("next_id", &self.next_id.load(Ordering::SeqCst)) + .field( + "shutdown_requested", + &self.shutdown_requested.load(Ordering::SeqCst), + ) .finish() } } -impl PiServer { - fn new(stdin_sender: mpsc::UnboundedSender, dialect: PiRpcDialect) -> Self { +impl PiSessionRuntime { + fn new( + stdin_sender: mpsc::UnboundedSender, + child: Arc>>, + ) -> Self { Self { stdin_sender, pending_requests: std::sync::Mutex::new(HashMap::new()), next_id: AtomicI64::new(1), - session_map: std::sync::Mutex::new(HashMap::new()), - converters: std::sync::Mutex::new(HashMap::new()), - dialect, - current_session_id: std::sync::Mutex::new(None), - current_native_session_id: std::sync::Mutex::new(None), + converter: std::sync::Mutex::new(convert_pi::PiEventConverter::default()), + child, + shutdown_requested: AtomicBool::new(false), } } - fn dialect(&self) -> PiRpcDialect { - self.dialect - } - - fn current_session_id(&self) -> Option { - self.current_session_id.lock().unwrap().clone() - } - - fn current_native_session_id(&self) -> Option { - self.current_native_session_id.lock().unwrap().clone() - } - fn next_request_id(&self) -> i64 { self.next_id.fetch_add(1, Ordering::SeqCst) } @@ -1031,51 +1031,39 @@ impl PiServer { } } - fn register_session(&self, native_session_id: String, session_id: String) { - let mut sessions = self.session_map.lock().unwrap(); - sessions.insert(native_session_id.clone(), session_id.clone()); - let mut converters = self.converters.lock().unwrap(); - converters - .entry(native_session_id.clone()) - .or_insert_with(convert_pi::PiEventConverter::default); - if self.dialect == PiRpcDialect::CodingAgent { - *self.current_session_id.lock().unwrap() = Some(session_id); - *self.current_native_session_id.lock().unwrap() = Some(native_session_id); - } - } - - fn unregister_session(&self, native_session_id: &str) { - let mut sessions = self.session_map.lock().unwrap(); - sessions.remove(native_session_id); - let mut converters = self.converters.lock().unwrap(); - converters.remove(native_session_id); - if self.dialect == PiRpcDialect::CodingAgent { - let current_native = self.current_native_session_id.lock().unwrap().clone(); - if current_native.as_deref() == Some(native_session_id) { - *self.current_session_id.lock().unwrap() = None; - *self.current_native_session_id.lock().unwrap() = None; - } - } - } - - fn session_for_native(&self, native_session_id: &str) -> Option { - let sessions = self.session_map.lock().unwrap(); - sessions.get(native_session_id).cloned() - } - fn clear_pending(&self) { let mut pending = self.pending_requests.lock().unwrap(); pending.clear(); } - fn clear_sessions(&self) { - let mut sessions = self.session_map.lock().unwrap(); - sessions.clear(); - let mut converters = self.converters.lock().unwrap(); - converters.clear(); - *self.current_session_id.lock().unwrap() = None; - *self.current_native_session_id.lock().unwrap() = None; + fn mark_shutdown_requested(&self) { + self.shutdown_requested.store(true, Ordering::SeqCst); } + + fn shutdown_requested(&self) -> bool { + self.shutdown_requested.load(Ordering::SeqCst) + } + + fn kill_process(&self) { + if let Ok(mut guard) = self.child.lock() { + if let Some(child) = guard.as_mut() { + let _ = child.kill(); + } + } + } + + fn shutdown(&self) { + self.mark_shutdown_requested(); + self.clear_pending(); + self.kill_process(); + } +} + +#[derive(Debug)] +struct PiSessionBootstrap { + runtime: Arc, + native_session_id: String, + _session_file: Option, } pub(crate) struct SessionSubscription { @@ -1103,7 +1091,7 @@ impl ManagedServer { fn base_url(&self) -> Option { match &self.kind { ManagedServerKind::Http { base_url } => Some(base_url.clone()), - ManagedServerKind::StdioCodex { .. } | ManagedServerKind::StdioPi { .. } => None, + ManagedServerKind::StdioCodex { .. } => None, } } @@ -1203,24 +1191,6 @@ impl AgentServerManager { let mut natives = self.native_sessions.lock().await; natives.remove(&agent); } - - if agent == AgentId::Pi { - if let Some(native_session_id) = native_session_id { - let server = { - let servers = self.servers.lock().await; - servers.get(&AgentId::Pi).and_then(|server| { - if let ManagedServerKind::StdioPi { server } = &server.kind { - Some(server.clone()) - } else { - None - } - }) - }; - if let Some(server) = server { - server.unregister_session(native_session_id); - } - } - } } async fn clear_mappings(&self, agent: AgentId) { @@ -1367,65 +1337,6 @@ impl AgentServerManager { Ok((server, Some(stdout_rx))) } - async fn ensure_pi_server( - self: &Arc, - ) -> Result<(Arc, Option>), SandboxError> { - { - let servers = self.servers.lock().await; - if let Some(server) = servers.get(&AgentId::Pi) { - if matches!(server.status, ServerStatus::Running) { - if let ManagedServerKind::StdioPi { server } = &server.kind { - return Ok((server.clone(), None)); - } - } - } - } - - let (server, stdout_rx, child) = self.spawn_pi_server().await?; - let restart_count = { - let servers = self.servers.lock().await; - servers - .get(&AgentId::Pi) - .map(|server| server.restart_count + 1) - .unwrap_or(0) - }; - - { - let mut servers = self.servers.lock().await; - if let Some(existing) = servers.get(&AgentId::Pi) { - if matches!(existing.status, ServerStatus::Running) { - if let Ok(mut guard) = child.lock() { - if let Some(child) = guard.as_mut() { - let _ = child.kill(); - } - } - if let ManagedServerKind::StdioPi { server } = &existing.kind { - return Ok((server.clone(), None)); - } - } - } - servers.insert( - AgentId::Pi, - ManagedServer { - kind: ManagedServerKind::StdioPi { - server: server.clone(), - }, - child: child.clone(), - status: ServerStatus::Running, - start_time: Some(Instant::now()), - restart_count, - last_error: None, - shutdown_requested: false, - instance_id: restart_count, - }, - ); - } - - self.spawn_monitor_task(AgentId::Pi, restart_count, child); - - Ok((server, Some(stdout_rx))) - } - async fn shutdown(&self) { let mut servers = self.servers.lock().await; for server in servers.values_mut() { @@ -1441,10 +1352,6 @@ impl AgentServerManager { server.clear_pending(); server.clear_threads(); } - if let ManagedServerKind::StdioPi { server } = &server.kind { - server.clear_pending(); - server.clear_sessions(); - } } } @@ -1586,95 +1493,6 @@ impl AgentServerManager { )) } - async fn spawn_pi_server( - self: &Arc, - ) -> Result< - ( - Arc, - mpsc::UnboundedReceiver, - Arc>>, - ), - SandboxError, - > { - let manager = self.agent_manager.clone(); - let log_dir = self.log_base_dir.clone(); - let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::(); - let (stdout_tx, stdout_rx) = mpsc::unbounded_channel::(); - - let child = tokio::task::spawn_blocking( - move || -> Result<(std::process::Child, PiRpcDialect), SandboxError> { - let path = manager - .resolve_binary(AgentId::Pi) - .map_err(|err| map_spawn_error(AgentId::Pi, err))?; - let dialect = detect_pi_dialect(&path); - let mut command = std::process::Command::new(path); - let stderr = AgentServerLogs::new(log_dir, AgentId::Pi.as_str()).open()?; - command - .arg("--mode") - .arg("rpc") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(stderr); - - let mut child = command.spawn().map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })?; - - let stdin = child - .stdin - .take() - .ok_or_else(|| SandboxError::StreamError { - message: "pi stdin unavailable".to_string(), - })?; - let stdout = child - .stdout - .take() - .ok_or_else(|| SandboxError::StreamError { - message: "pi stdout unavailable".to_string(), - })?; - - let stdin_rx_mut = std::sync::Mutex::new(stdin_rx); - std::thread::spawn(move || { - let mut stdin = stdin; - let mut rx = stdin_rx_mut.lock().unwrap(); - while let Some(line) = rx.blocking_recv() { - if writeln!(stdin, "{line}").is_err() { - break; - } - if stdin.flush().is_err() { - break; - } - } - }); - - std::thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines() { - let Ok(line) = line else { break }; - if stdout_tx.send(line).is_err() { - break; - } - } - }); - - Ok((child, dialect)) - }, - ) - .await - .map_err(|err| SandboxError::StreamError { - message: err.to_string(), - })??; - - let (child, dialect) = child; - let server = Arc::new(PiServer::new(stdin_tx, dialect)); - - Ok(( - server, - stdout_rx, - Arc::new(std::sync::Mutex::new(Some(child))), - )) - } - fn spawn_monitor_task( self: &Arc, agent: AgentId, @@ -1719,7 +1537,6 @@ impl AgentServerManager { let exit_code = status.code(); let message = format!("agent server exited with status {:?}", status); let mut codex_server = None; - let mut pi_server = None; let mut shutdown_requested = false; { let mut servers = self.servers.lock().await; @@ -1743,9 +1560,6 @@ impl AgentServerManager { if let ManagedServerKind::StdioCodex { server } = &server.kind { codex_server = Some(server.clone()); } - if let ManagedServerKind::StdioPi { server } = &server.kind { - pi_server = Some(server.clone()); - } } } @@ -1753,10 +1567,6 @@ impl AgentServerManager { server.clear_pending(); server.clear_threads(); } - if let Some(server) = pi_server { - server.clear_pending(); - server.clear_sessions(); - } if shutdown_requested { self.clear_mappings(agent).await; @@ -1805,21 +1615,6 @@ impl AgentServerManager { } } } - AgentId::Pi => { - let (server, receiver) = self.ensure_pi_server().await?; - if let Some(stdout_rx) = receiver { - let owner = self.owner.lock().expect("owner lock").clone(); - if let Some(owner) = owner.as_ref().and_then(|weak| weak.upgrade()) { - let owner_clone = owner.clone(); - let server_clone = server.clone(); - tokio::spawn(async move { - owner_clone - .handle_pi_server_output(server_clone, stdout_rx) - .await; - }); - } - } - } _ => {} } Ok(()) @@ -1920,6 +1715,20 @@ impl SessionManager { logs.read_stderr() } + async fn shutdown(&self) { + let runtimes = { + let mut sessions = self.sessions.lock().await; + sessions + .iter_mut() + .filter_map(|session| session.pi_runtime.take()) + .collect::>() + }; + for runtime in runtimes { + runtime.shutdown(); + } + self.server_manager.shutdown().await; + } + pub(crate) async fn create_session( self: &Arc, session_id: String, @@ -1977,22 +1786,19 @@ impl SessionManager { session.native_session_id = Some(thread_id); } if agent_id == AgentId::Pi { - let snapshot = SessionSnapshot { - session_id: session_id.clone(), - agent: agent_id, - agent_mode: session.agent_mode.clone(), - permission_mode: session.permission_mode.clone(), - model: session.model.clone(), - variant: session.variant.clone(), - native_session_id: None, - }; - let native_id = self.create_pi_session(&session_id, &snapshot).await?; - session.native_session_id = Some(native_id); + let pi = self + .create_pi_session(&session_id, session.model.as_deref()) + .await?; + session.native_session_id = Some(pi.native_session_id); + session.pi_runtime = Some(pi.runtime); } if agent_id == AgentId::Mock { 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, @@ -2022,10 +1828,12 @@ 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); - if agent_id == AgentId::Opencode || agent_id == AgentId::Codex || agent_id == AgentId::Pi { + if agent_id == AgentId::Opencode || agent_id == AgentId::Codex { self.server_manager .register_session(agent_id, &session_id, native_session_id.as_deref()) .await; @@ -2035,6 +1843,14 @@ 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, @@ -2042,6 +1858,27 @@ impl SessionManager { }) } + pub(crate) async fn set_session_overrides( + &self, + session_id: &str, + model: Option, + variant: Option, + ) -> Result<(), SandboxError> { + let mut sessions = self.sessions.lock().await; + let Some(session) = SessionManager::session_mut(&mut sessions, session_id) else { + return Err(SandboxError::SessionNotFound { + session_id: session_id.to_string(), + }); + }; + if let Some(model) = model { + session.model = Some(model); + } + if let Some(variant) = variant { + session.variant = Some(variant); + } + Ok(()) + } + async fn agent_modes(&self, agent: AgentId) -> Result, SandboxError> { if agent != AgentId::Opencode { return Ok(agent_modes_for(agent)); @@ -2060,6 +1897,32 @@ impl SessionManager { } } + pub(crate) async fn agent_models( + self: &Arc, + agent: AgentId, + ) -> Result { + match agent { + AgentId::Claude => self.fetch_claude_models().await, + AgentId::Codex => self.fetch_codex_models().await, + AgentId::Opencode => match self.fetch_opencode_models().await { + Ok(models) => Ok(models), + Err(_) => Ok(AgentModelsResponse { + models: Vec::new(), + default_model: None, + }), + }, + AgentId::Amp => Ok(amp_models_response()), + AgentId::Pi => match self.fetch_pi_models().await { + Ok(models) => Ok(models), + Err(_) => Ok(AgentModelsResponse { + models: Vec::new(), + default_model: None, + }), + }, + AgentId::Mock => Ok(mock_models_response()), + } + } + pub(crate) async fn send_message( self: &Arc, session_id: String, @@ -2217,8 +2080,12 @@ impl SessionManager { session.record_conversions(vec![ended]); let agent = session.agent; let native_session_id = session.native_session_id.clone(); + let pi_runtime = session.pi_runtime.take(); drop(sessions); - if agent == AgentId::Opencode || agent == AgentId::Codex || agent == AgentId::Pi { + if let Some(runtime) = pi_runtime { + runtime.shutdown(); + } + if agent == AgentId::Opencode || agent == AgentId::Codex { self.server_manager .unregister_session(agent, &session_id, native_session_id.as_deref()) .await; @@ -3524,7 +3391,7 @@ impl SessionManager { approval_policy: codex_approval_policy(Some(&session.permission_mode)), collaboration_mode: None, cwd: None, - effort: None, + effort: codex_effort_from_variant(session.variant.as_deref()), input: vec![codex_schema::UserInput::Text { text: prompt_text, text_elements: Vec::new(), @@ -3551,27 +3418,171 @@ impl SessionManager { Ok(()) } - /// Ensures a shared Pi RPC process is running. - async fn ensure_pi_server(self: &Arc) -> Result, SandboxError> { - let (server, receiver) = self.server_manager.ensure_pi_server().await?; - - if let Some(stdout_rx) = receiver { - let server_for_task = server.clone(); - let self_for_task = Arc::clone(self); - tokio::spawn(async move { - self_for_task - .handle_pi_server_output(server_for_task, stdout_rx) - .await; - }); + fn apply_pi_model_args(command: &mut std::process::Command, model: Option<&str>) { + let Some(model) = model else { + return; + }; + if let Some((provider, model_id)) = model.split_once('/') { + command + .arg("--provider") + .arg(provider) + .arg("--model") + .arg(model_id); + return; } - - Ok(server) + command.arg("--model").arg(model); } - /// Handles output from the Pi RPC server, routing responses and events. - async fn handle_pi_server_output( + async fn spawn_pi_runtime( + self: &Arc, + model: Option<&str>, + ) -> Result<(Arc, mpsc::UnboundedReceiver), SandboxError> { + let manager = self.agent_manager.clone(); + let log_dir = self.server_manager.log_base_dir.clone(); + let model = model.map(str::to_string); + let (stdin_tx, stdin_rx) = mpsc::unbounded_channel::(); + let (stdout_tx, stdout_rx) = mpsc::unbounded_channel::(); + + let child = + tokio::task::spawn_blocking(move || -> Result { + let path = manager + .resolve_binary(AgentId::Pi) + .map_err(|err| map_spawn_error(AgentId::Pi, err))?; + let mut command = std::process::Command::new(path); + let stderr = AgentServerLogs::new(log_dir, AgentId::Pi.as_str()).open()?; + command.arg("--mode").arg("rpc"); + Self::apply_pi_model_args(&mut command, model.as_deref()); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(stderr); + + let mut child = command.spawn().map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| SandboxError::StreamError { + message: "pi stdin unavailable".to_string(), + })?; + let stdout = child + .stdout + .take() + .ok_or_else(|| SandboxError::StreamError { + message: "pi stdout unavailable".to_string(), + })?; + + let stdin_rx_mut = std::sync::Mutex::new(stdin_rx); + std::thread::spawn(move || { + let mut stdin = stdin; + let mut rx = stdin_rx_mut.lock().unwrap(); + while let Some(line) = rx.blocking_recv() { + if writeln!(stdin, "{line}").is_err() { + break; + } + if stdin.flush().is_err() { + break; + } + } + }); + + std::thread::spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + let Ok(line) = line else { break }; + if stdout_tx.send(line).is_err() { + break; + } + } + }); + + Ok(child) + }) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })??; + + let child = Arc::new(std::sync::Mutex::new(Some(child))); + let runtime = Arc::new(PiSessionRuntime::new(stdin_tx, child)); + Ok((runtime, stdout_rx)) + } + + fn spawn_pi_runtime_tasks( + self: &Arc, + session_id: String, + runtime: Arc, + stdout_rx: mpsc::UnboundedReceiver, + ) { + let output_manager = Arc::clone(self); + let output_session_id = session_id.clone(); + let output_runtime = runtime.clone(); + tokio::spawn(async move { + output_manager + .handle_pi_runtime_output(output_session_id, output_runtime, stdout_rx) + .await; + }); + + let exit_manager = Arc::clone(self); + tokio::spawn(async move { + loop { + let status = { + let mut guard = match runtime.child.lock() { + Ok(guard) => guard, + Err(_) => return, + }; + match guard.as_mut() { + Some(child) => match child.try_wait() { + Ok(status) => status, + Err(_) => None, + }, + None => return, + } + }; + + if let Some(status) = status { + if let Ok(mut guard) = runtime.child.lock() { + *guard = None; + } + exit_manager + .handle_pi_runtime_exit(&session_id, runtime.clone(), status) + .await; + break; + } + + sleep(Duration::from_millis(250)).await; + } + }); + } + + async fn is_active_pi_runtime( + &self, + session_id: &str, + runtime: &Arc, + ) -> bool { + let sessions = self.sessions.lock().await; + let Some(session) = Self::session_ref(&sessions, session_id) else { + return false; + }; + session + .pi_runtime + .as_ref() + .is_some_and(|active| Arc::ptr_eq(active, runtime)) + } + + async fn session_native_session_id(&self, session_id: &str) -> Option { + let sessions = self.sessions.lock().await; + Self::session_ref(&sessions, session_id) + .and_then(|session| session.native_session_id.clone()) + } + + /// Handles output from one Pi runtime process and routes events to exactly one daemon session. + async fn handle_pi_runtime_output( self: Arc, - server: Arc, + session_id: String, + runtime: Arc, mut stdout_rx: mpsc::UnboundedReceiver, ) { while let Some(line) = stdout_rx.recv().await { @@ -3583,8 +3594,14 @@ impl SessionManager { let value: Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(err) => { - self.record_pi_unparsed(None, &err.to_string(), Value::String(trimmed.to_string())) + if self.is_active_pi_runtime(&session_id, &runtime).await { + self.record_pi_unparsed( + &session_id, + &err.to_string(), + Value::String(trimmed.to_string()), + ) .await; + } continue; } }; @@ -3596,67 +3613,39 @@ impl SessionManager { .and_then(Value::as_i64) .or_else(|| value.get("id").and_then(Value::as_str)?.parse::().ok()); if let Some(id) = id { - server.complete_request(id, value.clone()); + runtime.complete_request(id, value.clone()); } continue; } - let native_session_id = extract_pi_session_id(&value).or_else(|| { - if server.dialect() == PiRpcDialect::CodingAgent { - server.current_native_session_id() - } else { - None - } - }); - let session_id = native_session_id - .as_ref() - .and_then(|id| server.session_for_native(id)) - .or_else(|| { - if server.dialect() == PiRpcDialect::CodingAgent { - server.current_session_id() - } else { - None - } - }); - let Some(session_id) = session_id else { - self.record_pi_unparsed( - None, - "pi event missing session id", - value.clone(), - ) - .await; + if !self.is_active_pi_runtime(&session_id, &runtime).await { continue; - }; + } let event: pi_schema::RpcEvent = match serde_json::from_value(value.clone()) { Ok(event) => event, Err(err) => { - self.record_pi_unparsed(Some(session_id.clone()), &err.to_string(), value.clone()) + self.record_pi_unparsed(&session_id, &err.to_string(), value.clone()) .await; continue; } }; let conversions = { - let mut converters = server.converters.lock().unwrap(); - let key = native_session_id - .clone() - .unwrap_or_else(|| session_id.clone()); - let converter = converters - .entry(key) - .or_insert_with(convert_pi::PiEventConverter::default); + let mut converter = runtime.converter.lock().unwrap(); converter.event_to_universal(&event) }; let mut conversions = match conversions { Ok(conversions) => conversions, Err(err) => { - self.record_pi_unparsed(Some(session_id.clone()), &err, value.clone()) + self.record_pi_unparsed(&session_id, &err, value.clone()) .await; continue; } }; + let native_session_id = self.session_native_session_id(&session_id).await; for conversion in &mut conversions { if conversion.native_session_id.is_none() { conversion.native_session_id = native_session_id.clone(); @@ -3668,129 +3657,168 @@ impl SessionManager { } } + async fn handle_pi_runtime_exit( + &self, + session_id: &str, + runtime: Arc, + status: std::process::ExitStatus, + ) { + runtime.clear_pending(); + + let should_emit_error = { + let mut sessions = self.sessions.lock().await; + let Some(session) = Self::session_mut(&mut sessions, session_id) else { + return; + }; + let Some(active_runtime) = session.pi_runtime.as_ref() else { + return; + }; + if !Arc::ptr_eq(active_runtime, &runtime) { + return; + } + session.pi_runtime = None; + !runtime.shutdown_requested() && !session.ended + }; + + if !should_emit_error { + return; + } + + let message = format!("pi rpc process exited with status {:?}", status); + self.record_error( + session_id, + message.clone(), + Some("server_exit".to_string()), + None, + ) + .await; + let logs = self.read_agent_stderr(AgentId::Pi); + self.mark_session_ended( + session_id, + status.code(), + &message, + SessionEndReason::Error, + TerminatedBy::Daemon, + logs, + ) + .await; + } + async fn create_pi_session( self: &Arc, session_id: &str, - _session: &SessionSnapshot, - ) -> Result { - let server = self.ensure_pi_server().await?; - if server.dialect() == PiRpcDialect::CodingAgent && server.current_session_id().is_some() { - return Err(SandboxError::InvalidRequest { - message: - "pi-coding-agent supports a single active session; terminate it before creating a new session" - .to_string(), - }); - } + model: Option<&str>, + ) -> Result { + let (runtime, stdout_rx) = self.spawn_pi_runtime(model).await?; + self.spawn_pi_runtime_tasks(session_id.to_string(), runtime.clone(), stdout_rx); - let id = server.next_request_id(); - let request = match server.dialect() { - PiRpcDialect::Mono => json!({ - "type": "command", - "id": id, - "command": "new_session", - "params": { "sessionName": session_id } - }), - PiRpcDialect::CodingAgent => json!({ + let result: Result = async { + let new_id = runtime.next_request_id(); + let new_request = json!({ "type": "new_session", - "id": id - }), - }; - - let rx = server - .send_request(id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send pi new_session request".to_string(), - })?; - - let result = tokio::time::timeout(Duration::from_secs(30), rx).await; - match result { - Ok(Ok(response)) => { - if response - .get("success") - .and_then(Value::as_bool) - .is_some_and(|success| !success) - { - return Err(SandboxError::StreamError { - message: format!("pi new_session failed: {response}"), - }); + "id": new_id + }); + let new_rx = runtime.send_request(new_id, &new_request).ok_or_else(|| { + SandboxError::StreamError { + message: "failed to send pi new_session request".to_string(), } - if response - .get("data") - .and_then(|value| value.get("cancelled")) - .and_then(Value::as_bool) - .is_some_and(|cancelled| cancelled) - { + })?; + let new_response = tokio::time::timeout(Duration::from_secs(30), new_rx).await; + let new_response = match new_response { + Ok(Ok(response)) => response, + Ok(Err(_)) => { return Err(SandboxError::StreamError { message: "pi new_session request cancelled".to_string(), - }); + }) } - - let native_session_id = if server.dialect() == PiRpcDialect::CodingAgent { - let state_id = server.next_request_id(); - let request = json!({ - "type": "get_state", - "id": state_id - }); - let rx = server - .send_request(state_id, &request) - .ok_or_else(|| SandboxError::StreamError { - message: "failed to send pi get_state request".to_string(), - })?; - let result = tokio::time::timeout(Duration::from_secs(30), rx).await; - let response = match result { - Ok(Ok(response)) => response, - Ok(Err(_)) => { - return Err(SandboxError::StreamError { - message: "pi get_state request cancelled".to_string(), - }) - } - Err(_) => { - return Err(SandboxError::StreamError { - message: "pi get_state request timed out".to_string(), - }) - } - }; - if response - .get("success") - .and_then(Value::as_bool) - .is_some_and(|success| !success) - { - return Err(SandboxError::StreamError { - message: format!("pi get_state failed: {response}"), - }); - } - let session_value = response.get("data").unwrap_or(&response); - session_value - .get("sessionId") - .or_else(|| session_value.get("session_id")) - .and_then(Value::as_str) - .ok_or_else(|| SandboxError::StreamError { - message: "pi get_state response missing session id".to_string(), - })? - .to_string() - } else { - let session_value = response.get("data").unwrap_or(&response); - session_value - .get("sessionId") - .or_else(|| session_value.get("session_id")) - .and_then(Value::as_str) - .ok_or_else(|| SandboxError::StreamError { - message: "pi new_session response missing session id".to_string(), - })? - .to_string() - }; - - server.register_session(native_session_id.clone(), session_id.to_string()); - - Ok(native_session_id) + Err(_) => { + return Err(SandboxError::StreamError { + message: "pi new_session request timed out".to_string(), + }) + } + }; + if new_response + .get("success") + .and_then(Value::as_bool) + .is_some_and(|success| !success) + { + return Err(SandboxError::StreamError { + message: format!("pi new_session failed: {new_response}"), + }); } - Ok(Err(_)) => Err(SandboxError::StreamError { - message: "pi new_session request cancelled".to_string(), - }), - Err(_) => Err(SandboxError::StreamError { - message: "pi new_session request timed out".to_string(), - }), + if new_response + .get("data") + .and_then(|value| value.get("cancelled")) + .and_then(Value::as_bool) + .is_some_and(|cancelled| cancelled) + { + return Err(SandboxError::StreamError { + message: "pi new_session request cancelled".to_string(), + }); + } + + let state_id = runtime.next_request_id(); + let state_request = json!({ + "type": "get_state", + "id": state_id + }); + let state_rx = runtime + .send_request(state_id, &state_request) + .ok_or_else(|| SandboxError::StreamError { + message: "failed to send pi get_state request".to_string(), + })?; + let state_response = tokio::time::timeout(Duration::from_secs(30), state_rx).await; + let state_response = match state_response { + Ok(Ok(response)) => response, + Ok(Err(_)) => { + return Err(SandboxError::StreamError { + message: "pi get_state request cancelled".to_string(), + }) + } + Err(_) => { + return Err(SandboxError::StreamError { + message: "pi get_state request timed out".to_string(), + }) + } + }; + if state_response + .get("success") + .and_then(Value::as_bool) + .is_some_and(|success| !success) + { + return Err(SandboxError::StreamError { + message: format!("pi get_state failed: {state_response}"), + }); + } + + let state = state_response.get("data").unwrap_or(&state_response); + let native_session_id = state + .get("sessionId") + .or_else(|| state.get("session_id")) + .and_then(Value::as_str) + .ok_or_else(|| SandboxError::StreamError { + message: "pi get_state response missing session id".to_string(), + })? + .to_string(); + let session_file = state + .get("sessionFile") + .or_else(|| state.get("session_file")) + .and_then(Value::as_str) + .map(|value| value.to_string()); + + Ok(PiSessionBootstrap { + runtime: runtime.clone(), + native_session_id, + _session_file: session_file, + }) } + .await; + + if result.is_err() { + runtime.shutdown(); + } + + result } async fn send_pi_prompt( @@ -3798,47 +3826,30 @@ impl SessionManager { session: &SessionSnapshot, prompt: &str, ) -> Result<(), SandboxError> { - let server = self.ensure_pi_server().await?; - let native_session_id = - session - .native_session_id - .as_ref() - .ok_or_else(|| SandboxError::InvalidRequest { - message: "missing Pi session id".to_string(), - })?; - if server.dialect() == PiRpcDialect::CodingAgent { - if let Some(current) = server.current_session_id() { - if current != session.session_id { - return Err(SandboxError::InvalidRequest { - message: "pi-coding-agent supports a single active session; prompt must target the current session" - .to_string(), - }); - } - } - } - - let id = server.next_request_id(); - let request = match server.dialect() { - PiRpcDialect::Mono => json!({ - "type": "command", - "id": id, - "command": "prompt", - "params": { - "sessionId": native_session_id, - "message": { - "role": "user", - "content": [{ "type": "text", "text": prompt }] + let runtime = { + let sessions = self.sessions.lock().await; + let session_state = + Self::session_ref(&sessions, &session.session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session.session_id.clone(), } - } - }), - PiRpcDialect::CodingAgent => json!({ - "type": "prompt", - "id": id, - "message": prompt - }), + })?; + session_state + .pi_runtime + .clone() + .ok_or_else(|| SandboxError::InvalidRequest { + message: "Pi session runtime is not active".to_string(), + })? }; - server + let id = runtime.next_request_id(); + let request = json!({ + "type": "prompt", + "id": id, + "message": prompt + }); + + runtime .send_request(id, &request) .ok_or_else(|| SandboxError::StreamError { message: "failed to send pi prompt request".to_string(), @@ -3847,27 +3858,10 @@ impl SessionManager { Ok(()) } - async fn record_pi_unparsed(&self, session_id: Option, error: &str, raw: Value) { - if let Some(session_id) = session_id { - let _ = self - .record_conversions(&session_id, vec![agent_unparsed("pi", error, raw)]) - .await; - return; - } - let session_ids = { - let sessions = self.server_manager.sessions.lock().await; - sessions - .get(&AgentId::Pi) - .cloned() - .unwrap_or_default() - .into_iter() - .collect::>() - }; - for session_id in session_ids { - let _ = self - .record_conversions(&session_id, vec![agent_unparsed("pi", error, raw.clone())]) - .await; - } + async fn record_pi_unparsed(&self, session_id: &str, error: &str, raw: Value) { + let _ = self + .record_conversions(session_id, vec![agent_unparsed("pi", error, raw)]) + .await; } async fn fetch_opencode_modes(&self) -> Result, SandboxError> { @@ -3901,6 +3895,296 @@ impl SessionManager { }) } + async fn fetch_claude_models(&self) -> Result { + let credentials = self.extract_credentials().await?; + let Some(cred) = credentials.anthropic else { + return Ok(AgentModelsResponse { + models: Vec::new(), + default_model: None, + }); + }; + + let headers = build_anthropic_headers(&cred)?; + let response = self + .http_client + .get(ANTHROPIC_MODELS_URL) + .headers(headers) + .send() + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(SandboxError::StreamError { + message: format!("Anthropic models request failed {status}: {body}"), + }); + } + + let value: Value = response + .json() + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + let data = value + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + let mut models = Vec::new(); + let mut default_model: Option = None; + let mut default_created: Option = None; + for item in data { + let Some(id) = item.get("id").and_then(Value::as_str) else { + continue; + }; + let name = item + .get("display_name") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let created = item + .get("created_at") + .and_then(Value::as_str) + .map(|value| value.to_string()); + if let Some(created) = created.as_ref() { + let should_update = match default_created.as_deref() { + Some(current) => created.as_str() > current, + None => true, + }; + if should_update { + default_created = Some(created.clone()); + default_model = Some(id.to_string()); + } + } + models.push(AgentModelInfo { + id: id.to_string(), + name, + variants: None, + default_variant: None, + }); + } + models.sort_by(|a, b| a.id.cmp(&b.id)); + if default_model.is_none() { + default_model = models.first().map(|model| model.id.clone()); + } + + Ok(AgentModelsResponse { + models, + default_model, + }) + } + + async fn fetch_codex_models(self: &Arc) -> Result { + let server = self.ensure_codex_server().await?; + let mut models: Vec = Vec::new(); + let mut default_model: Option = None; + let mut seen = HashSet::new(); + let mut cursor: Option = None; + + loop { + let id = server.next_request_id(); + let request = json!({ + "jsonrpc": "2.0", + "id": id, + "method": "model/list", + "params": { + "cursor": cursor, + "limit": null + } + }); + let rx = + server + .send_request(id, &request) + .ok_or_else(|| SandboxError::StreamError { + message: "failed to send model/list request".to_string(), + })?; + + let result = tokio::time::timeout(Duration::from_secs(30), rx).await; + let value = match result { + Ok(Ok(value)) => value, + Ok(Err(_)) => { + return Err(SandboxError::StreamError { + message: "model/list request cancelled".to_string(), + }) + } + Err(_) => { + return Err(SandboxError::StreamError { + message: "model/list request timed out".to_string(), + }) + } + }; + + let data = value + .get("data") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + + for item in data { + let model_id = item + .get("model") + .and_then(Value::as_str) + .or_else(|| item.get("id").and_then(Value::as_str)); + let Some(model_id) = model_id else { + continue; + }; + if !seen.insert(model_id.to_string()) { + continue; + } + + let name = item + .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 = 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::>() + }) + .unwrap_or_default(); + if variants.is_empty() { + variants = codex_variants(); + } + variants.sort(); + variants.dedup(); + + if default_model.is_none() + && item + .get("isDefault") + .and_then(Value::as_bool) + .unwrap_or(false) + { + default_model = Some(model_id.to_string()); + } + + models.push(AgentModelInfo { + id: model_id.to_string(), + name, + variants: Some(variants), + default_variant, + }); + } + + let next_cursor = value + .get("nextCursor") + .and_then(Value::as_str) + .map(|value| value.to_string()); + if next_cursor.is_none() { + break; + } + cursor = next_cursor; + } + + models.sort_by(|a, b| a.id.cmp(&b.id)); + if default_model.is_none() { + default_model = models.first().map(|model| model.id.clone()); + } + + Ok(AgentModelsResponse { + models, + default_model, + }) + } + + async fn fetch_opencode_models(&self) -> Result { + let base_url = self.ensure_opencode_server().await?; + let endpoints = [ + format!("{base_url}/config/providers"), + format!("{base_url}/provider"), + ]; + for url in endpoints { + let response = self.http_client.get(&url).send().await; + let response = match response { + Ok(response) => response, + Err(_) => continue, + }; + if !response.status().is_success() { + continue; + } + let value: Value = response + .json() + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + if let Some(models) = parse_opencode_models(&value) { + return Ok(models); + } + } + Err(SandboxError::StreamError { + message: "OpenCode models unavailable".to_string(), + }) + } + + async fn fetch_pi_models(&self) -> Result { + let binary = self + .agent_manager + .resolve_binary(AgentId::Pi) + .map_err(|err| map_spawn_error(AgentId::Pi, err))?; + let output = tokio::time::timeout( + Duration::from_secs(10), + tokio::task::spawn_blocking(move || { + std::process::Command::new(binary) + .arg("--list-models") + .output() + }), + ) + .await + .map_err(|_| SandboxError::StreamError { + message: "pi --list-models timed out".to_string(), + })? + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })? + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let message = if stderr.is_empty() { + format!("pi --list-models failed with status {}", output.status) + } else { + format!( + "pi --list-models failed with status {}: {stderr}", + output.status + ) + }; + return Err(SandboxError::StreamError { message }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(parse_pi_models_output(&stdout)) + } + + async fn extract_credentials(&self) -> Result { + tokio::task::spawn_blocking(move || { + let options = CredentialExtractionOptions::new(); + extract_all_credentials(&options) + }) + .await + .map_err(|err| SandboxError::StreamError { + message: err.to_string(), + }) + } + async fn create_opencode_session(&self) -> Result { let base_url = self.ensure_opencode_server().await?; let url = format!("{base_url}/session"); @@ -4167,6 +4451,26 @@ pub struct AgentModesResponse { pub modes: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentModelInfo { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub variants: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_variant: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AgentModelsResponse { + pub models: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_model: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct AgentCapabilities { @@ -4188,6 +4492,7 @@ pub struct AgentCapabilities { pub mcp_tools: bool, pub streaming_deltas: bool, pub item_started: bool, + pub variants: bool, /// Whether this agent uses a shared long-running server process (vs per-turn subprocess) pub shared_process: bool, } @@ -4421,21 +4726,45 @@ async fn get_agent_modes( Ok(Json(AgentModesResponse { modes })) } -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 +#[utoipa::path( + get, + path = "/v1/agents/{agent}/models", + responses( + (status = 200, body = AgentModelsResponse), + (status = 400, body = ProblemDetails) + ), + params(("agent" = String, Path, description = "Agent id")), + tag = "agents" +)] +async fn get_agent_models( + State(state): State>, + Path(agent): Path, +) -> Result, ApiError> { + let agent_id = parse_agent_id(&agent)?; + let models = state.session_manager.agent_models(agent_id).await?; + Ok(Json(models)) } -async fn not_found() -> (StatusCode, String) { +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(), + ) +} + +async fn get_root(State(state): State>) -> String { + server_info(state.branding) +} + +async fn not_found(State(state): State>) -> (StatusCode, String) { ( StatusCode::NOT_FOUND, - format!("404 Not Found\n\n{SERVER_INFO}"), + format!("404 Not Found\n\n{}", server_info(state.branding)), ) } @@ -4822,6 +5151,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: false, streaming_deltas: true, item_started: false, + variants: false, shared_process: false, // per-turn subprocess with --resume }, AgentId::Codex => AgentCapabilities { @@ -4842,6 +5172,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: true, streaming_deltas: true, item_started: true, + variants: true, shared_process: true, // shared app-server via JSON-RPC }, AgentId::Opencode => AgentCapabilities { @@ -4862,6 +5193,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: false, streaming_deltas: true, item_started: true, + variants: true, shared_process: true, // shared HTTP server }, AgentId::Amp => AgentCapabilities { @@ -4882,6 +5214,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: false, streaming_deltas: false, item_started: false, + variants: true, shared_process: false, // per-turn subprocess with --continue }, AgentId::Pi => AgentCapabilities { @@ -4902,7 +5235,8 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: false, streaming_deltas: true, item_started: true, - shared_process: true, // shared stdio RPC + variants: false, + shared_process: false, // one dedicated rpc process per session }, AgentId::Mock => AgentCapabilities { plan_mode: true, @@ -4922,6 +5256,7 @@ fn agent_capabilities_for(agent: AgentId) -> AgentCapabilities { mcp_tools: true, streaming_deltas: true, item_started: true, + variants: false, shared_process: false, // in-memory mock (no subprocess) }, } @@ -5001,6 +5336,163 @@ fn agent_modes_for(agent: AgentId) -> Vec { } } +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(); + AgentModelsResponse { + models, + default_model: Some("smart".to_string()), + } +} + +fn mock_models_response() -> AgentModelsResponse { + AgentModelsResponse { + models: vec![AgentModelInfo { + id: "mock".to_string(), + name: Some("Mock".to_string()), + variants: None, + default_variant: None, + }], + default_model: Some("mock".to_string()), + } +} + +fn amp_variants() -> Vec { + vec!["medium", "high", "xhigh"] + .into_iter() + .map(|value| value.to_string()) + .collect() +} + +fn codex_variants() -> Vec { + vec!["none", "minimal", "low", "medium", "high", "xhigh"] + .into_iter() + .map(|value| value.to_string()) + .collect() +} + +fn parse_pi_models_output(output: &str) -> AgentModelsResponse { + let mut models = Vec::new(); + let mut seen = HashSet::new(); + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let mut parts = trimmed.split_whitespace(); + let Some(provider) = parts.next() else { + continue; + }; + let Some(model) = parts.next() else { + continue; + }; + + if provider.eq_ignore_ascii_case("provider") && model.eq_ignore_ascii_case("model") { + continue; + } + if provider.chars().all(|ch| ch == '-' || ch == '=') + && model.chars().all(|ch| ch == '-' || ch == '=') + { + continue; + } + + let id = format!("{provider}/{model}"); + if !seen.insert(id.clone()) { + continue; + } + + models.push(AgentModelInfo { + id, + name: None, + variants: None, + default_variant: None, + }); + } + + models.sort_by(|a, b| a.id.cmp(&b.id)); + + AgentModelsResponse { + models, + default_model: None, + } +} + +fn parse_opencode_models(value: &Value) -> Option { + let providers = value + .get("providers") + .and_then(Value::as_array) + .or_else(|| value.get("all").and_then(Value::as_array))?; + let default_map = value + .get("default") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + let mut models = Vec::new(); + let mut provider_order = Vec::new(); + for provider in providers { + let provider_id = provider.get("id").and_then(Value::as_str)?; + provider_order.push(provider_id.to_string()); + let Some(model_map) = provider.get("models").and_then(Value::as_object) else { + continue; + }; + for (key, model) in model_map { + let model_id = model + .get("id") + .and_then(Value::as_str) + .unwrap_or(key.as_str()); + let name = model + .get("name") + .and_then(Value::as_str) + .map(|value| value.to_string()); + let mut variants = model + .get("variants") + .and_then(Value::as_object) + .map(|map| map.keys().cloned().collect::>()); + if let Some(variants) = variants.as_mut() { + variants.sort(); + } + models.push(AgentModelInfo { + id: format!("{provider_id}/{model_id}"), + name, + variants, + default_variant: None, + }); + } + } + models.sort_by(|a, b| a.id.cmp(&b.id)); + + let mut default_model = None; + for provider_id in provider_order { + if let Some(model_id) = default_map.get(&provider_id).and_then(Value::as_str) { + default_model = Some(format!("{provider_id}/{model_id}")); + break; + } + } + if default_model.is_none() { + default_model = models.first().map(|model| model.id.clone()); + } + + Some(AgentModelsResponse { + models, + default_model, + }) +} + fn normalize_agent_mode(agent: AgentId, agent_mode: Option<&str>) -> Result { let mode = agent_mode.unwrap_or("build"); match agent { @@ -5313,6 +5805,7 @@ struct CodexAppServerState { next_id: i64, prompt: String, model: Option, + effort: Option, cwd: Option, approval_policy: Option, sandbox_mode: Option, @@ -5337,6 +5830,7 @@ 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()), @@ -5582,7 +6076,7 @@ impl CodexAppServerState { approval_policy: self.approval_policy, collaboration_mode: None, cwd: self.cwd.clone(), - effort: None, + effort: self.effort.clone(), input: vec![codex_schema::UserInput::Text { text: self.prompt.clone(), text_elements: Vec::new(), @@ -5625,6 +6119,15 @@ fn codex_prompt_for_mode(prompt: &str, mode: Option<&str>) -> String { } } +fn codex_effort_from_variant(variant: Option<&str>) -> Option { + 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 { match mode { Some("plan") => Some(codex_schema::AskForApproval::Untrusted), @@ -5925,28 +6428,6 @@ fn extract_opencode_session_id(value: &Value) -> Option { None } -fn extract_pi_session_id(value: &Value) -> Option { - if let Some(id) = value.get("sessionId").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = value.get("session_id").and_then(Value::as_str) { - return Some(id.to_string()); - } - if let Some(id) = extract_nested_string(value, &["session", "id"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(value, &["message", "sessionId"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(value, &["message", "session_id"]) { - return Some(id); - } - if let Some(id) = extract_nested_string(value, &["params", "sessionId"]) { - return Some(id); - } - None -} - fn extract_nested_string(value: &Value, path: &[&str]) -> Option { let mut current = value; for key in path { @@ -5959,6 +6440,314 @@ fn extract_nested_string(value: &Value, path: &[&str]) -> Option { current.as_str().map(|s| s.to_string()) } +#[cfg(test)] +mod pi_model_parser_tests { + use super::*; + + #[test] + fn parse_pi_models_output_parses_rows_from_table() { + let output = r#" +provider model aliases +openai gpt-4.1 gpt-4.1-latest +anthropic claude-sonnet-4-5-20250929 sonnet +"#; + + let parsed = parse_pi_models_output(output); + let ids = parsed + .models + .iter() + .map(|model| model.id.clone()) + .collect::>(); + + assert_eq!( + ids, + vec!["anthropic/claude-sonnet-4-5-20250929", "openai/gpt-4.1"] + ); + assert_eq!(parsed.default_model, None); + } + + #[test] + fn parse_pi_models_output_skips_blank_header_separator_and_malformed_rows() { + let output = r#" +provider model aliases +-------- ----- ------- + +openai +malformed-row +groq llama-3.3-70b-versatile alias +"#; + + let parsed = parse_pi_models_output(output); + let ids = parsed + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + + assert_eq!(ids, vec!["groq/llama-3.3-70b-versatile"]); + } + + #[test] + fn parse_pi_models_output_handles_model_ids_with_slashes() { + let output = "openrouter qwen/qwen3-32b"; + + let parsed = parse_pi_models_output(output); + let ids = parsed + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + + assert_eq!(ids, vec!["openrouter/qwen/qwen3-32b"]); + } + + #[test] + fn parse_pi_models_output_deduplicates_and_sorts_stably() { + let output = r#" +zeta z-model +alpha a-model +zeta z-model +beta b-model +alpha a-model +"#; + + let parsed = parse_pi_models_output(output); + let ids = parsed + .models + .iter() + .map(|model| model.id.as_str()) + .collect::>(); + + assert_eq!(ids, vec!["alpha/a-model", "beta/b-model", "zeta/z-model"]); + assert_eq!(parsed.default_model, None); + } +} + +#[cfg(test)] +mod pi_runtime_tests { + use super::*; + use tempfile::TempDir; + + fn test_request(agent: AgentId) -> CreateSessionRequest { + CreateSessionRequest { + agent: agent.as_str().to_string(), + agent_mode: None, + permission_mode: Some("default".to_string()), + model: None, + variant: None, + agent_version: None, + } + } + + #[test] + fn pi_model_args_with_provider_and_model() { + let mut command = std::process::Command::new("pi"); + SessionManager::apply_pi_model_args(&mut command, Some("openai/gpt-5.2-codex")); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + assert_eq!( + args, + vec!["--provider", "openai", "--model", "gpt-5.2-codex"] + ); + } + + #[test] + fn pi_model_args_with_slashes_in_model_id() { + let mut command = std::process::Command::new("pi"); + SessionManager::apply_pi_model_args( + &mut command, + Some("openrouter/meta-llama/llama-3.1-8b-instruct"), + ); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + assert_eq!( + args, + vec![ + "--provider", + "openrouter", + "--model", + "meta-llama/llama-3.1-8b-instruct" + ] + ); + } + + #[test] + fn pi_model_args_with_model_only() { + let mut command = std::process::Command::new("pi"); + SessionManager::apply_pi_model_args(&mut command, Some("gpt-5.2-codex")); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect::>(); + assert_eq!(args, vec!["--model", "gpt-5.2-codex"]); + } + + async fn setup_pi_session( + session_id: &str, + ) -> (Arc, Arc, TempDir) { + let temp_dir = TempDir::new().expect("temp dir"); + let agent_manager = Arc::new(AgentManager::new(temp_dir.path()).expect("agent manager")); + let http_client = Client::builder().no_proxy().build().expect("http client"); + let server_manager = Arc::new(AgentServerManager::new( + agent_manager.clone(), + http_client.clone(), + temp_dir.path().join("logs"), + false, + )); + let session_manager = Arc::new(SessionManager { + agent_manager, + sessions: Mutex::new(Vec::new()), + server_manager, + http_client, + }); + session_manager + .server_manager + .set_owner(Arc::downgrade(&session_manager)); + + let (stdin_tx, _stdin_rx) = mpsc::unbounded_channel::(); + let runtime = Arc::new(PiSessionRuntime::new( + stdin_tx, + Arc::new(std::sync::Mutex::new(None)), + )); + + let mut session = SessionState::new( + session_id.to_string(), + AgentId::Pi, + &test_request(AgentId::Pi), + ) + .expect("session"); + session.native_session_id = Some(format!("native-{session_id}")); + session.pi_runtime = Some(runtime.clone()); + session_manager.sessions.lock().await.push(session); + + (session_manager, runtime, temp_dir) + } + + #[tokio::test] + async fn pi_runtime_correlates_multiple_inflight_requests() { + let (stdin_tx, mut stdin_rx) = mpsc::unbounded_channel::(); + let runtime = PiSessionRuntime::new(stdin_tx, Arc::new(std::sync::Mutex::new(None))); + + let id1 = runtime.next_request_id(); + let id2 = runtime.next_request_id(); + let rx1 = runtime + .send_request(id1, &json!({ "type": "one", "id": id1 })) + .expect("request 1"); + let rx2 = runtime + .send_request(id2, &json!({ "type": "two", "id": id2 })) + .expect("request 2"); + + let line1 = stdin_rx.recv().await.expect("line1"); + let line2 = stdin_rx.recv().await.expect("line2"); + assert!(line1.contains("\"id\":1") || line2.contains("\"id\":1")); + assert!(line1.contains("\"id\":2") || line2.contains("\"id\":2")); + + runtime.complete_request(id2, json!({ "type": "response", "id": id2, "ok": true })); + runtime.complete_request(id1, json!({ "type": "response", "id": id1, "ok": true })); + + let result2 = rx2.await.expect("response 2"); + let result1 = rx1.await.expect("response 1"); + assert_eq!(result2.get("id").and_then(Value::as_i64), Some(id2)); + assert_eq!(result1.get("id").and_then(Value::as_i64), Some(id1)); + } + + #[tokio::test] + async fn pi_runtime_output_non_json_emits_agent_unparsed() { + let (session_manager, runtime, _temp_dir) = setup_pi_session("pi-unparsed").await; + let (tx, rx) = mpsc::unbounded_channel::(); + tx.send("not-json".to_string()).expect("send malformed"); + drop(tx); + + session_manager + .clone() + .handle_pi_runtime_output("pi-unparsed".to_string(), runtime, rx) + .await; + + let events = session_manager + .events("pi-unparsed", 0, None, true) + .await + .expect("events") + .events; + assert!(events.iter().any(|event| { + event.event_type == UniversalEventType::AgentUnparsed + && matches!(event.source, EventSource::Daemon) + && event.synthetic + })); + } + + #[tokio::test] + async fn pi_runtime_converter_continuity_for_incremental_deltas() { + let (session_manager, runtime, _temp_dir) = setup_pi_session("pi-delta").await; + let (tx, rx) = mpsc::unbounded_channel::(); + let lines = [ + json!({ + "type": "message_update", + "assistantMessageEvent": { "type": "text_delta", "delta": "Hel" } + }), + json!({ + "type": "message_update", + "assistantMessageEvent": { "type": "text_delta", "delta": "lo" } + }), + json!({ + "type": "message_update", + "assistantMessageEvent": { "type": "done" } + }), + ]; + for line in lines { + tx.send(line.to_string()).expect("send line"); + } + drop(tx); + + session_manager + .clone() + .handle_pi_runtime_output("pi-delta".to_string(), runtime, rx) + .await; + + let events = session_manager + .events("pi-delta", 0, None, true) + .await + .expect("events") + .events; + let deltas = events + .iter() + .filter_map(|event| match &event.data { + UniversalEventData::ItemDelta(delta) => { + Some((delta.item_id.clone(), delta.delta.clone())) + } + _ => None, + }) + .collect::>(); + assert_eq!(deltas.len(), 2); + assert_eq!(deltas[0].0, deltas[1].0, "deltas should share one item"); + assert_eq!(deltas[0].1, "Hel"); + assert_eq!(deltas[1].1, "lo"); + + let completed = events.iter().find_map(|event| match &event.data { + UniversalEventData::Item(item) + if event.event_type == UniversalEventType::ItemCompleted => + { + Some(item.item.clone()) + } + _ => None, + }); + let completed = completed.expect("completed item"); + assert_eq!(completed.kind, ItemKind::Message); + let text = completed + .content + .iter() + .find_map(|part| match part { + ContentPart::Text { text } => Some(text.clone()), + _ => None, + }) + .unwrap_or_default(); + assert_eq!(text, "Hello"); + } +} + #[cfg(feature = "test-utils")] pub mod test_utils { use super::*; @@ -6121,7 +6910,7 @@ pub mod test_utils { } pub async fn shutdown(&self) { - self.session_manager.server_manager.shutdown().await; + self.session_manager.shutdown().await; } pub async fn server_status(&self, agent: AgentId) -> Option { @@ -6337,7 +7126,26 @@ fn mock_command_conversions(prefix: &str, input: &str) -> Vec { if trimmed.is_empty() { return vec![]; } + let mut events = mock_command_events(prefix, trimmed); + if should_append_turn_completed(&events) { + events.push(turn_completed_event()); + } + events +} +fn should_append_turn_completed(events: &[EventConversion]) -> bool { + let Some(last) = events.last() else { + return false; + }; + !matches!( + last.event_type, + UniversalEventType::SessionEnded + | UniversalEventType::PermissionRequested + | UniversalEventType::QuestionRequested + ) +} + +fn mock_command_events(prefix: &str, trimmed: &str) -> Vec { if trimmed.eq_ignore_ascii_case(MOCK_OK_PROMPT) { return mock_assistant_message(format!("{prefix}_ok"), "OK".to_string()); } @@ -7172,7 +7980,7 @@ fn stream_turn_events( }) } -fn is_turn_terminal(event: &UniversalEvent, agent: AgentId) -> bool { +fn is_turn_terminal(event: &UniversalEvent, _agent: AgentId) -> bool { match event.event_type { UniversalEventType::SessionEnded | UniversalEventType::Error @@ -7183,15 +7991,7 @@ fn is_turn_terminal(event: &UniversalEvent, agent: AgentId) -> bool { let UniversalEventData::Item(ItemEventData { item }) = &event.data else { return false; }; - if let Some(label) = status_label(item) { - if label == "turn.completed" || label == "session.idle" { - return true; - } - } - if matches!(item.role, Some(ItemRole::Assistant)) && item.kind == ItemKind::Message { - return agent != AgentId::Codex; - } - false + matches!(status_label(item), Some("turn.completed" | "session.idle")) } _ => false, } @@ -7247,3 +8047,34 @@ pub fn add_token_header(headers: &mut HeaderMap, token: &str) { headers.insert(axum::http::header::AUTHORIZATION, header); } } + +fn build_anthropic_headers( + credentials: &ProviderCredentials, +) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + match credentials.auth_type { + AuthType::ApiKey => { + let value = + reqwest::header::HeaderValue::from_str(&credentials.api_key).map_err(|_| { + SandboxError::StreamError { + message: "invalid anthropic api key header".to_string(), + } + })?; + headers.insert("x-api-key", value); + } + AuthType::Oauth => { + let value = format!("Bearer {}", credentials.api_key); + let header = reqwest::header::HeaderValue::from_str(&value).map_err(|_| { + SandboxError::StreamError { + message: "invalid anthropic oauth header".to_string(), + } + })?; + headers.insert(reqwest::header::AUTHORIZATION, header); + } + } + headers.insert( + "anthropic-version", + reqwest::header::HeaderValue::from_static(ANTHROPIC_VERSION), + ); + Ok(headers) +} diff --git a/server/packages/sandbox-agent/src/telemetry.rs b/server/packages/sandbox-agent/src/telemetry.rs index 20d0633..11d6430 100644 --- a/server/packages/sandbox-agent/src/telemetry.rs +++ b/server/packages/sandbox-agent/src/telemetry.rs @@ -3,6 +3,7 @@ use std::env; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use reqwest::Client; @@ -11,6 +12,7 @@ use time::OffsetDateTime; use tokio::time::Instant; use crate::http_client; +static TELEMETRY_ENABLED: AtomicBool = AtomicBool::new(false); const TELEMETRY_URL: &str = "https://tc.rivet.dev"; const TELEMETRY_ENV_DEBUG: &str = "SANDBOX_AGENT_TELEMETRY_DEBUG"; @@ -21,7 +23,7 @@ const TELEMETRY_INTERVAL_SECS: u64 = 300; const TELEMETRY_MIN_GAP_SECS: i64 = 300; #[derive(Debug, Serialize)] -struct TelemetryEvent { +struct TelemetryEvent { // p = project identifier p: String, // dt = unix timestamp (seconds) @@ -33,13 +35,13 @@ struct TelemetryEvent { // ev = event name ev: String, // d = data payload - d: TelemetryData, + d: D, // v = schema version v: u8, } #[derive(Debug, Serialize)] -struct TelemetryData { +struct BeaconData { version: String, os: OsInfo, provider: ProviderInfo, @@ -62,15 +64,17 @@ struct ProviderInfo { } pub fn telemetry_enabled(no_telemetry: bool) -> bool { - if no_telemetry { - return false; - } - if cfg!(debug_assertions) { - return env::var(TELEMETRY_ENV_DEBUG) + let enabled = if no_telemetry { + false + } else if cfg!(debug_assertions) { + env::var(TELEMETRY_ENV_DEBUG) .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE")) - .unwrap_or(false); - } - true + .unwrap_or(false) + } else { + true + }; + TELEMETRY_ENABLED.store(enabled, Ordering::Relaxed); + enabled } pub fn log_enabled_message() { @@ -107,7 +111,7 @@ async fn attempt_send(client: &Client) { return; } - let event = build_event(dt); + let event = build_beacon_event(dt); if let Err(err) = client.post(TELEMETRY_URL).json(&event).send().await { tracing::debug!(error = %err, "telemetry request failed"); return; @@ -115,15 +119,12 @@ async fn attempt_send(client: &Client) { write_last_sent(dt); } -fn build_event(dt: i64) -> TelemetryEvent { - let eid = load_or_create_id(); - TelemetryEvent { - p: "sandbox-agent".to_string(), +fn build_beacon_event(dt: i64) -> TelemetryEvent { + new_event( dt, - et: "sandbox".to_string(), - eid, - ev: "entity_beacon".to_string(), - d: TelemetryData { + "sandbox", + "entity_beacon", + BeaconData { version: env!("CARGO_PKG_VERSION").to_string(), os: OsInfo { name: std::env::consts::OS.to_string(), @@ -132,6 +133,23 @@ fn build_event(dt: i64) -> TelemetryEvent { }, provider: detect_provider(), }, + ) +} + +fn new_event( + dt: i64, + entity_type: &str, + event_name: &str, + data: D, +) -> TelemetryEvent { + let eid = load_or_create_id(); + TelemetryEvent { + p: "sandbox-agent".to_string(), + dt, + et: entity_type.to_string(), + eid, + ev: event_name.to_string(), + d: data, v: 1, } } @@ -433,3 +451,66 @@ fn metadata_or_none( Some(map) } } + +#[derive(Debug, Serialize)] +struct SessionCreatedData { + version: String, + agent: String, + #[serde(skip_serializing_if = "Option::is_none")] + agent_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + permission_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + variant: Option, +} + +pub struct SessionConfig { + pub agent: String, + pub agent_mode: Option, + pub permission_mode: Option, + pub model: Option, + pub variant: Option, +} + +pub fn log_session_created(config: SessionConfig) { + if !TELEMETRY_ENABLED.load(Ordering::Relaxed) { + return; + } + + let event = new_event( + OffsetDateTime::now_utc().unix_timestamp(), + "session", + "session_created", + SessionCreatedData { + version: env!("CARGO_PKG_VERSION").to_string(), + agent: config.agent, + agent_mode: config.agent_mode, + permission_mode: config.permission_mode, + model: config.model, + variant: config.variant, + }, + ); + + spawn_send(event); +} + +fn spawn_send(event: TelemetryEvent) { + tokio::spawn(async move { + let client = match Client::builder() + .timeout(Duration::from_millis(TELEMETRY_TIMEOUT_MS)) + .build() + { + Ok(client) => client, + Err(err) => { + tracing::debug!(error = %err, "failed to build telemetry client"); + return; + } + }; + + if let Err(err) = client.post(TELEMETRY_URL).json(&event).send().await { + tracing::debug!(error = %err, "telemetry send failed"); + } + }); +} diff --git a/server/packages/sandbox-agent/tests/agent-flows/pi_rpc_integration.rs b/server/packages/sandbox-agent/tests/agent-flows/pi_rpc_integration.rs index 630c373..5ca66ea 100644 --- a/server/packages/sandbox-agent/tests/agent-flows/pi_rpc_integration.rs +++ b/server/packages/sandbox-agent/tests/agent-flows/pi_rpc_integration.rs @@ -1,26 +1,22 @@ // Pi RPC integration tests (gated via SANDBOX_TEST_PI + PATH). include!("../common/http.rs"); -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pi_rpc_session_and_stream() { +fn pi_test_config() -> Option { let configs = match test_agents_from_env() { Ok(configs) => configs, Err(err) => { eprintln!("Skipping Pi RPC integration test: {err}"); - return; + return None; } }; - let Some(config) = configs.iter().find(|config| config.agent == AgentId::Pi) else { - return; - }; + configs + .into_iter() + .find(|config| config.agent == AgentId::Pi) +} - let app = TestApp::new(); - let _guard = apply_credentials(&config.credentials); - install_agent(&app.app, config.agent).await; - - let session_id = "pi-rpc-session".to_string(); +async fn create_pi_session_with_native(app: &Router, session_id: &str) -> String { let (status, payload) = send_json( - &app.app, + app, Method::POST, &format!("/v1/sessions/{session_id}"), Some(json!({ @@ -33,19 +29,16 @@ async fn pi_rpc_session_and_stream() { let native_session_id = payload .get("native_session_id") .and_then(Value::as_str) - .unwrap_or(""); + .unwrap_or("") + .to_string(); assert!( !native_session_id.is_empty(), "expected native_session_id for pi session" ); + native_session_id +} - let events = read_turn_stream_events(&app.app, &session_id, Duration::from_secs(120)).await; - assert!(!events.is_empty(), "no events from pi stream"); - assert!( - !events.iter().any(is_unparsed_event), - "agent.unparsed event encountered" - ); - +fn assert_strictly_increasing_sequences(events: &[Value], label: &str) { let mut last_sequence = 0u64; for event in events { let sequence = event @@ -54,8 +47,241 @@ async fn pi_rpc_session_and_stream() { .expect("missing sequence"); assert!( sequence > last_sequence, - "sequence did not increase (prev {last_sequence}, next {sequence})" + "{label}: sequence did not increase (prev {last_sequence}, next {sequence})" ); last_sequence = sequence; } } + +fn assert_all_events_for_session(events: &[Value], session_id: &str) { + for event in events { + let event_session_id = event + .get("session_id") + .and_then(Value::as_str) + .unwrap_or_default(); + assert_eq!( + event_session_id, session_id, + "cross-session event detected in {session_id}: {event}" + ); + } +} + +fn assert_item_started_ids_unique(events: &[Value], label: &str) { + let mut ids = std::collections::HashSet::new(); + for event in events { + let event_type = event + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + if event_type != "item.started" { + continue; + } + let Some(item_id) = event + .get("data") + .and_then(|data| data.get("item")) + .and_then(|item| item.get("item_id")) + .and_then(Value::as_str) + else { + continue; + }; + assert!( + ids.insert(item_id.to_string()), + "{label}: duplicate item.started id {item_id}" + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pi_rpc_session_and_stream() { + let Some(config) = pi_test_config() else { + return; + }; + + let app = TestApp::new(); + let _guard = apply_credentials(&config.credentials); + install_agent(&app.app, config.agent).await; + + let session_id = "pi-rpc-session"; + let _native_session_id = create_pi_session_with_native(&app.app, session_id).await; + + let events = read_turn_stream_events(&app.app, session_id, Duration::from_secs(120)).await; + assert!(!events.is_empty(), "no events from pi stream"); + assert!( + !events.iter().any(is_unparsed_event), + "agent.unparsed event encountered" + ); + assert!( + should_stop(&events), + "turn stream did not reach a terminal event" + ); + assert_strictly_increasing_sequences(&events, "pi_rpc_session_and_stream"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pi_parallel_sessions_turns() { + let Some(config) = pi_test_config() else { + return; + }; + + let app = TestApp::new(); + let _guard = apply_credentials(&config.credentials); + install_agent(&app.app, config.agent).await; + + let session_a = "pi-parallel-a"; + let session_b = "pi-parallel-b"; + create_pi_session_with_native(&app.app, session_a).await; + create_pi_session_with_native(&app.app, session_b).await; + + let app_a = app.app.clone(); + let app_b = app.app.clone(); + let send_a = send_message(&app_a, session_a); + let send_b = send_message(&app_b, session_b); + tokio::join!(send_a, send_b); + + let app_a = app.app.clone(); + let app_b = app.app.clone(); + let poll_a = poll_events_until(&app_a, session_a, Duration::from_secs(120)); + let poll_b = poll_events_until(&app_b, session_b, Duration::from_secs(120)); + let (events_a, events_b) = tokio::join!(poll_a, poll_b); + + assert!(!events_a.is_empty(), "no events for session A"); + assert!(!events_b.is_empty(), "no events for session B"); + assert!( + should_stop(&events_a), + "session A did not reach a terminal event" + ); + assert!( + should_stop(&events_b), + "session B did not reach a terminal event" + ); + assert!( + !events_a.iter().any(is_unparsed_event), + "session A encountered agent.unparsed" + ); + assert!( + !events_b.iter().any(is_unparsed_event), + "session B encountered agent.unparsed" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pi_event_isolation() { + let Some(config) = pi_test_config() else { + return; + }; + + let app = TestApp::new(); + let _guard = apply_credentials(&config.credentials); + install_agent(&app.app, config.agent).await; + + let session_a = "pi-isolation-a"; + let session_b = "pi-isolation-b"; + create_pi_session_with_native(&app.app, session_a).await; + create_pi_session_with_native(&app.app, session_b).await; + + let app_a = app.app.clone(); + let app_b = app.app.clone(); + let send_a = send_message(&app_a, session_a); + let send_b = send_message(&app_b, session_b); + tokio::join!(send_a, send_b); + + let app_a = app.app.clone(); + let app_b = app.app.clone(); + let poll_a = poll_events_until(&app_a, session_a, Duration::from_secs(120)); + let poll_b = poll_events_until(&app_b, session_b, Duration::from_secs(120)); + let (events_a, events_b) = tokio::join!(poll_a, poll_b); + + assert!(should_stop(&events_a), "session A did not complete"); + assert!(should_stop(&events_b), "session B did not complete"); + assert_all_events_for_session(&events_a, session_a); + assert_all_events_for_session(&events_b, session_b); + assert_strictly_increasing_sequences(&events_a, "session A"); + assert_strictly_increasing_sequences(&events_b, "session B"); + assert_item_started_ids_unique(&events_a, "session A"); + assert_item_started_ids_unique(&events_b, "session B"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pi_terminate_one_session_does_not_affect_other() { + let Some(config) = pi_test_config() else { + return; + }; + + let app = TestApp::new(); + let _guard = apply_credentials(&config.credentials); + install_agent(&app.app, config.agent).await; + + let session_a = "pi-terminate-a"; + let session_b = "pi-terminate-b"; + create_pi_session_with_native(&app.app, session_a).await; + create_pi_session_with_native(&app.app, session_b).await; + + let terminate_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_a}/terminate"), + None, + ) + .await; + assert_eq!( + terminate_status, + StatusCode::NO_CONTENT, + "terminate session A" + ); + + send_message(&app.app, session_b).await; + let events_b = poll_events_until(&app.app, session_b, Duration::from_secs(120)).await; + assert!(!events_b.is_empty(), "no events for session B"); + assert!( + should_stop(&events_b), + "session B did not complete after A terminated" + ); + + let events_a = poll_events_until(&app.app, session_a, Duration::from_secs(10)).await; + assert!( + events_a.iter().any(|event| { + event + .get("type") + .and_then(Value::as_str) + .is_some_and(|ty| ty == "session.ended") + }), + "session A missing session.ended after terminate" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pi_runtime_restart_scope() { + let Some(config) = pi_test_config() else { + return; + }; + + let app = TestApp::new(); + let _guard = apply_credentials(&config.credentials); + install_agent(&app.app, config.agent).await; + + let session_a = "pi-restart-scope-a"; + let session_b = "pi-restart-scope-b"; + create_pi_session_with_native(&app.app, session_a).await; + create_pi_session_with_native(&app.app, session_b).await; + + let terminate_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_a}/terminate"), + None, + ) + .await; + assert_eq!( + terminate_status, + StatusCode::NO_CONTENT, + "terminate session A to stop only its runtime" + ); + + send_message(&app.app, session_b).await; + let events_b = poll_events_until(&app.app, session_b, Duration::from_secs(120)).await; + assert!( + should_stop(&events_b), + "session B did not continue after A stopped" + ); + assert_all_events_for_session(&events_b, session_b); +} diff --git a/server/packages/sandbox-agent/tests/common/http.rs b/server/packages/sandbox-agent/tests/common/http.rs index 6ff9ff4..c2b40ae 100644 --- a/server/packages/sandbox-agent/tests/common/http.rs +++ b/server/packages/sandbox-agent/tests/common/http.rs @@ -736,6 +736,81 @@ fn normalize_agent_modes(value: &Value) -> Value { json!({ "modes": normalized }) } +fn normalize_agent_models(value: &Value, agent: AgentId) -> Value { + let models = value + .get("models") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let default_model = value.get("defaultModel").and_then(Value::as_str); + + let mut map = Map::new(); + let model_count = models.len(); + map.insert("nonEmpty".to_string(), Value::Bool(model_count > 0)); + map.insert("hasDefault".to_string(), Value::Bool(default_model.is_some())); + let default_in_list = default_model.map_or(false, |default_id| { + models + .iter() + .any(|model| model.get("id").and_then(Value::as_str) == Some(default_id)) + }); + map.insert( + "defaultInList".to_string(), + Value::Bool(default_in_list), + ); + let has_variants = models.iter().any(|model| { + model + .get("variants") + .and_then(Value::as_array) + .is_some_and(|variants| !variants.is_empty()) + }); + match agent { + AgentId::Claude | AgentId::Opencode => { + map.insert( + "hasVariants".to_string(), + Value::String("".to_string()), + ); + } + _ => { + map.insert("hasVariants".to_string(), Value::Bool(has_variants)); + } + } + + if matches!(agent, AgentId::Amp | AgentId::Mock) { + map.insert( + "modelCount".to_string(), + Value::Number(model_count.into()), + ); + let mut ids: Vec = models + .iter() + .filter_map(|model| model.get("id").and_then(Value::as_str).map(|id| id.to_string())) + .collect(); + ids.sort(); + map.insert("ids".to_string(), json!(ids)); + if let Some(default_model) = default_model { + map.insert( + "defaultModel".to_string(), + Value::String(default_model.to_string()), + ); + } + if agent == AgentId::Amp { + if let Some(variants) = models + .first() + .and_then(|model| model.get("variants")) + .and_then(Value::as_array) + { + let mut variant_ids: Vec = variants + .iter() + .filter_map(|variant| variant.as_str().map(|id| id.to_string())) + .collect(); + variant_ids.sort(); + map.insert("variants".to_string(), json!(variant_ids)); + } + } + } + + Value::Object(map) +} + fn normalize_sessions(value: &Value) -> Value { let sessions = value .get("sessions") diff --git a/server/packages/sandbox-agent/tests/http/agent_endpoints.rs b/server/packages/sandbox-agent/tests/http/agent_endpoints.rs index f195205..b0fa269 100644 --- a/server/packages/sandbox-agent/tests/http/agent_endpoints.rs +++ b/server/packages/sandbox-agent/tests/http/agent_endpoints.rs @@ -162,4 +162,27 @@ async fn agent_endpoints_snapshots() { insta::assert_yaml_snapshot!(normalize_agent_modes(&modes)); }); } + + for config in &configs { + let _guard = apply_credentials(&config.credentials); + let (status, models) = send_json( + &app.app, + Method::GET, + &format!("/v1/agents/{}/models", config.agent.as_str()), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "agent models"); + let model_count = models + .get("models") + .and_then(|value| value.as_array()) + .map(|models| models.len()) + .unwrap_or_default(); + assert!(model_count > 0, "agent models should not be empty"); + insta::with_settings!({ + snapshot_suffix => snapshot_name("agent_models", Some(config.agent)), + }, { + insta::assert_yaml_snapshot!(normalize_agent_models(&models, config.agent)); + }); + } } diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap new file mode 100644 index 0000000..10c6ff5 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_amp.snap @@ -0,0 +1,19 @@ +--- +source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs +expression: normalize_agent_models(&models, config.agent) +--- +nonEmpty: true +hasDefault: true +defaultInList: true +hasVariants: true +modelCount: 4 +ids: + - deep + - free + - rush + - smart +defaultModel: smart +variants: + - high + - medium + - xhigh diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap new file mode 100644 index 0000000..d493d4a --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_claude.snap @@ -0,0 +1,8 @@ +--- +source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs +expression: normalize_agent_models(&models, config.agent) +--- +nonEmpty: true +hasDefault: true +defaultInList: true +hasVariants: "" diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap new file mode 100644 index 0000000..977a38c --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_codex.snap @@ -0,0 +1,8 @@ +--- +source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs +expression: normalize_agent_models(&models, config.agent) +--- +nonEmpty: true +hasDefault: true +defaultInList: true +hasVariants: true diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_mock.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_mock.snap new file mode 100644 index 0000000..f5e2b0a --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_mock.snap @@ -0,0 +1,12 @@ +--- +source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs +expression: normalize_agent_models(&models, config.agent) +--- +nonEmpty: true +hasDefault: true +defaultInList: true +hasVariants: false +modelCount: 1 +ids: + - mock +defaultModel: mock diff --git a/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap new file mode 100644 index 0000000..d493d4a --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/agent_endpoints__agent_endpoints_snapshots@agent_models_opencode.snap @@ -0,0 +1,8 @@ +--- +source: server/packages/sandbox-agent/tests/http_sse_snapshots.rs +expression: normalize_agent_models(&models, config.agent) +--- +nonEmpty: true +hasDefault: true +defaultInList: true +hasVariants: "" diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_amp.snap.new b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agent_install_amp.snap.new 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_amp.snap.new @@ -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@agents_list_global.snap.new b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agents_list_global.snap.new new file mode 100644 index 0000000..259f870 --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__agent_endpoints_snapshots@agents_list_global.snap.new @@ -0,0 +1,12 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 129 +expression: normalize_agent_list(&agents) +--- +agents: + - id: amp + - id: claude + - id: codex + - id: mock + - id: opencode + - id: pi diff --git a/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__auth_snapshots@auth_valid_token_global.snap.new b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__auth_snapshots@auth_valid_token_global.snap.new new file mode 100644 index 0000000..37566aa --- /dev/null +++ b/server/packages/sandbox-agent/tests/http/snapshots/http_endpoints__agent_endpoints__auth_snapshots@auth_valid_token_global.snap.new @@ -0,0 +1,14 @@ +--- +source: server/packages/sandbox-agent/tests/http/agent_endpoints.rs +assertion_line: 59 +expression: "json!({\n \"status\": status.as_u16(), \"payload\": normalize_agent_list(&payload),\n})" +--- +payload: + agents: + - id: amp + - id: claude + - id: codex + - id: mock + - id: opencode + - id: pi +status: 200 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 587ebf3..2140ef3 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/events.test.ts @@ -115,7 +115,7 @@ describe("OpenCode-compatible Event Streaming", () => { await client.session.prompt({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Say hello" }], }, }); @@ -145,4 +145,98 @@ describe("OpenCode-compatible Event Streaming", () => { expect(response.data).toBeDefined(); }); }); + + describe("session.idle count", () => { + it("should emit exactly one session.idle for echo flow", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + + const eventStream = await client.event.subscribe(); + const idleEvents: any[] = []; + + // Wait for first idle, then linger 1s for duplicates + const collectIdle = new Promise((resolve, reject) => { + let lingerTimer: ReturnType | 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.type === "session.idle") { + idleEvents.push(event); + if (!lingerTimer) { + lingerTimer = setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 1000); + } + } + } + } catch { + // Stream ended + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "echo hello" }], + }, + }); + + await collectIdle; + expect(idleEvents.length).toBe(1); + }); + + it("should emit exactly one session.idle for tool flow", async () => { + const session = await client.session.create(); + const sessionId = session.data?.id!; + + const eventStream = await client.event.subscribe(); + const allEvents: any[] = []; + const idleEvents: any[] = []; + + const collectIdle = new Promise((resolve, reject) => { + let lingerTimer: ReturnType | 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) { + allEvents.push(event); + if (event.type === "session.idle") { + idleEvents.push(event); + if (!lingerTimer) { + lingerTimer = setTimeout(() => { + clearTimeout(timeout); + resolve(); + }, 1000); + } + } + } + } catch { + // Stream ended + } + })(); + }); + + await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "tool" }], + }, + }); + + await collectIdle; + + expect(idleEvents.length).toBe(1); + + // All tool parts should have been emitted before idle + const toolParts = allEvents.filter( + (e) => e.type === "message.part.updated" && e.properties?.part?.type === "tool" + ); + expect(toolParts.length).toBeGreaterThan(0); + }); + }); }); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts index f45db83..ce37371 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/messaging.test.ts @@ -45,7 +45,7 @@ describe("OpenCode-compatible Messaging API", () => { const response = await client.session.prompt({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Hello, world!" }], }, }); @@ -58,7 +58,7 @@ describe("OpenCode-compatible Messaging API", () => { const response = await client.session.prompt({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Say hello" }], }, }); @@ -72,7 +72,7 @@ describe("OpenCode-compatible Messaging API", () => { const response = await client.session.promptAsync({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Process this asynchronously" }], }, }); @@ -96,7 +96,7 @@ describe("OpenCode-compatible Messaging API", () => { await client.session.prompt({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Test message" }], }, }); @@ -116,7 +116,7 @@ describe("OpenCode-compatible Messaging API", () => { await client.session.prompt({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Test" }], }, }); @@ -144,7 +144,7 @@ describe("OpenCode-compatible Messaging API", () => { await client.session.promptAsync({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "Long running task" }], }, }); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts new file mode 100644 index 0000000..43a270d --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/models.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for OpenCode-compatible provider/model listing. + */ + +import { describe, it, expect, beforeAll, afterEach, beforeEach } from "vitest"; +import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +describe("OpenCode-compatible Model API", () => { + let handle: SandboxAgentHandle; + let client: OpencodeClient; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + handle = await spawnSandboxAgent({ opencodeCompat: true }); + client = createOpencodeClient({ + baseUrl: `${handle.baseUrl}/opencode`, + headers: { Authorization: `Bearer ${handle.token}` }, + }); + }); + + afterEach(async () => { + await handle?.dispose(); + }); + + it("should list models grouped by agent with real model IDs", async () => { + const response = await client.provider.list(); + const providers = response.data?.all ?? []; + const mockProvider = providers.find((entry) => entry.id === "mock"); + const ampProvider = providers.find((entry) => entry.id === "amp"); + const sandboxProvider = providers.find((entry) => entry.id === "sandbox-agent"); + expect(sandboxProvider).toBeUndefined(); + expect(mockProvider).toBeDefined(); + expect(ampProvider).toBeDefined(); + + const mockModels = mockProvider?.models ?? {}; + expect(mockModels["mock"]).toBeDefined(); + expect(mockModels["mock"].id).toBe("mock"); + expect(mockModels["mock"].family).toBe("Mock"); + + const ampModels = ampProvider?.models ?? {}; + expect(ampModels["smart"]).toBeDefined(); + expect(ampModels["smart"].id).toBe("smart"); + expect(ampModels["smart"].family).toBe("Amp"); + + expect(response.data?.default?.["mock"]).toBe("mock"); + expect(response.data?.default?.["amp"]).toBe("smart"); + }); + + it("should keep provider backends visible when discovery is degraded", async () => { + const response = await client.provider.list(); + const providers = response.data?.all ?? []; + const providerIds = new Set(providers.map((provider) => provider.id)); + + expect(providerIds.has("claude")).toBe(true); + expect(providerIds.has("codex")).toBe(true); + expect( + providerIds.has("opencode") || Array.from(providerIds).some((id) => id.startsWith("opencode:")) + ).toBe(true); + }); +}); 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 097d9fe..0742da7 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts @@ -57,7 +57,7 @@ describe("OpenCode-compatible Permission API", () => { it("should receive permission.asked and reply via global endpoint", async () => { await client.session.prompt({ sessionID: sessionId, - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: permissionPrompt }], }); @@ -77,7 +77,7 @@ describe("OpenCode-compatible Permission API", () => { it("should accept permission response for a session", async () => { await client.session.prompt({ sessionID: sessionId, - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: permissionPrompt }], }); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts index ae881fb..4868f98 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/questions.test.ts @@ -49,7 +49,7 @@ describe("OpenCode-compatible Question API", () => { it("should ask a question and accept a reply", async () => { await client.session.prompt({ sessionID: sessionId, - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: questionPrompt }], }); @@ -67,7 +67,7 @@ describe("OpenCode-compatible Question API", () => { it("should allow rejecting a question", async () => { await client.session.prompt({ sessionID: sessionId, - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: questionPrompt }], }); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts index 4cdda8f..ea45950 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts @@ -72,7 +72,7 @@ describe("OpenCode-compatible Tool + File Actions", () => { await client.session.prompt({ path: { id: sessionId }, body: { - model: { providerID: "sandbox-agent", modelID: "mock" }, + model: { providerID: "mock", modelID: "mock" }, parts: [{ type: "text", text: "tool" }], }, }); 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 new file mode 100644 index 0000000..2a091af --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new @@ -0,0 +1,131 @@ +--- +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: "" + item_id: "" + native_item_id: "" + 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: "" + item_id: "" + native_item_id: "" + seq: 6 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 10 + type: item.delta +second: + - item: + content_types: + - text + kind: message + role: user + status: in_progress + seq: 1 + type: item.started + - delta: + delta: "" + item_id: "" + native_item_id: "" + 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 + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 5 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 6 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + type: item.delta 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 new file mode 100644 index 0000000..d5c1b20 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new @@ -0,0 +1,237 @@ +--- +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: "" + item_id: "" + native_item_id: "" + 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: "" + item_id: "" + native_item_id: "" + seq: 6 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 10 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 11 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 12 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 13 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 14 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 15 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 16 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 17 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 18 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 19 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 20 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 21 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 22 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 23 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 24 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 25 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 26 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 27 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 28 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 29 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 30 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 31 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 32 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 33 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 34 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 35 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 36 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 37 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 38 + type: item.delta 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 new file mode 100644 index 0000000..f414271 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new @@ -0,0 +1,105 @@ +--- +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: "" + item_id: "" + native_item_id: "" + 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: "" + item_id: "" + native_item_id: "" + seq: 6 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 10 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 11 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 12 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 13 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 14 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 15 + type: item.delta +- question: + id: "" + options: 4 + status: requested + seq: 16 + type: question.requested 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 new file mode 100644 index 0000000..a6e0065 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@concurrency_events_mock.snap.new @@ -0,0 +1,105 @@ +--- +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: "" + item_id: "" + native_item_id: "" + 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: "" + item_id: "" + native_item_id: "" + seq: 6 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + 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: "" + item_id: "" + native_item_id: "" + 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: "" + item_id: "" + native_item_id: "" + seq: 6 + 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.new new file mode 100644 index 0000000..b63c3a7 --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__assert_session_snapshot@create_session_mock-2.snap.new @@ -0,0 +1,7 @@ +--- +source: server/packages/sandbox-agent/tests/sessions/session_lifecycle.rs +assertion_line: 12 +expression: value +--- +healthy: true +nativeSessionId: "" 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 new file mode 100644 index 0000000..da365cc --- /dev/null +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__session_lifecycle__run_http_events_snapshot@http_events_mock.snap.new @@ -0,0 +1,46 @@ +--- +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: "" + item_id: "" + native_item_id: "" + 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 +- item: + content_types: [] + kind: message + role: assistant + status: completed + seq: 6 + type: item.completed diff --git a/server/packages/universal-agent-schema/src/agents/amp.rs b/server/packages/universal-agent-schema/src/agents/amp.rs index 75326fc..7134896 100644 --- a/server/packages/universal-agent-schema/src/agents/amp.rs +++ b/server/packages/universal-agent-schema/src/agents/amp.rs @@ -4,9 +4,9 @@ use serde_json::Value; use crate::amp as schema; use crate::{ - ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, - ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy, UniversalEventData, - UniversalEventType, UniversalItem, + turn_completed_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, + ItemKind, ItemRole, ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy, + UniversalEventData, UniversalEventType, UniversalItem, }; static TEMP_ID: AtomicU64 = AtomicU64::new(1); @@ -99,6 +99,7 @@ pub fn event_to_universal( )); } schema::StreamJsonMessageType::Done => { + events.push(turn_completed_event()); 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 5e5c7bc..94ff081 100644 --- a/server/packages/universal-agent-schema/src/agents/claude.rs +++ b/server/packages/universal-agent-schema/src/agents/claude.rs @@ -3,9 +3,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::Value; use crate::{ - ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, - PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, SessionStartedData, - UniversalEventData, UniversalEventType, UniversalItem, + turn_completed_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, + ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, + SessionStartedData, UniversalEventData, UniversalEventType, UniversalItem, }; static TEMP_ID: AtomicU64 = AtomicU64::new(1); @@ -420,10 +420,13 @@ fn result_event_to_universal(event: &Value, session_id: &str) -> Vec String { diff --git a/server/packages/universal-agent-schema/src/lib.rs b/server/packages/universal-agent-schema/src/lib.rs index 7307eef..8431e35 100644 --- a/server/packages/universal-agent-schema/src/lib.rs +++ b/server/packages/universal-agent-schema/src/lib.rs @@ -8,8 +8,8 @@ pub use sandbox_agent_extracted_agent_schemas::{amp, claude, codex, opencode, pi pub mod agents; pub use agents::{ - amp as convert_amp, claude as convert_claude, codex as convert_codex, opencode as convert_opencode, - pi as convert_pi, + amp as convert_amp, claude as convert_claude, codex as convert_codex, + opencode as convert_opencode, pi as convert_pi, }; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -317,6 +317,27 @@ impl EventConversion { } } +pub fn turn_completed_event() -> 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, + }, + }), + ) + .synthetic() +} + pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem { UniversalItem { item_id: String::new(),