From 1b9552bebaf648e7dd989f3d48515d3c116c154e Mon Sep 17 00:00:00 2001 From: Alaeddine Messadi <10847134+AlaeddineMessadi@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:55:26 +0200 Subject: [PATCH 1/3] feat: add V1/V2 compatibility foundation and diagnostics --- .github/workflows/ci.yml | 11 +- .gitignore | 3 + CHANGELOG.md | 19 ++ MIGRATION.md | 103 +++++++ README.md | 22 +- docs/architecture.md | 16 +- docs/compatibility.md | 148 ++++++++++ docs/configuration.md | 11 +- docs/examples.md | 27 +- docs/releasing.md | 38 +++ docs/tools.md | 396 +++++++++++++++++++++++++-- package-lock.json | 222 ++++++++++++++- package.json | 11 +- plans/001-contracts.md | 25 ++ plans/002-detection-doctor.md | 25 ++ plans/003-v2-reads.md | 25 ++ plans/004-v2-writes-input.md | 45 ++++ plans/005-durable-v2-jobs.md | 25 ++ plans/006-release-verification.md | 33 +++ plans/README.md | 32 +++ plans/upstream-v2-command-issue.md | 24 ++ plans/verification.md | 134 +++++++++ scripts/generate-tool-docs.mjs | 51 +++- scripts/test-compat.mjs | 404 ++++++++++++++++++++++++++++ scripts/test-package.mjs | 282 +++++++++++++++++++ src/app.ts | 6 +- src/backend-detection.ts | 168 ++++++++++++ src/backends/adapter.ts | 84 ++++++ src/backends/capabilities.ts | 113 ++++++++ src/backends/contracts.ts | 33 +++ src/backends/normalization.ts | 38 +++ src/backends/operations.ts | 55 ++++ src/backends/v1-routes.ts | 272 +++++++++++++++++++ src/backends/v2-forms.ts | 64 +++++ src/backends/v2-guards.ts | 21 ++ src/backends/v2-mcp.ts | 30 +++ src/backends/v2-observation.ts | 218 +++++++++++++++ src/backends/v2.ts | 308 +++++++++++++++++++++ src/cli.ts | 31 +++ src/client.ts | 17 ++ src/doctor.ts | 112 ++++++++ src/helpers.ts | 16 +- src/index.ts | 51 +++- src/jobs.ts | 193 +++++++++++-- src/mcp-server.ts | 7 + src/prompts.ts | 2 +- src/resources.ts | 29 +- src/server-manager.ts | 47 +++- src/task-input.ts | 47 +++- src/task-transport.ts | 18 +- src/tools/config.ts | 8 +- src/tools/events.ts | 9 +- src/tools/file.ts | 13 +- src/tools/global.ts | 3 +- src/tools/input.ts | 28 +- src/tools/message.ts | 61 ++--- src/tools/misc.ts | 25 +- src/tools/project.ts | 7 +- src/tools/provider.ts | 24 +- src/tools/session.ts | 93 ++++--- src/tools/tui.ts | 19 +- src/tools/workflow.ts | 126 ++++----- src/version.ts | 3 + tests/backend-detection.test.ts | 94 +++++++ tests/capability-catalog.test.ts | 52 ++++ tests/compat-harness.test.ts | 145 ++++++++++ tests/doctor.test.ts | 150 +++++++++++ tests/server-manager.test.ts | 35 ++- tests/stdio-integration.test.ts | 3 +- tests/task-transport.test.ts | 2 +- tests/tools.test.ts | 2 +- tests/transport-reliability.test.ts | 20 ++ tests/v2-adapter.test.ts | 294 ++++++++++++++++++++ tests/v2-auth.test.ts | 197 ++++++++++++++ tests/v2-jobs-http.test.ts | 114 ++++++++ tests/v2-jobs.test.ts | 247 +++++++++++++++++ tests/v2-task-transport.test.ts | 199 ++++++++++++++ 77 files changed, 5737 insertions(+), 348 deletions(-) create mode 100644 MIGRATION.md create mode 100644 docs/compatibility.md create mode 100644 plans/001-contracts.md create mode 100644 plans/002-detection-doctor.md create mode 100644 plans/003-v2-reads.md create mode 100644 plans/004-v2-writes-input.md create mode 100644 plans/005-durable-v2-jobs.md create mode 100644 plans/006-release-verification.md create mode 100644 plans/README.md create mode 100644 plans/upstream-v2-command-issue.md create mode 100644 plans/verification.md create mode 100644 scripts/test-compat.mjs create mode 100644 scripts/test-package.mjs create mode 100644 src/backend-detection.ts create mode 100644 src/backends/adapter.ts create mode 100644 src/backends/capabilities.ts create mode 100644 src/backends/contracts.ts create mode 100644 src/backends/normalization.ts create mode 100644 src/backends/operations.ts create mode 100644 src/backends/v1-routes.ts create mode 100644 src/backends/v2-forms.ts create mode 100644 src/backends/v2-guards.ts create mode 100644 src/backends/v2-mcp.ts create mode 100644 src/backends/v2-observation.ts create mode 100644 src/backends/v2.ts create mode 100644 src/cli.ts create mode 100644 src/doctor.ts create mode 100644 src/version.ts create mode 100644 tests/backend-detection.test.ts create mode 100644 tests/capability-catalog.test.ts create mode 100644 tests/compat-harness.test.ts create mode 100644 tests/doctor.test.ts create mode 100644 tests/v2-adapter.test.ts create mode 100644 tests/v2-auth.test.ts create mode 100644 tests/v2-jobs-http.test.ts create mode 100644 tests/v2-jobs.test.ts create mode 100644 tests/v2-task-transport.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf911e0..113b5d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, macos-latest, windows-latest] node: [22, 24] runs-on: ${{ matrix.os }} steps: @@ -24,6 +24,13 @@ jobs: cache: npm - run: npm ci - run: npm test + - name: Coverage gate + if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + run: npm run test:coverage + - name: Dependency vulnerability gate + if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + run: npm audit - run: npm run docs:check - - run: npm pack --dry-run + - run: npm run test:package + - run: npm run test:compat - run: git diff --check diff --git a/.gitignore b/.gitignore index 82effd7..edf379a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ pr_comments.txt # Local test coverage reports coverage/ + +# Locally prepared release candidate archives +artifacts/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8020c61..c616c65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.1.0-rc.1] - 2026-09-17 + +Evaluation candidate for the V1/V2 compatibility foundation. The original 65-operation V2 completion gate remains blocked by upstream command correlation; this is not a completed milestone declaration. + +### Added + +- Typed backend operations, normalized results, and one compatibility manifest for all 87 tools, 10 resources, 4 templates, and 6 prompts. V2 currently maps 64 tools, retains 22 explicit V1-only exceptions, and rejects `command_execute` before mutation because V2 2.0.6 supplies no execution receipt or caller-assigned command ID. +- Automatic or forced backend detection, read-only V2 service discovery, ownership reporting, and standalone `doctor`/`--check`, help, and version commands. +- V2 queue submission identity, durable log observation with saved correlation state, versioned job storage, and shared ordinary-tool/native-Task workflows. +- V2 model/agent preservation, scoped permissions, typed form values, exact OAuth attempt IDs, explicit diff ranges, staged reverts, pagination metadata, and project-filtered events. +- macOS CI, production archive installation tests, and isolated V1/V2 compatibility checks. + +### Compatibility and validation + +- V1 behavior and stored record format remain supported. Authentication errors and ambiguous mutations never trigger fallback submission or replay. +- Model-free integration exercised `opencode-ai@1.18.31` and `@opencode/cli@2.0.6` on Node.js 22.23.2, macOS arm64. The V2 client dependency is pinned to `@opencode/client@2.0.6`. +- Live provider inference, live job execution, OAuth completion, and interactive permission/form flows were not verified. Those paths have fixture-backed contract and recovery tests; client UI compatibility is not claimed from those fixtures. +- See [migration guidance](MIGRATION.md) and the generated [compatibility matrix](docs/compatibility.md) for guarded options and the upstream blocker. No package has been published by this change. + ## [3.0.0] - 2026-09-16 ### Breaking changes diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..77ee6fe --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,103 @@ +# OpenCode V1/V2 compatibility migration + +The compatibility foundation preserves the existing V1 SDK and V1 job records. It adds the pinned `@opencode/client@2.0.6` adapter, explicit backend detection, and a read-only doctor. An API mapping is not proof of live end-to-end behavior: consult the [release verification guide](docs/releasing.md) and record actual backend check results separately. + +## Select a backend + +Keep the existing MCP stdio command and tool profile. `OPENCODE_BACKEND` defaults to `auto`; set `v1` or `v2` to require that backend's validated contract. `OPENCODE_BASE_URL`, when present, is authoritative. Detection never switches from a failed explicit URL to another server. + +Without an explicit URL, the bridge probes `http://127.0.0.1:4096`, then checks an existing V2 service registration using the pinned client's read-only discovery contract. Present but invalid, unauthorized, or unresponsive registrations prevent alternate startup. HTTP 401/403 ends detection. Credentials stay in memory and are excluded from doctor output. + +`OPENCODE_AUTO_SERVE=true` continues to allow an owned local V1 child when no existing backend or service registration is present. It never enables V2 `Service.ensure()` or stops a shared service. An owned child stops with MCP; externally managed and discovered servers can continue work while their processes remain alive. + +## Check readiness + +For a source build: + +```bash +npm run build +node dist/index.js doctor --json --directory /absolute/project/on/the/server +``` + +`doctor` and `--check` perform no inference, session creation, configuration writes, or startup. They share a 15-second deadline. A supplied directory is interpreted on the OpenCode host; omit it to skip the optional project check. External connections do not require an OpenCode executable on the MCP host. Exit codes are `0` for readiness, `1` for failed checks, and `2` for invalid arguments. + +The versioned JSON reports `integrationTargetVersion` and `matchesIntegrationTarget`. These describe the intended version baseline, not a completed live test. Provider configuration is inspected without exercising provider credentials. Ordinary stdio can expose its catalog while detection is unresolved, but backend calls fail before mutation with `BACKEND_UNAVAILABLE`; repair the connection and restart MCP. + +## Backend-specific behavior + +The [generated inventory](docs/compatibility.md) records 87 tool names, with 64 V2 mappings, 22 deliberate V1-only exceptions, and one upstream-blocked command operation. Both profiles retain their names and schemas. Calls that cannot preserve their requested semantics return `UNSUPPORTED_CAPABILITY` before mutation rather than substituting weaker behavior. + +| Operation | V2 behavior | +|---|---| +| Prompt output | `format` may be omitted or text. JSON-schema output, per-prompt `system`, and `noReply: true` are rejected. | +| Existing sessions | Model, variant, and agent must match the existing selections. Create a new session to choose different values. | +| Session creation | `parentID` is rejected; fork explicitly. A variant requires an explicit provider/model pair. | +| Fork | A message boundary is **before** that message. The result reports the boundary. | +| Diff/review | Supply explicit `from` and `to` message IDs; no range silently means “all session changes.” Cross-location ranges are rejected. | +| Revert | Whole-message revert is staged and remains reversible through `opencode_session_unrevert`; the bridge never automatically commits it. V2 2.0.6 implicitly commits a staged revert when prompting or compacting, so the bridge rejects those operations while a revert is staged. Clear it with `opencode_session_unrevert`, or explicitly commit it outside the bridge, before continuing. Individual `partID` reverts are rejected. | +| Permissions | `once` affects the specified request. `always` requires `scope: "project"`; `reject` requires `scope: "session"` and rejects all pending permissions in that session. Present that scope before forwarding the user's decision. | +| Questions/forms | Use `sessionId`, `requestId`, and typed, field-keyed `values` from the returned form fields. Legacy selected-label `answers` are not silently converted. | +| Authentication | `opencode_auth_set` accepts API keys through an integration with a published key method. Use the explicit OAuth workflow for OAuth. | +| Slash command execution | `opencode_command_execute` is blocked: V2 2.0.6 supplies no caller ID or submission receipt that allows exact completion correlation. No command is submitted. | + +Configuration, providers, sessions, messages, and events may include explicitly labeled V2 raw data or pagination metadata. Consumers should use the bridge's normalized fields and inspect `isError`, structured state, and completeness instead of assuming V1 internals. + +## V2 OAuth and explicit input + +Discover the provider's authentication methods first. Use the returned integration/method identifiers, then retain the exact authorization attempt: + +```javascript +opencode_provider_auth_methods({}) +opencode_provider_oauth_authorize({ + providerId: "", + integrationId: "", + methodId: "" + // Include values only when the selected method's form requires them. +}) +// Follow the returned authorization instructions. Retain structuredContent.data.attemptId. +opencode_provider_oauth_callback({ + providerId: "", + callbackData: { + integrationId: "", + attemptId: "" + // Include code only if the authorization method requests one. + } +}) +``` + +For a form whose returned fields are `target` (string), `count` (integer), and `enabled` (boolean), send the user's selected values without coercing their types: + +```javascript +opencode_job_input({ + jobId: "", + responses: [{ id: "", kind: "question", values: { + target: "web", count: 2, enabled: false + } }] +}) +``` + +Use the fields of the actual pending form; this example does not define a universal schema. Conditional fields, patterns, or other forms that cannot be faithfully represented by MCP elicitation are returned for manual typed responses. A native task remains `working` with `pendingInputs` and manual instructions until that input is supplied. Representable forms use native `inputRequests`; both routes validate against the original form. + +Permission decisions require the user's explicit approval of their scope: + +```javascript +// Only after the user approves saved matching permissions across this project: +opencode_job_input({ jobId: "", responses: [ + { id: "", kind: "permission", reply: "always", scope: "project" } +] }) + +// Only after the user chooses to reject ALL pending permissions in this session: +opencode_job_input({ jobId: "", responses: [ + { id: "", kind: "permission", reply: "reject", scope: "session" } +] }) +``` + +For one request, use `reply: "once"`. Native permission forms require a matching scope acknowledgment (`request`, `project`, or `session`) and have no preselected approval. Dismissing a native V2 permission form does not infer a session-wide rejection. + +## Durable jobs + +V1 records and behavior remain readable. V2 records identify their backend and preserve the correlation evidence needed to observe the submitted work. A lost response produces an unknown outcome; reconnect and inspect the existing job instead of resubmitting. Observation cancellation does not request a remote abort, and record expiration does not cancel remote work. + +The same `JobService` serves ordinary `opencode_fire` / `opencode_job_get` / `opencode_job_input` calls and native `opencode_run` / `tasks/get` / `tasks/update`. A native `taskId` is the ordinary `jobId`; changing observation interface does not dispatch a second task. V1 records keep their original layout, while V2 records live in the server/caller scope's `v2/` subdirectory under `OPENCODE_TASK_STORE` (or its default root). Active jobs require their recorded backend; cached terminal results remain readable without re-executing the work. + +Persistent records preserve observation handles and results. They do not checkpoint model execution or keep a stopped OpenCode process alive. Separate sessions share filesystem changes unless you explicitly use separate directories or Git worktrees. diff --git a/README.md b/README.md index f974ba9..5e05107 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ opencode-mcp connects Claude, Cursor, VS Code, and other MCP clients to OpenCode > Version 3.0.0 requires **Node.js 22 or newer**. Upgrading from 2.x? See the [migration notes](CHANGELOG.md#300---2026-09-16). +This source branch adds an OpenCode V1/V2 compatibility foundation. It retains the V1 API and adds the pinned V2 client contract. V2 currently maps **64 tools**, keeps **22 V1-only exceptions**, and blocks **1 command operation** whose completion cannot be correlated safely. See the [generated compatibility inventory](docs/compatibility.md) and [V1/V2 migration guide](MIGRATION.md). This is not a claim that every live backend workflow has been verified. + ## Quick Start -Install [OpenCode](https://opencode.ai/docs/) and start its server from your project: +For OpenCode V1, install [OpenCode](https://opencode.ai/docs/) and start its server from your project: ```bash opencode serve --hostname 127.0.0.1 --port 4096 @@ -20,6 +22,8 @@ opencode serve --hostname 127.0.0.1 --port 4096 If you use the TUI, start it with `opencode --port 4096` and share that server. Set `OPENCODE_BASE_URL` for another endpoint. +For V2, start OpenCode separately and supply its URL, or let this bridge discover its existing local service registration. The bridge never starts or stops the shared V2 service. `OPENCODE_BACKEND=auto` recognizes the running backend; set `v1` or `v2` to require a specific contract. An explicit `OPENCODE_BASE_URL` is always authoritative. + For Claude Code: ```bash @@ -43,6 +47,14 @@ Restart the client and call `opencode_setup`. Choose a provider from its configu [Client-specific configuration](docs/configuration.md) includes VS Code, Windsurf, Continue, Zed, and Amazon Q. To test unreleased changes, [build from source](CONTRIBUTING.md) and configure your client to run `node` with the absolute path to `dist/index.js`. +Check a source build before connecting your MCP client: + +```bash +node dist/index.js doctor --json --directory /absolute/path/on/opencode/server +``` + +Doctor performs only read operations, with a 15-second total deadline and no inference or server startup. It reports version recognition, provider configuration, project access, and lifecycle. Exit codes are `0` ready, `1` failed checks, and `2` invalid arguments. `--check` is an alias; no arguments still starts stdio. + ## Choose a Workflow | Need | Tools | @@ -69,7 +81,7 @@ Async results distinguish `accepted`, `running`, `input_required`, `completed`, Modern clients can use the MCP Tasks extension for `opencode_run`. Clients without that extension use ordinary tools, including `opencode_fire` and `opencode_check`. Task status is retrieved by polling; this package does not promise to wake an idle assistant with completion notifications. -Tools retain readable text and provide structured results for clients that consume them. Prompt tools accept optional model variants and OpenCode structured-output formats. See the [generated tools reference](docs/tools.md) and [examples](docs/examples.md). +Tools retain readable text and provide structured results for clients that consume them. V1 accepts OpenCode structured-output formats. V2 rejects JSON-schema output and preserves the selected model, variant, and agent when continuing an existing session. See [migration restrictions](MIGRATION.md), the [generated tools reference](docs/tools.md), and [examples](docs/examples.md). ## Multi-Project Use @@ -85,9 +97,10 @@ All settings are optional; an OpenCode server must already be running by default | Variable | Purpose | |---|---| -| `OPENCODE_BASE_URL` | Server endpoint; defaults to `http://127.0.0.1:4096` | +| `OPENCODE_BACKEND` | `auto` (default), `v1`, or `v2` | +| `OPENCODE_BASE_URL` | Authoritative endpoint; otherwise probe loopback, then discover an existing local V2 service | | `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD` | Optional server HTTP authentication | -| `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching a local server | +| `OPENCODE_AUTO_SERVE` | Set to `true` to opt in to launching an owned local V1 child | | `OPENCODE_DEFAULT_PROVIDER`, `OPENCODE_DEFAULT_MODEL` | Default prompt provider/model pair | | `OPENCODE_TOOL_PROFILE` | `full` (default) or a smaller `essential` tool set | | `OPENCODE_TASK_STORE` | Override the local directory for persisted job records | @@ -115,6 +128,7 @@ Live smoke checks use a disposable project and owned session. Inference is opt-i - [Getting started](docs/getting-started.md) - [Configuration](docs/configuration.md) +- [V1/V2 migration](MIGRATION.md) and [capability inventory](docs/compatibility.md) - [Tools reference](docs/tools.md) - [Resources](docs/resources.md) and [prompts](docs/prompts.md) - [Examples](docs/examples.md) diff --git a/docs/architecture.md b/docs/architecture.md index c86ecb3..8507b03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,8 @@ opencode-mcp is a stdio bridge. OpenCode owns coding sessions and model executio |---|---| | Entry point and MCP adapter | Register the catalog, select a profile, negotiate protocol support, manage stdio | | `client.ts` | SDK-backed HTTP requests, error handling, bounded retries, SSE, directory routing | +| `backends/` | V1/V2 domain adapters, normalized records, capability inventory, and explicit compatibility guards | +| `backend-detection.ts`, `doctor.ts` | Contract validation, read-only discovery, lifecycle identity, and bounded readiness diagnostics | | `server-manager.ts` | Health probe, optional local child startup, owned-child shutdown | | `async.ts` | Shared request deadlines, cancellation, and abortable waits | | Job service and job tools | Persist handles/results, correlate completion, observe, cancel, and request required input | @@ -23,11 +25,11 @@ opencode-mcp is a stdio bridge. OpenCode owns coding sessions and model executio | `tools/` | OpenCode API tools and combined workflows | | `resources.ts`, `prompts.ts` | Data reads and reusable workflow instructions | -The full catalog is available by default. The essential profile advertises a smaller group of common workflows. Both profiles use the same implementations and OpenCode permission model. +The full catalog is available by default. The essential profile advertises a smaller group of common workflows. Both profiles keep their names and use the selected backend's OpenCode permission model. The [generated inventory](compatibility.md) records supported, deliberately V1-only, and upstream-blocked operations. V2 calls never fall through to guessed V1 endpoints. ## Async State and Recovery -The async workflow distinguishes submission from completion. A job can be `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, or `unknown`. Completion is correlated with the submitted work and an assistant message; an omitted idle entry in OpenCode's status map is not sufficient evidence by itself. +The async workflow distinguishes submission from completion. A job can be `accepted`, `running`, `input_required`, `completed`, `failed`, `cancelled`, or `unknown`. Completion is correlated with the submitted work; an omitted idle entry in OpenCode's status map is not sufficient evidence by itself. V1 observation uses submitted message/assistant correlation. V2 uses its inbox and execution/event identities, retaining the evidence needed after reconnect. V1 record interpretation is preserved; V2 records carry their backend identity. `opencode_fire` returns promptly after dispatch. `opencode_check` observes work; `opencode_wait` and `opencode_run` wait within a bounded observation period. Expiring that period returns progress with a timeout indication. It does not abort the OpenCode session. Requests for permission or answers surface as `input_required` so the caller can respond explicitly. Cancellation of an observation stops waiting; use the job-cancel or session-abort tool to stop remote work. @@ -51,7 +53,13 @@ Project-scoped calls include an absolute `directory` on the OpenCode server. Val Authentication routes are global. `opencode_project_init` is a separate local-filesystem operation: it accepts `path`, checks protected roots and symlinks, creates the directory if necessary, then opens it through OpenCode. It does not create remote projects. -On startup the process probes the configured endpoint. With automatic startup enabled, the SDK can launch `opencode serve` on a local loopback HTTP endpoint. Concurrent starts are coalesced by endpoint. Shutdown handlers stop only owned child processes; externally managed servers remain running. +On startup the process validates the selected backend's health/info contract. An explicit endpoint is authoritative. Otherwise, it probes the default loopback endpoint and checks the existing V2 registration. The pinned 2.0.6 discovery implementation hides authentication failures as an absent result, so the bridge preflights the documented registration file with a bounded authenticated probe before `Service.discover()` selects the service. Present-but-invalid registrations fail closed. It never calls V2 `Service.ensure()` or `Service.stop()`. + +With automatic startup explicitly enabled and no existing endpoint or registration, the V1 SDK can launch `opencode serve` on a local loopback HTTP endpoint. Concurrent starts are coalesced by endpoint. Shutdown handlers stop only owned child processes; externally managed servers remain running. A failed owned-child startup closes only that child. HTTP 401/403 is terminal and never enables another probe or unauthenticated retry. + +Backend identity records kind, version, connection source, process ownership, and whether execution can survive MCP disconnect. In auto mode, unresolved startup can leave the stdio catalog available, but backend operations return `BACKEND_UNAVAILABLE` before mutation. Reconnect after repairing the server. Forced backend mismatches and authentication failures terminate startup. + +Doctor shares a 15-second deadline across configuration, detection, and read-only readiness checks. It performs no startup, inference, session creation, or configuration writes. Its versioned JSON uses fixed sanitized messages rather than raw server bodies; reported provider configuration is not credential verification. ## Transport and Observation Costs @@ -73,6 +81,6 @@ Unit tests cover formatting, validation, job state, and tool behavior. Local HTT ### Shared-session cancellation -OpenCode aborts a whole session. Job cancellation refuses a known newer turn, but another client can submit work between that check and the abort request. Use the default dedicated session per job when cancellation must not affect concurrent work. +V1 OpenCode aborts a whole session. Job cancellation refuses a known newer turn, but another client can submit work between that check and the abort request. V2 can cancel a queued item by its exact inbox ID; interrupting a running execution is also session-wide. V2 checks execution ownership and additional queued work before interrupting, but another client can submit between that check and the write. Use the default dedicated session per job on either backend when cancellation must not affect concurrent work. Uncertain backend responses remain uncertain and are not replayed. A native `tasks/cancel` acknowledgement records cancellation intent; it does not prove execution has stopped. Poll `tasks/get` for the eventual state, especially after a lost backend response. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..31a4272 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,148 @@ +# Backend Compatibility + + + +The catalog contains **87 tools**. V1 supports all 87; V2 has **64 supported tools**, **22 planned V1-only exceptions**, and **1 operation blocked by an upstream contract limitation**. + +The full and essential profiles keep their existing names and schemas. An unavailable V2 call returns a structured `UNSUPPORTED_CAPABILITY` error before mutation; it never falls back to a V1 route. Resource and prompt inventories are included below. + +## Meaning of support + +- **supported:** an adapter mapping exists. Individual options may still require explicit preflight rejection; see [migration](../MIGRATION.md). This status does not claim that every option or live execution has been verified. +- **unsupported:** an intentional V1-only exception for this milestone. +- **blocked:** a required operation could not preserve the public behavior with the pinned upstream contract. It remains an outstanding compatibility limitation. + +The integration targets are OpenCode V1 `opencode-ai@1.18.31` and OpenCode V2 `@opencode/cli@2.0.6`, with `@opencode/client@2.0.6` pinned. Version recognition and fixture coverage are separate from [live verification evidence](releasing.md). Doctor reports the target version without claiming that a live integration check has passed. + +V2 whole-message reverts remain staged and reversible. Because upstream 2.0.6 automatically commits staged reverts when prompting or compacting, the bridge rejects those operations until the revert is cleared with `opencode_session_unrevert` or explicitly committed outside the bridge. + +## Required upstream follow-up + +- **`opencode_command_execute`**: V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. No detached command or guessed completion result is substituted. + +## Tools (87) + +| Name | V1 | V2 | Notes | +|---|---|---|---| +| `opencode_agent_list` | supported | supported | | +| `opencode_ask` | supported | supported | | +| `opencode_auth_set` | supported | supported | | +| `opencode_check` | supported | supported | | +| `opencode_command_execute` | supported | blocked | V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. | +| `opencode_command_list` | supported | supported | | +| `opencode_config_get` | supported | supported | | +| `opencode_config_providers` | supported | supported | | +| `opencode_config_update` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_context` | supported | supported | | +| `opencode_conversation` | supported | supported | | +| `opencode_events_poll` | supported | supported | | +| `opencode_file_list` | supported | supported | | +| `opencode_file_read` | supported | supported | | +| `opencode_file_status` | supported | supported | | +| `opencode_find_file` | supported | supported | | +| `opencode_find_symbol` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_find_text` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_fire` | supported | supported | | +| `opencode_formatter_status` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_health` | supported | supported | | +| `opencode_instance_dispose` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_job_cancel` | supported | supported | | +| `opencode_job_get` | supported | supported | | +| `opencode_job_input` | supported | supported | | +| `opencode_job_list` | supported | supported | | +| `opencode_log` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_lsp_status` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_mcp_add` | supported | supported | | +| `opencode_mcp_status` | supported | supported | | +| `opencode_message_get` | supported | supported | | +| `opencode_message_list` | supported | supported | | +| `opencode_message_send` | supported | supported | | +| `opencode_message_send_async` | supported | supported | | +| `opencode_path_get` | supported | supported | | +| `opencode_permission_list` | supported | supported | | +| `opencode_project_current` | supported | supported | | +| `opencode_project_init` | supported | supported | | +| `opencode_project_list` | supported | supported | | +| `opencode_provider_auth_methods` | supported | supported | | +| `opencode_provider_list` | supported | supported | | +| `opencode_provider_models` | supported | supported | | +| `opencode_provider_oauth_authorize` | supported | supported | | +| `opencode_provider_oauth_callback` | supported | supported | | +| `opencode_provider_test` | supported | supported | | +| `opencode_question_list` | supported | supported | | +| `opencode_question_reject` | supported | supported | | +| `opencode_question_reply` | supported | supported | | +| `opencode_reply` | supported | supported | | +| `opencode_review_changes` | supported | supported | | +| `opencode_run` | supported | supported | | +| `opencode_session_abort` | supported | supported | | +| `opencode_session_children` | supported | supported | | +| `opencode_session_create` | supported | supported | | +| `opencode_session_delete` | supported | supported | | +| `opencode_session_diff` | supported | supported | | +| `opencode_session_fork` | supported | supported | | +| `opencode_session_get` | supported | supported | | +| `opencode_session_init` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_session_list` | supported | supported | | +| `opencode_session_permission` | supported | supported | | +| `opencode_session_revert` | supported | supported | | +| `opencode_session_search` | supported | supported | | +| `opencode_session_share` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_session_status` | supported | supported | | +| `opencode_session_summarize` | supported | supported | | +| `opencode_session_todo` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_session_unrevert` | supported | supported | | +| `opencode_session_unshare` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_session_update` | supported | supported | | +| `opencode_sessions_overview` | supported | supported | | +| `opencode_setup` | supported | supported | | +| `opencode_shell_execute` | supported | supported | | +| `opencode_status` | supported | supported | | +| `opencode_tool_ids` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tool_list` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_append_prompt` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_clear_prompt` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_execute_command` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_open_help` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_open_models` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_open_sessions` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_open_themes` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_show_toast` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_tui_submit_prompt` | supported | unsupported | No equivalent published V2 API; V1-only capability. | +| `opencode_vcs_info` | supported | supported | | +| `opencode_wait` | supported | supported | | + +## Static resources (10) + +| Name | V1 | V2 | Notes | +|---|---|---|---| +| `agents` | supported | supported | | +| `commands` | supported | supported | | +| `config` | supported | supported | | +| `file-status` | supported | supported | | +| `health` | supported | supported | | +| `mcp-servers` | supported | supported | | +| `project-current` | supported | supported | | +| `providers` | supported | supported | | +| `sessions` | supported | supported | | +| `vcs` | supported | supported | | + +## Resource templates (4) + +| Name | V1 | V2 | Notes | +|---|---|---|---| +| `project-scoped` | supported | supported | | +| `session-messages-scoped` | supported | supported | | +| `session-scoped` | supported | supported | | +| `sessions-scoped` | supported | supported | | + +## Prompts (6) + +| Name | V1 | V2 | Notes | +|---|---|---|---| +| `opencode-best-practices` | supported | supported | | +| `opencode-code-review` | supported | supported | | +| `opencode-debug` | supported | supported | | +| `opencode-implement` | supported | supported | | +| `opencode-project-setup` | supported | supported | | +| `opencode-session-summary` | supported | supported | | diff --git a/docs/configuration.md b/docs/configuration.md index a271a91..4e6eb77 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -6,10 +6,11 @@ An OpenCode server must already be running unless automatic startup is explicitl | Variable | Default | Description | |---|---|---| -| `OPENCODE_BASE_URL` | `http://127.0.0.1:4096` | OpenCode HTTP endpoint | +| `OPENCODE_BACKEND` | `auto` | Detect the backend, or require `v1` or `v2` | +| `OPENCODE_BASE_URL` | unset | Authoritative OpenCode HTTP(S) endpoint; if absent, probe loopback `http://127.0.0.1:4096` then discover an existing local V2 service | | `OPENCODE_SERVER_USERNAME` | `opencode` | Username when HTTP authentication is enabled | | `OPENCODE_SERVER_PASSWORD` | unset | HTTP password; set matching credentials on OpenCode and MCP | -| `OPENCODE_AUTO_SERVE` | `false` | Set exactly `true` to allow a local child server to start | +| `OPENCODE_AUTO_SERVE` | `false` | Set exactly `true` to allow an owned local V1 child; never starts V2 | | `OPENCODE_DEFAULT_PROVIDER` | unset | Default prompt provider ID | | `OPENCODE_DEFAULT_MODEL` | unset | Default prompt model ID; configure both defaults together | | `OPENCODE_TOOL_PROFILE` | `full` | `full` or `essential`; changes the advertised tool set | @@ -17,6 +18,10 @@ An OpenCode server must already be running unless automatic startup is explicitl Choose provider/model IDs from `opencode_setup` and `opencode_provider_models`. Authentication credentials are global to OpenCode; `directory` does not make provider credentials project-specific. +An explicit URL never falls back to a different server. URLs must be HTTP(S), without embedded credentials, queries, or fragments; configure authentication using the environment variables above. HTTP 401/403 ends detection without alternate probes or startup. A present but invalid or unresponsive local service registration also prevents replacement startup. V2 discovery uses the pinned 2.0.6 registration-file contract and `Service.discover()`; it never calls `Service.ensure()` or `Service.stop()`. + +Run `opencode-mcp doctor --json [--directory ABSOLUTE_SERVER_PATH]` after installing this build, or `node dist/index.js doctor` from source. Doctor shares a 15-second deadline across checks, performs no inference or writes, and ignores auto-start permission. Project access is checked only when a directory is supplied. The report identifies backend, recognized version, connection source, ownership, and disconnect behavior; credentials and raw upstream responses are omitted. `--check` is an alias. Exit `0` means ready, `1` means a failed check, and `2` means invalid CLI arguments. + The `essential` profile keeps the common delegation, observation, and required-input workflows available with fewer tool definitions. Select `full` for low-level API tools, TUI control, or provider administration. The profile is a discovery choice, not a security boundary: coding workflows can still modify files or run commands through OpenCode according to its permissions. Job records hold local operational metadata and results, independent of OpenCode's session storage. The default root is `$XDG_STATE_HOME/opencode-mcp/tasks` (falling back to `~/.local/state/opencode-mcp/tasks`); Windows uses `%LOCALAPPDATA%/opencode-mcp/tasks`. Records are partitioned by server and caller scope. Records expire 24 hours after creation; expiry removes the handle and does not abort the OpenCode session. Keep the task store private to your user account and persist it if the MCP process is ephemeral. See [architecture](architecture.md) for recovery behavior and retention. @@ -150,6 +155,6 @@ opencode serve --hostname 127.0.0.1 --port 4096 Or share the TUI's server by starting it with `opencode --port 4096`. For custom flags, start OpenCode manually. `OPENCODE_SERVE_ARGS` is unsupported by the SDK launcher. -To opt in to a separate local child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. Automatic startup accepts loopback HTTP endpoints only and shuts down only the child it launched. An existing external server remains running when the MCP client disconnects. +To opt in to a separate local V1 child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. Automatic startup accepts loopback HTTP endpoints only and shuts down only the child it launched. An existing external server or discovered V2 service remains running when the MCP client disconnects. Start V2 separately and use its URL or existing local registration. When other TUI instances are running, prefer a shared explicitly configured server. [Issue #18](https://github.com/AlaeddineMessadi/opencode-mcp/issues/18) reports hangs when a second server shares OpenCode storage; the underlying cause has not been confirmed. MCP does not scan processes to select a TUI. diff --git a/docs/examples.md b/docs/examples.md index 492c1bd..7418adb 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,6 +1,6 @@ # Usage Examples -These examples describe version 3.0.0. Replace provider/model placeholders with IDs discovered from `opencode_setup` and `opencode_provider_models`. All `directory` values refer to absolute paths on the OpenCode server. +These examples describe the V1/V2 compatibility foundation. Replace provider/model placeholders with IDs discovered from `opencode_setup` and `opencode_provider_models`. All `directory` values refer to absolute paths on the OpenCode server. See [migration](../MIGRATION.md) for backend-specific restrictions, V2 OAuth, and typed input examples. ## Quick Question and Follow-Up @@ -21,7 +21,7 @@ opencode_reply({ }) ``` -If both default provider/model environment variables are configured, you may omit them from individual calls. An optional `variant` selects an available model variant; use a value supported by the chosen provider/model. +Configured provider/model defaults apply to new sessions. Existing V2 sessions retain their model, variant, and agent; omitting those arguments continues the existing selections, and conflicting overrides are rejected. An optional `variant` on new work must be supported by the selected provider/model. ## Background Work and Recovery @@ -70,7 +70,7 @@ When a job reports `input_required`, inspect its requests. July 2026 MCP clients opencode_job_input({ jobId: "" }) ``` -Or send an explicit response after the user has chosen it: +For V1, send an explicit response after the user has chosen it: ```javascript opencode_job_input({ @@ -86,7 +86,11 @@ Question answers are an array per question, each containing the selected answers Lower-level callers can use `opencode_question_list`, `opencode_question_reply({requestId, answers, directory})`, and `opencode_question_reject({requestId, directory})`. Existing `opencode_permission_list` and `opencode_session_permission` remain available. -## Request Structured Model Output +V2 uses typed field-keyed `values` instead of legacy question `answers`. Saved project approvals and session-wide rejections require explicit `scope`; dismissing a native permission form does not reject every pending request. Follow the [V2 input examples](../MIGRATION.md#v2-oauth-and-explicit-input), using the fields and scopes returned for the actual pending request. + +## Request Structured Model Output on V1 + +V2 rejects JSON-schema output before dispatch; omit `format` or use text on V2. The following example and `StructuredOutput` permission guidance apply to V1. ```javascript opencode_run({ @@ -141,6 +145,8 @@ Create the directories or worktrees first. Sessions alone do not isolate file ch ## Review Completed Work +On V1, a session-wide review is available without a range: + ```javascript opencode_review_changes({ directory: "/home/user/project", @@ -155,3 +161,16 @@ opencode_conversation({ ``` Review the changes and test results before accepting the work. Resources and prompts offer additional entry points; see the [resources reference](resources.md), [prompts](prompts.md), and [generated tool schemas](tools.md). + +On V2, select an explicit message range rather than treating a default latest-turn diff as the entire session: + +```javascript +opencode_review_changes({ + directory: "/home/user/project", + sessionId: "", + from: "", + to: "" +}) +``` + +V2 whole-message reverts are staged and reversible; the bridge does not commit them automatically. Use `opencode_session_unrevert` to clear a staged revert. diff --git a/docs/releasing.md b/docs/releasing.md index 5ec140f..93d0324 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,5 +1,41 @@ # Release and live verification +## V1/V2 evidence requirements + +The compatibility foundation targets `opencode-ai@1.18.31` for V1 and `@opencode/cli@2.0.6` for V2, with `@opencode/client@2.0.6` pinned. Run each in a disposable installation and isolated state directory. Do not replace a global OpenCode executable or attach to a user's shared service for mutation tests. + +Fixture tests verify transport contracts, normalization, request guards, job correlation, and CLI behavior. They do not establish live model/provider compatibility. Likewise, doctor's `matchesIntegrationTarget` is a version comparison, not live verification evidence. Record the exact backend, command, exit status, covered operations, and skipped capabilities for each live run. Inference must remain opt-in with an explicitly selected provider/model. + +The generated [capability inventory](compatibility.md) currently lists 64 V2 tool mappings, 22 intentional V1-only exceptions, and one upstream-blocked operation. `opencode_command_execute` remains blocked because the V2 2.0.6 contract cannot correlate exact command completion. Passing tests does not fulfill that missing behavior, and no detached command may be substituted. Keep this limitation in release notes until an upstream contract and its regression tests resolve it. + +## Verify the installed archive and isolated backends + +```bash +npm run test:package +npm run test:compat +``` + +`test:package` installs the produced archive with production dependencies into a disposable consumer and checks its executable against an HTTP fixture. `test:compat` builds and packs the bridge, installs each pinned native backend into temporary directories, and exercises that installed bridge against the real isolated backend. It checks runtime versions, catalog/read operations, owned-session create/read/update/list/delete, and backend survival after MCP disconnect. These commands do not publish anything or replace a global executable. + +To run one native backend: + +```bash +npm run test:compat -- --backend v1 +npm run test:compat -- --backend v2 +``` + +The default is `--backend all`; inference is disabled. Temporary homes, state, project, and job stores are removed after the run. Authentication for the owned test server is kept in memory and passed to its bridge; the V2 foreground password is read only from that owned server's documented startup line. Failure reports include safe phase labels, never raw server output or credential values. + +To opt in to one model request, choose the provider/model explicitly. Forward only the credential environment variable required by that provider: + +```bash +npm run test:compat -- --backend v2 --inference \ + --provider YOUR_PROVIDER --model YOUR_MODEL \ + --credential-env OPENAI_API_KEY +``` + +Here `OPENAI_API_KEY` is an example variable name; use the relevant existing credential variable for the selected provider. The harness takes its value from the invoking environment, not an argument. Repeat `--credential-env` for additional required credential names. Provider/model/credential arguments without `--inference` are rejected. This optional check can incur provider charges and does not exercise OAuth or GUI interaction. Record the emitted `checks` and `skips` rather than interpreting one successful request as complete capability coverage. + ## Local smoke checks Build the package, start a local OpenCode server, then run: @@ -46,6 +82,8 @@ The runner sends a text-only request through `opencode_fire`, checks progress, w npm run test:coverage npm audit git diff --check + npm run test:package + npm run test:compat ``` Confirm the required CI matrix passed on the exact release commit. Record the tested OpenCode version. The default fixture smoke checks should pass; explicitly select a provider/model when validating inference. Record skipped live capabilities accurately. diff --git a/docs/tools.md b/docs/tools.md index 9798d14..ee3f849 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -6,6 +6,8 @@ The full profile exposes **87 tools**; the essential profile exposes **25 tools* Set `OPENCODE_TOOL_PROFILE=essential` for the smaller catalog; `full` is the default. Profile availability is listed for each tool. Profiles control discovery, not OpenCode permissions. +Tool names and schemas stay available across backends. Backend support is listed below and in the [generated compatibility inventory](compatibility.md); unavailable operations fail before mutation. Supported tools may have backend-specific option restrictions described in [migration](../MIGRATION.md). + ## Input and Output Contracts When present, `directory` means an absolute path on the OpenCode server. Global authentication tools omit it; local project initialization uses `path`. See [configuration](configuration.md) for scope and [examples](examples.md) for workflows. @@ -40,6 +42,8 @@ Ask OpenCode a question in one step. Creates a new session, sends your prompt, a **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -53,7 +57,7 @@ Ask OpenCode a question in one step. Creates a new session, sends your prompt, a | `variant` | string | no | Model variant (e.g. 'fast', 'smart') | | `agent` | string | no | Agent to use (e.g. 'build', 'plan') | | `system` | string | no | Optional system prompt override | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -102,7 +106,7 @@ Ask OpenCode a question in one step. Creates a new session, sends your prompt, a "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -184,6 +188,8 @@ Get current task status, pending input, todos and file counts. Uses session summ **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -297,6 +303,8 @@ Get full project context in one call: current project, path, VCS info, config, a **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -358,6 +366,8 @@ Get the full conversation history of a session, formatted for easy reading. Show **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -366,6 +376,7 @@ Get the full conversation history of a session, formatted for easy reading. Show |---|---|---|---| | `sessionId` | string | yes | Session ID | | `limit` | number | no | Max messages to return (default: all) | +| `cursor` | string | no | V2 continuation cursor | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -393,6 +404,10 @@ Get the full conversation history of a session, formatted for easy reading. Show "description": "Max messages to return (default: all)", "type": "number" }, + "cursor": { + "description": "V2 continuation cursor", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -432,6 +447,8 @@ Dispatch a durable background task and return its job, session and message IDs i **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -446,7 +463,7 @@ Dispatch a durable background task and return its job, session and message IDs i | `variant` | string | no | Model variant | | `agent` | string | no | OpenCode agent name | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | #### Structured output @@ -505,7 +522,7 @@ Dispatch a durable background task and return its job, session and message IDs i "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -609,6 +626,8 @@ Quick-test whether a provider is working. Creates a temporary session, sends a t **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -618,7 +637,7 @@ Quick-test whether a provider is working. Creates a temporary session, sends a t | `providerId` | string | yes | Provider ID to test (e.g. 'anthropic', 'openrouter') | | `modelID` | string | no | Specific model ID to test. If omitted, discovers the provider default or first available model. | | `variant` | string | no | Model variant | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -651,7 +670,7 @@ Quick-test whether a provider is working. Creates a temporary session, sends a t "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -733,6 +752,8 @@ Send a follow-up message to an existing session. Use this to continue a conversa **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -745,7 +766,7 @@ Send a follow-up message to an existing session. Use this to continue a conversa | `modelID` | string | no | Model ID | | `variant` | string | no | Model variant | | `agent` | string | no | Agent to use | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -790,7 +811,7 @@ Send a follow-up message to an existing session. Use this to continue a conversa "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -873,6 +894,8 @@ Get a formatted summary of all file changes made in a session. Shows diffs in a **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -881,6 +904,8 @@ Get a formatted summary of all file changes made in a session. Shows diffs in a |---|---|---|---| | `sessionId` | string | yes | Session ID | | `messageID` | string | no | Specific message ID to get diff for | +| `from` | string | no | V2 first message in an explicit diff range | +| `to` | string | no | V2 last message in an explicit diff range | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -908,6 +933,14 @@ Get a formatted summary of all file changes made in a session. Shows diffs in a "description": "Specific message ID to get diff for", "type": "string" }, + "from": { + "description": "V2 first message in an explicit diff range", + "type": "string" + }, + "to": { + "description": "V2 last message in an explicit diff range", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -947,6 +980,8 @@ Send a task and wait for its correlated response. Returns a durable job ID; an o **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -961,7 +996,7 @@ Send a task and wait for its correlated response. Returns a durable job ID; an o | `variant` | string | no | Model variant | | `agent` | string | no | OpenCode agent name | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `maxDurationSeconds` | number | no | Maximum observation duration in seconds (default 600); timeout does not abort the job; maximum: 3600 | #### Structured output @@ -1021,7 +1056,7 @@ Send a task and wait for its correlated response. Returns a durable job ID; an o "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -1131,6 +1166,8 @@ Get a quick overview of all sessions with their titles and status. Useful to fin **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1192,6 +1229,8 @@ Check OpenCode status, provider configuration, and optionally initialize a proje **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1253,6 +1292,8 @@ Get a quick status dashboard: server health, provider count, session count, and **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1314,6 +1355,8 @@ Wait for a session or durable job. Returns completed, failed, input_required, or **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1438,6 +1481,8 @@ Explicitly abort the OpenCode session owned by a job. This also stops other work **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -1534,6 +1579,8 @@ Observe a durable job by ID, including pending inputs and final results. **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1630,6 +1677,8 @@ Respond to a job's pending questions or permissions. Omit responses to request M **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -1685,6 +1734,41 @@ Respond to a job's pending questions or permissions. Omit responses to request M } } }, + "values": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "sessionId": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "project", + "session" + ] + }, "reply": { "type": "string", "enum": [ @@ -1739,6 +1823,8 @@ List locally retained jobs in this OpenCode server and credential scope. **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1803,6 +1889,8 @@ List pending OpenCode questions, optionally filtered to a session. **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -1868,6 +1956,8 @@ Reject a pending OpenCode question explicitly. **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -1875,6 +1965,7 @@ Reject a pending OpenCode question explicitly. | Parameter | Type | Required | Description | |---|---|---|---| | `requestId` | string | yes | minimum length: 1 | +| `sessionId` | string | no | | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -1898,6 +1989,9 @@ Reject a pending OpenCode question explicitly. "type": "string", "minLength": 1 }, + "sessionId": { + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -1933,10 +2027,12 @@ Reject a pending OpenCode question explicitly. ### `opencode_question_reply` -Answer an OpenCode question request. Supply one array of selected labels or free text per question. +Answer an OpenCode question request. V1 accepts answers arrays. V2 accepts typed field-keyed values matching the returned form; include sessionId to identify the session. **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -1944,7 +2040,9 @@ Answer an OpenCode question request. Supply one array of selected labels or free | Parameter | Type | Required | Description | |---|---|---|---| | `requestId` | string | yes | minimum length: 1 | -| `answers` | array> | yes | | +| `sessionId` | string | no | | +| `answers` | array> | no | | +| `values` | object | no | | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -1968,6 +2066,9 @@ Answer an OpenCode question request. Supply one array of selected labels or free "type": "string", "minLength": 1 }, + "sessionId": { + "type": "string" + }, "answers": { "type": "array", "items": { @@ -1977,14 +2078,38 @@ Answer an OpenCode question request. Supply one array of selected labels or free } } }, + "values": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" } }, "required": [ - "requestId", - "answers" + "requestId" ], "additionalProperties": false }, @@ -2019,6 +2144,8 @@ List all pending permission requests across all sessions. When a session is bloc **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -2080,6 +2207,8 @@ Abort a running session **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2149,6 +2278,8 @@ Get child sessions of a session **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -2218,6 +2349,8 @@ Create a new session. Optionally provide a parentID to create a child session, a **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2226,6 +2359,10 @@ Create a new session. Optionally provide a parentID to create a child session, a |---|---|---|---| | `parentID` | string | no | Parent session ID | | `title` | string | no | Session title | +| `providerID` | string | no | | +| `modelID` | string | no | | +| `variant` | string | no | | +| `agent` | string | no | | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2254,6 +2391,18 @@ Create a new session. Optionally provide a parentID to create a child session, a "description": "Session title", "type": "string" }, + "providerID": { + "type": "string" + }, + "modelID": { + "type": "string" + }, + "variant": { + "type": "string" + }, + "agent": { + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -2295,6 +2444,8 @@ Delete a session and all its data **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2364,6 +2515,8 @@ Get the diff for a session, optionally for a specific message **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -2372,6 +2525,8 @@ Get the diff for a session, optionally for a specific message |---|---|---|---| | `id` | string | yes | Session ID | | `messageID` | string | no | Message ID (optional) | +| `from` | string | no | V2 first message in an explicit diff range | +| `to` | string | no | V2 last message in an explicit diff range | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2399,6 +2554,14 @@ Get the diff for a session, optionally for a specific message "description": "Message ID (optional)", "type": "string" }, + "from": { + "description": "V2 first message in an explicit diff range", + "type": "string" + }, + "to": { + "description": "V2 last message in an explicit diff range", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -2438,6 +2601,8 @@ Fork an existing session, optionally at a specific message **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2512,6 +2677,8 @@ Get details of a specific session by ID **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -2581,6 +2748,8 @@ Analyze the app and create AGENTS.md for a session. NOTE: This is a long-running **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2673,12 +2842,16 @@ List all sessions **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters | Parameter | Type | Required | Description | |---|---|---|---| +| `limit` | integer | no | maximum: 9007199254740991 | +| `cursor` | string | no | V2 continuation cursor | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2698,6 +2871,15 @@ List all sessions "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { + "limit": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "cursor": { + "description": "V2 continuation cursor", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -2730,10 +2912,12 @@ List all sessions ### `opencode_session_permission` -Respond to a permission request in a session. Use `opencode_permission_list` to see pending requests. Reply values: 'once' (approve this request only), 'always' (approve this + future matching requests for this session), 'reject' (deny the request). +Respond to a permission request in a session. Use `opencode_permission_list` to see pending requests. Reply values: 'once' (approve this request only), 'always' (V1 session behavior; V2 project-wide saved approval requiring scope=project), 'reject' (V2 denies ALL pending session requests requiring scope=session). **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2743,6 +2927,7 @@ Respond to a permission request in a session. Use `opencode_permission_list` to | `id` | string | yes | Session ID | | `permissionID` | string | yes | Permission request ID | | `reply` | "once" \| "always" \| "reject" | yes | Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny | +| `scope` | "project" \| "session" | no | V2 explicit acknowledgment: always applies to the project; reject denies all pending session requests | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -2779,6 +2964,14 @@ Respond to a permission request in a session. Use `opencode_permission_list` to ], "description": "Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny" }, + "scope": { + "description": "V2 explicit acknowledgment: always applies to the project; reject denies all pending session requests", + "type": "string", + "enum": [ + "project", + "session" + ] + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -2820,6 +3013,8 @@ Revert a message in a session **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -2900,6 +3095,8 @@ Search sessions by keyword in title. Useful for finding a specific session among **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -2969,6 +3166,8 @@ Share a session publicly **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3038,6 +3237,8 @@ Get status for all sessions (running, idle, etc.) **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -3099,6 +3300,8 @@ Summarize a session using a specified model. NOTE: This is a long-running operat **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3185,6 +3388,8 @@ Get the todo list for a session **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -3254,6 +3459,8 @@ Restore all reverted messages in a session **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3323,6 +3530,8 @@ Unshare a previously shared session **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3392,6 +3601,8 @@ Update session properties (e.g. title) **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3468,6 +3679,8 @@ Execute a slash command in a session (e.g. /init, /undo, /redo) **Profiles:** full. +**Backends:** V1 supported; V2 blocked. V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3568,6 +3781,8 @@ Get details of a specific message in a session **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -3643,6 +3858,8 @@ List all messages in a session with formatted output showing roles and content **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -3651,6 +3868,7 @@ List all messages in a session with formatted output showing roles and content |---|---|---|---| | `sessionId` | string | yes | Session ID | | `limit` | number | no | Maximum number of messages to return | +| `cursor` | string | no | V2 continuation cursor | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3678,6 +3896,10 @@ List all messages in a session with formatted output showing roles and content "description": "Maximum number of messages to return", "type": "number" }, + "cursor": { + "description": "V2 continuation cursor", + "type": "string" + }, "directory": { "description": "Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory.", "type": "string" @@ -3717,6 +3939,8 @@ Send a prompt message to a session and wait for the AI response. Use parts to se **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3731,7 +3955,7 @@ Send a prompt message to a session and wait for the AI response. Use parts to se | `agent` | string | no | Agent to use | | `noReply` | boolean | no | If true, inject context without triggering AI response (useful for plugins) | | `system` | string | no | System prompt override | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3784,7 +4008,7 @@ Send a prompt message to a session and wait for the AI response. Use parts to se "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -3867,6 +4091,8 @@ Send a prompt asynchronously and return its messageId. Pass sessionId and messag **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -3879,7 +4105,7 @@ Send a prompt asynchronously and return its messageId. Pass sessionId and messag | `modelID` | string | no | Model ID (e.g. 'claude-3-5-sonnet-20241022') | | `variant` | string | no | Model variant (e.g. 'fast', 'smart') | | `agent` | string | no | Agent to use | -| `format` | object \| object | no | Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool. | +| `format` | object \| object | no | Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool. | | `directory` | string | no | Absolute path to the project directory. When provided, the request targets that project. If omitted, the OpenCode server uses its own working directory. | #### Structured output @@ -3924,7 +4150,7 @@ Send a prompt asynchronously and return its messageId. Pass sessionId and messag "type": "string" }, "format": { - "description": "Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool.", + "description": "Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool.", "oneOf": [ { "type": "object", @@ -4007,6 +4233,8 @@ Run a shell command through the opencode session **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -4100,6 +4328,8 @@ List files and directories at a path **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4166,6 +4396,8 @@ Read the content of a file **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4235,6 +4467,8 @@ Get status for tracked files (VCS changes: modified, added, deleted, etc.) **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4296,6 +4530,8 @@ Find files and directories by name (fuzzy match) **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4384,6 +4620,8 @@ Find workspace symbols by name (functions, classes, variables, etc.) **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4453,6 +4691,8 @@ Search for text patterns in project files (regex supported). Returns file paths, **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4524,6 +4764,8 @@ Get the current opencode configuration **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4585,6 +4827,8 @@ List all configured providers and their default models **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4646,6 +4890,8 @@ Update the opencode configuration. Pass a partial config object with fields to u **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -4721,6 +4967,8 @@ Set authentication credentials for a provider (e.g. API key). Credentials are st **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -4797,6 +5045,8 @@ Get available authentication methods for all providers **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4858,6 +5108,8 @@ List all configured providers with their connection status. Returns a compact su **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4919,6 +5171,8 @@ List available models for a specific provider. Call opencode_provider_list first **Profiles:** full, essential. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -4993,6 +5247,8 @@ Start OAuth authorization for a provider **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5001,6 +5257,9 @@ Start OAuth authorization for a provider |---|---|---|---| | `providerId` | string | yes | Provider ID to authorize | | `method` | integer | yes | Auth method index from opencode_provider_auth_methods (default 0); minimum: 0; maximum: 9007199254740991; default: 0 | +| `integrationId` | string | no | | +| `methodId` | string | no | | +| `values` | object | no | | | `inputs` | object | no | Additional inputs requested by the selected authentication method | #### Structured output @@ -5031,6 +5290,37 @@ Start OAuth authorization for a provider "minimum": 0, "maximum": 9007199254740991 }, + "integrationId": { + "type": "string" + }, + "methodId": { + "type": "string" + }, + "values": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, "inputs": { "description": "Additional inputs requested by the selected authentication method", "type": "object", @@ -5077,6 +5367,8 @@ Handle OAuth callback for a provider **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5110,20 +5402,24 @@ Handle OAuth callback for a provider "callbackData": { "type": "object", "properties": { + "attemptId": { + "description": "V2 exact attemptId from authorization", + "type": "string" + }, + "integrationId": { + "type": "string" + }, "method": { + "description": "V1 method index used for authorization; V2 uses the exact attemptId", "type": "integer", "minimum": 0, - "maximum": 9007199254740991, - "description": "The method index used for authorization" + "maximum": 9007199254740991 }, "code": { "description": "Authorization code for methods that request one", "type": "string" } }, - "required": [ - "method" - ], "additionalProperties": false, "description": "OAuth callback data" } @@ -5165,6 +5461,8 @@ Append text to the TUI's prompt input field **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5234,6 +5532,8 @@ Clear the current prompt text in the TUI **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5295,6 +5595,8 @@ Execute a slash command through the TUI (e.g. '/init', '/undo') **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5364,6 +5666,8 @@ Open the help dialog in the TUI **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5425,6 +5729,8 @@ Open the model selector in the TUI **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5486,6 +5792,8 @@ Open the session selector in the TUI **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5547,6 +5855,8 @@ Open the theme selector in the TUI **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5608,6 +5918,8 @@ Show a toast notification in the TUI **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5693,6 +6005,8 @@ Submit the current prompt in the TUI (equivalent to pressing Enter) **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5756,6 +6070,8 @@ Get the current active project **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -5817,6 +6133,8 @@ Initialize or open a project directory to host an independent OpenCode session. **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -5881,6 +6199,8 @@ List all projects known to the opencode server **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -5944,6 +6264,8 @@ Poll project events from OpenCode, or explicitly select global scope. Collects u **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6030,6 +6352,8 @@ Check server health and version **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6093,6 +6417,8 @@ List all available agents with their names, descriptions, and modes (primary/sub **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6154,6 +6480,8 @@ List all available commands (built-in and custom slash commands) **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6215,6 +6543,8 @@ Get the status of configured formatters **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6276,6 +6606,8 @@ Dispose the current opencode instance (shuts it down). WARNING: This is destruct **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -6337,6 +6669,8 @@ Write a log entry to the opencode server **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -6433,6 +6767,8 @@ Get the status of LSP (Language Server Protocol) servers **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6494,6 +6830,8 @@ Add an MCP server dynamically to opencode **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. #### Parameters @@ -6573,6 +6911,8 @@ Get the status of all MCP servers configured in opencode **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6634,6 +6974,8 @@ Get the current working path of the opencode server **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6695,6 +7037,8 @@ List all available tool IDs that the LLM can use (experimental) **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6756,6 +7100,8 @@ List tools with JSON schemas for a given provider and model (experimental) **Profiles:** full. +**Backends:** V1 supported; V2 unsupported. No equivalent published V2 API; V1-only capability. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters @@ -6831,6 +7177,8 @@ Get VCS (version control) info for the current project (branch, remote, status) **Profiles:** full. +**Backends:** V1 supported; V2 supported. + **Annotations:** `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true`. #### Parameters diff --git a/package-lock.json b/package-lock.json index a65b28a..64a8233 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "opencode-mcp", - "version": "3.0.0", + "version": "3.1.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-mcp", - "version": "3.0.0", + "version": "3.1.0-rc.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "^1.18.31", + "@opencode/client": "2.0.6", "zod": "^4.6.5" }, "bin": { @@ -215,6 +216,84 @@ "node": ">=20" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@opencode-ai/sdk": { "version": "1.18.31", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", @@ -224,6 +303,48 @@ "cross-spawn": "7.0.6" } }, + "node_modules/@opencode/client": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.6.tgz", + "integrity": "sha512-yS8Ztw5KJzoY94GTiEmaX+2OqJYVVdc2Pyj8joNvmFywMfNzhS5aqqX+LpprlZdiDHKBLN9GhMU775TRPLol0A==", + "license": "MIT", + "dependencies": { + "@opencode/protocol": "2.0.6", + "@opencode/schema": "2.0.6" + }, + "peerDependencies": { + "effect": "4.0.0-rc.112", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/protocol": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.6.tgz", + "integrity": "sha512-31pt2SC2/urvitx8q8A0i0IwewwAu70/knkSfY9NFhZyLfggfNrPMUvlzMV0rwbBgubXbyRFRshU6B2tX/6czQ==", + "license": "MIT", + "dependencies": { + "@opencode/schema": "2.0.6", + "effect": "4.0.0-rc.112" + } + }, + "node_modules/@opencode/schema": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.6.tgz", + "integrity": "sha512-oJC1Uc1ZhqQ5fBUhRza5tFc4Y8UKy0bWxpOv4f9qm6VCs16xgNGPi5Dts4bM29KbtfARr3W926tHvk7BxzUf9A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.0-rc.112" + } + }, "node_modules/@oxc-project/types": { "version": "0.149.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", @@ -518,7 +639,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, "license": "MIT" }, "node_modules/@types/chai": { @@ -976,7 +1096,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1004,6 +1124,16 @@ "dev": true, "license": "MIT" }, + "node_modules/effect": { + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", + "license": "MIT", + "dependencies": { + "fast-check": "^4.9.0", + "msgpackr": "^2.0.5" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -1178,6 +1308,28 @@ "express": ">= 4.11" } }, + "node_modules/fast-check": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.1.tgz", + "integrity": "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1918,6 +2070,37 @@ "dev": true, "license": "MIT" }, + "node_modules/msgpackr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", + "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -1947,6 +2130,21 @@ "node": ">= 0.6" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2114,6 +2312,22 @@ "node": ">= 0.10" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", diff --git a/package.json b/package.json index a2ce46f..fd761b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-mcp", - "version": "3.0.0", + "version": "3.1.0-rc.1", "description": "MCP server for OpenCode coding workflows, durable async jobs, project resources, and explicit user input", "type": "module", "main": "dist/index.js", @@ -11,7 +11,9 @@ "dist", "README.md", "LICENSE", - "CHANGELOG.md" + "CHANGELOG.md", + "MIGRATION.md", + "docs" ], "scripts": { "build": "tsc", @@ -24,7 +26,9 @@ "prepublishOnly": "npm run build", "pretest": "npm run build", "docs:generate": "npm run build && node scripts/generate-tool-docs.mjs", - "docs:check": "npm run build && node scripts/generate-tool-docs.mjs --check" + "docs:check": "npm run build && node scripts/generate-tool-docs.mjs --check", + "test:package": "node scripts/test-package.mjs", + "test:compat": "node scripts/test-compat.mjs" }, "keywords": [ "mcp", @@ -57,6 +61,7 @@ "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "^1.18.31", + "@opencode/client": "2.0.6", "zod": "^4.6.5" }, "devDependencies": { diff --git a/plans/001-contracts.md b/plans/001-contracts.md new file mode 100644 index 0000000..9baedbb --- /dev/null +++ b/plans/001-contracts.md @@ -0,0 +1,25 @@ +# 001: Capability manifest and typed adapters + +Planned at: `0de817a919969821eceb8f29059a0ad9d3518a18` + +Scope: first milestone of the user-approved OpenCode MCP compatibility foundation. Work only in ``, branch `codex/v1-v2-foundation`. Preserve original checkout and historical plans. No publishing, pushing, registry submissions, global CLI replacement, implicit V2 startup, telemetry, remote transport, budgets, or future roadmap features. Keep V1 SDK and V1 record schema intact; pin V2 client 2.0.6. Preserve full/essential tool names and defaults. + +Verification: focused contract/regression tests before integration; final gates `npm test`, `npm run docs:check`, `npm run test:coverage`, `npm audit`, `git diff --check`, `npm run test:package`, `npm run test:compat`. Tests must distinguish fixtures from live backend evidence. Isolated live targets are opencode-ai@1.18.31 and @opencode/cli@2.0.6; inference is opt-in. + +Stop an individual operation and report the exact incompatibility if published upstream contracts cannot preserve its required behavior. Never implement a weaker silent substitute. Ambiguous writes are never replayed. + +## Implementation + +Implement typed backend domains for sessions, messages, providers, files, permissions, configuration, events and lifecycle. Characterize existing V1 behavior and move handlers off route assembly. One manifest covers all 87 tools, resources/templates/prompts. Exactly 65 V2 tools and 22 V1-only tools: config_update; session_todo/init/share/unshare; find_text/symbol; instance_dispose; lsp_status; formatter_status; tool_ids/list; log; all nine tui tools. Unsupported calls return structured UNSUPPORTED_CAPABILITY before mutation. Generate documentation from the manifest. + +## Dependency + +None; establish this contract first. + +## Done criteria + +Implementation, generated capability documentation and focused tests agree. Run focused tests, `npm run build`, and all relevant final gates. Report unavailable upstream behavior explicitly. Reviewer maintains index. + +## Final status — 2026-09-17 + +Implemented; catalog coverage and V1 regression passed. See [final verification evidence](verification.md#final-candidate-evidence-2026-09-17). The overall 65-tool V2 acceptance gate remains blocked by the command API; fixture coverage is not live workflow verification. diff --git a/plans/002-detection-doctor.md b/plans/002-detection-doctor.md new file mode 100644 index 0000000..829c219 --- /dev/null +++ b/plans/002-detection-doctor.md @@ -0,0 +1,25 @@ +# 002: Backend detection, lifecycle, and diagnostics + +Planned at: `0de817a919969821eceb8f29059a0ad9d3518a18` + +Scope: first milestone of the user-approved OpenCode MCP compatibility foundation. Work only in ``, branch `codex/v1-v2-foundation`. Preserve original checkout and historical plans. No publishing, pushing, registry submissions, global CLI replacement, implicit V2 startup, telemetry, remote transport, budgets, or future roadmap features. Keep V1 SDK and V1 record schema intact; pin V2 client 2.0.6. Preserve full/essential tool names and defaults. + +Verification: focused contract/regression tests before integration; final gates `npm test`, `npm run docs:check`, `npm run test:coverage`, `npm audit`, `git diff --check`, `npm run test:package`, `npm run test:compat`. Tests must distinguish fixtures from live backend evidence. Isolated live targets are opencode-ai@1.18.31 and @opencode/cli@2.0.6; inference is opt-in. + +Stop an individual operation and report the exact incompatibility if published upstream contracts cannot preserve its required behavior. Never implement a weaker silent substitute. Ambiguous writes are never replayed. + +## Implementation + +Add OPENCODE_BACKEND auto/v1/v2. Explicit URL authoritative; otherwise existing loopback then read-only Service.discover. Health/info validate selected contract; authentication terminal. V1 opt-in owned startup only. Never ensure or stop shared V2. Expose backend/version/source/capabilities/ownership/disconnect survival. CLI doctor [--json] [--directory], --check alias, --help, --version; no args still stdio. Read-only 15-second total deadline, zero inference/session/write/startup effects; exit 0 ready, 1 failure, 2 bad args. Versioned redacted JSON checks/remedies. External connection does not require local executable. + +## Dependency + +Complete work package 001; independently developed tests may be prepared earlier. + +## Done criteria + +Implementation, generated capability documentation and focused tests agree. Run focused tests, `npm run build`, and all relevant final gates. Report unavailable upstream behavior explicitly. Reviewer maintains index. + +## Final status — 2026-09-17 + +Implemented; contract, CLI, lifecycle and deadline tests passed. See [final verification evidence](verification.md#final-candidate-evidence-2026-09-17). The overall 65-tool V2 acceptance gate remains blocked by the command API; fixture coverage is not live workflow verification. diff --git a/plans/003-v2-reads.md b/plans/003-v2-reads.md new file mode 100644 index 0000000..e582f77 --- /dev/null +++ b/plans/003-v2-reads.md @@ -0,0 +1,25 @@ +# 003: V2 reads, resources, and normalization + +Planned at: `0de817a919969821eceb8f29059a0ad9d3518a18` + +Scope: first milestone of the user-approved OpenCode MCP compatibility foundation. Work only in ``, branch `codex/v1-v2-foundation`. Preserve original checkout and historical plans. No publishing, pushing, registry submissions, global CLI replacement, implicit V2 startup, telemetry, remote transport, budgets, or future roadmap features. Keep V1 SDK and V1 record schema intact; pin V2 client 2.0.6. Preserve full/essential tool names and defaults. + +Verification: focused contract/regression tests before integration; final gates `npm test`, `npm run docs:check`, `npm run test:coverage`, `npm audit`, `git diff --check`, `npm run test:package`, `npm run test:compat`. Tests must distinguish fixtures from live backend evidence. Isolated live targets are opencode-ai@1.18.31 and @opencode/cli@2.0.6; inference is opt-in. + +Stop an individual operation and report the exact incompatibility if published upstream contracts cannot preserve its required behavior. Never implement a weaker silent substitute. Ambiguous writes are never replayed. + +## Implementation + +Normalize bridge-owned session/message/file/provider/permission types with labeled backend raw data. Translate remote absolute directory without host resolution, including Windows/UNC. Respect pagination limits and report complete/cursor. Filter events by real location. Report ordered V2 config sources, never naive merged effective config. Preserve project initialization as explicitly local. Port every equivalent read tool/resource and generate precise dispositions. + +## Dependency + +Complete work package 002; independently developed tests may be prepared earlier. + +## Done criteria + +Implementation, generated capability documentation and focused tests agree. Run focused tests, `npm run build`, and all relevant final gates. Report unavailable upstream behavior explicitly. Reviewer maintains index. + +## Final status — 2026-09-17 + +Implemented; contract tests and model-free native subset passed. See [final verification evidence](verification.md#final-candidate-evidence-2026-09-17). The overall 65-tool V2 acceptance gate remains blocked by the command API; fixture coverage is not live workflow verification. diff --git a/plans/004-v2-writes-input.md b/plans/004-v2-writes-input.md new file mode 100644 index 0000000..6a6c7b2 --- /dev/null +++ b/plans/004-v2-writes-input.md @@ -0,0 +1,45 @@ +# 004: V2 mutations, forms, OAuth, permissions + +Planned at: `0de817a919969821eceb8f29059a0ad9d3518a18` + +Scope: first milestone of the user-approved OpenCode MCP compatibility foundation. Work only in ``, branch `codex/v1-v2-foundation`. Preserve original checkout and historical plans. No publishing, pushing, registry submissions, global CLI replacement, implicit V2 startup, telemetry, remote transport, budgets, or future roadmap features. Keep V1 SDK and V1 record schema intact; pin V2 client 2.0.6. Preserve full/essential tool names and defaults. + +Verification: focused contract/regression tests before integration; final gates `npm test`, `npm run docs:check`, `npm run test:coverage`, `npm audit`, `git diff --check`, `npm run test:package`, `npm run test:compat`. Tests must distinguish fixtures from live backend evidence. Isolated live targets are opencode-ai@1.18.31 and @opencode/cli@2.0.6; inference is opt-in. + +Stop an individual operation and report the exact incompatibility if published upstream contracts cannot preserve its required behavior. Never implement a weaker silent substitute. Ambiguous writes are never replayed. + +## Implementation + +Create model/variant/agent on new V2 sessions; configured defaults only there. Explicit existing selections must match. Reject unsupported JSON-schema format, nonempty system, noReply:true, parentID creation, part-specific revert before writes. Revert via reversible staging, never commit. Preserve fork boundary and explicit session diff range with cross-location rejection/turn attribution. Observe command/shell execution to completion. Add field-keyed typed values/session identity preserving V1 answers. Shared form validation for native elicitation and manual fallback. OAuth requires exact attemptId and explicit integration/method resolution; reject ambiguous provider mappings. Translate supported MCP config and reject conflicts/unsupported fields. V2 always requires explicit project-scope acknowledgment, reject requires session scope. Forms show scopes/saved patterns. + +## Dependency + +Complete work package 003; independently developed tests may be prepared earlier. + +## Done criteria + +Implementation, generated capability documentation and focused tests agree. Run focused tests, `npm run build`, and all relevant final gates. Report unavailable upstream behavior explicitly. Reviewer maintains index. + +## Final status — 2026-09-17 + +Implemented except upstream-blocked command; forms/OAuth fixture-tested. See [final verification evidence](verification.md#final-candidate-evidence-2026-09-17). The overall 65-tool V2 acceptance gate remains blocked by the command API; fixture coverage is not live workflow verification. + +## V2 command blocker tracking + +Recorded: 2026-09-18. Status: **BLOCKED — awaiting a usable upstream contract or documented existing mechanism**. + +Upstream feature request: [anomalyco/opencode#49690 — V2 session.command invocation identity and result correlation](https://github.com/anomalyco/opencode/issues/49690). The submitted [issue body](upstream-v2-command-issue.md) and [source verification](verification.md#upstream-command-tracking-request-2026-09-18) are retained locally. + +Affected tool: `opencode_command_execute`. The pinned `@opencode/client@2.0.6` command input has no caller-assigned ID and returns void/HTTP 204. The same contract was checked on upstream V2 commit `90112f52db59a8f2ec412c66c6677193bf5dc7b8`. Callback completion does not provide an identity linking the invocation to any admitted agent work. Concurrent session inputs and lost responses therefore prevent reliable result attribution. This is an integration limitation; a live concurrency bug has not been reproduced. + +Until resolved, keep the V2 capability blocked and return `UNSUPPORTED_CAPABILITY` before dispatch. V1 behavior remains available. Shell commands and ordinary prompts use separate supported paths. This blocker is additional to the 22 intentional V1-only exceptions. + +### Conditions to unblock + +- [ ] Confirm a published upstream contract or documented mechanism provides recoverable invocation identity and links to resulting inputs/output, including commands that admit no input. Record the exact version and source. +- [ ] Define callback completion versus completion/failure of admitted agent work; do not infer success from session inactivity. +- [ ] Implement the adapter and correlated observation without replaying uncertain mutations. Any dependency upgrade must preserve the other V1/V2 contracts. +- [ ] Test concurrent inputs, queued work, lost submission responses, reconnects, failures, and commands with no message result. Distinguish fixture evidence from live verification. +- [ ] Run focused adapter/catalog tests and the repository release gates, then update the capability manifest, generated docs, migration notes and verification evidence together. + +An upstream issue closure alone does not satisfy these conditions. Verified support would raise V2-backed tools from 64 to 65; the separate release verification gaps remain tracked in work package 006. diff --git a/plans/005-durable-v2-jobs.md b/plans/005-durable-v2-jobs.md new file mode 100644 index 0000000..47b7240 --- /dev/null +++ b/plans/005-durable-v2-jobs.md @@ -0,0 +1,25 @@ +# 005: Durable V2 jobs and native Tasks + +Planned at: `0de817a919969821eceb8f29059a0ad9d3518a18` + +Scope: first milestone of the user-approved OpenCode MCP compatibility foundation. Work only in ``, branch `codex/v1-v2-foundation`. Preserve original checkout and historical plans. No publishing, pushing, registry submissions, global CLI replacement, implicit V2 startup, telemetry, remote transport, budgets, or future roadmap features. Keep V1 SDK and V1 record schema intact; pin V2 client 2.0.6. Preserve full/essential tool names and defaults. + +Verification: focused contract/regression tests before integration; final gates `npm test`, `npm run docs:check`, `npm run test:coverage`, `npm audit`, `git diff --check`, `npm run test:package`, `npm run test:compat`. Tests must distinguish fixtures from live backend evidence. Isolated live targets are opencode-ai@1.18.31 and @opencode/cli@2.0.6; inference is opt-in. + +Stop an individual operation and report the exact incompatibility if published upstream contracts cannot preserve its required behavior. Never implement a weaker silent substitute. Ambiguous writes are never replayed. + +## Implementation + +Keep accepted/running/input_required/completed/failed/cancelled/unknown semantics. Persist assigned msg_ ID before dispatch, pass prompt id, verify receipt. Use queue delivery. Save session log observation cursor; correlate inbox delivery/execution/assistant IDs and terminal events to submitted work. Queued history absence is not failure; session idle alone is not success. Preserve ambiguous unknown/no replay, observation timeout vs cancellation, reject cancellation ownership lost to unrelated turns. V1 schema/files unchanged; V2 records in versioned subdirectory with backend identity. Combined lists backend-labeled, cached terminal results readable; active only via recorded backend. Ordinary tools/native MCP Tasks share JobService. Cover restart, lost receipt, forms, outage, unrelated turns and cancellation. + +## Dependency + +Complete work package 004; independently developed tests may be prepared earlier. + +## Done criteria + +Implementation, generated capability documentation and focused tests agree. Run focused tests, `npm run build`, and all relevant final gates. Report unavailable upstream behavior explicitly. Reviewer maintains index. + +## Final status — 2026-09-17 + +Implemented; recovery and native Tasks fixture-tested; live inference unverified. See [final verification evidence](verification.md#final-candidate-evidence-2026-09-17). The overall 65-tool V2 acceptance gate remains blocked by the command API; fixture coverage is not live workflow verification. diff --git a/plans/006-release-verification.md b/plans/006-release-verification.md new file mode 100644 index 0000000..913ce9a --- /dev/null +++ b/plans/006-release-verification.md @@ -0,0 +1,33 @@ +# 006: Compatibility, documentation, package CI and release candidate + +Planned at: `0de817a919969821eceb8f29059a0ad9d3518a18` + +Scope: first milestone of the user-approved OpenCode MCP compatibility foundation. Work only in ``, branch `codex/v1-v2-foundation`. Preserve original checkout and historical plans. No publishing, pushing, registry submissions, global CLI replacement, implicit V2 startup, telemetry, remote transport, budgets, or future roadmap features. Keep V1 SDK and V1 record schema intact; pin V2 client 2.0.6. Preserve full/essential tool names and defaults. + +Verification: focused contract/regression tests before integration; final gates `npm test`, `npm run docs:check`, `npm run test:coverage`, `npm audit`, `git diff --check`, `npm run test:package`, `npm run test:compat`. Tests must distinguish fixtures from live backend evidence. Isolated live targets are opencode-ai@1.18.31 and @opencode/cli@2.0.6; inference is opt-in. + +Stop an individual operation and report the exact incompatibility if published upstream contracts cannot preserve its required behavior. Never implement a weaker silent substitute. Ambiguous writes are never replayed. + +## Implementation + +Generate compatibility/tool docs from manifest. Update setup/lifecycle/limitations/forms/OAuth/migration examples. Add macOS to Ubuntu/Windows Node22/24 matrix. Test actual npm archive clean production install, executable/init/catalog/fixture calls/shutdown. Add test:compat isolated exact V1/V2 versions with model-free default and explicitly enabled selected-model inference. Record versions, skips and exact evidence; mocks do not establish live/client verification. Prepare release candidate only, no publishing or external metadata changes. + +## Dependency + +Complete work package 005; independently developed tests may be prepared earlier. + +## Done criteria + +Implementation, generated capability documentation and focused tests agree. Run focused tests, `npm run build`, and all relevant final gates. Report unavailable upstream behavior explicitly. Reviewer maintains index. + +## Final status — 2026-09-17 + +RC prepared; local Node 22/24 gates passed; hosted OS matrix unrun. See [final verification evidence](verification.md#final-candidate-evidence-2026-09-17). The overall 65-tool V2 acceptance gate remains blocked by the command API; fixture coverage is not live workflow verification. + +## Artifact verification follow-up — 2026-09-18 + +The native compatibility harness previously packed the current source independently of the retained candidate archive. Add `--archive` support to run the real pinned backends against the retained bytes and record their integrity, skipping build/repack. Exercise `doctor --json` against each owned backend without model calls, checking backend identity, expected provider readiness, lifecycle, redaction, and lack of session creation. Runtime/package contents remain frozen unless these checks expose a defect. + +Linux-container verification was investigated: Docker CLI is installed, but its daemon information request did not respond within approximately one minute. Only that probe process was terminated; no Docker daemon or user container was started/stopped. Hosted Windows/Linux checks remain unverified. + +Follow-up completed locally: exact retained archive checks passed for both native backends on Node 22/24, including read-only doctor behavior. Full Node 22 regression: 755 passed / 3 skipped; final harness tests: 27 passed on both Node versions. One initial V2 final-phase failure did not reproduce and remains explicitly unresolved in [verification evidence](verification.md#follow-up-results). Runtime/archive contents are unchanged. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..947c118 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,32 @@ +# Compatibility foundation implementation + +Baseline: `0de817a`. User-approved scope: milestone 1 only. Original checkout and historical review remain intact. + +Implementation and local verification are complete for the supported scope; release candidate `3.1.0-rc.1` is prepared. The original milestone acceptance gate remains **BLOCKED**: V2 supports 64 tools, with 22 planned V1-only exceptions and `opencode_command_execute` blocked by the upstream 2.0.6 contract. See [final evidence and limitations](verification.md#final-candidate-evidence-2026-09-17). No publication is claimed. + +| Package | Status | Dependency | +|---|---|---| +| [001: Capability manifest and typed adapters](001-contracts.md) | Implemented; catalog coverage and V1 regression passed | — | +| [002: Backend detection, lifecycle, and diagnostics](002-detection-doctor.md) | Implemented; contract, CLI, lifecycle and deadline tests passed | 001 | +| [003: V2 reads, resources, and normalization](003-v2-reads.md) | Implemented; contract tests and model-free native subset passed | 002 | +| [004: V2 mutations, forms, OAuth, permissions](004-v2-writes-input.md) | Implemented except upstream-blocked command; forms/OAuth fixture-tested | 003 | +| [005: Durable V2 jobs and native Tasks](005-durable-v2-jobs.md) | Implemented; recovery and native Tasks fixture-tested; live inference unverified | 004 | +| [006: Compatibility, documentation, package CI and release candidate](006-release-verification.md) | RC retained bytes verified on Node 22/24; initial native-test failure unreproduced; hosted OS matrix unrun | 005 | + +## Open upstream blocker + +`opencode_command_execute` is tracked in [OpenCode #49690](https://github.com/anomalyco/opencode/issues/49690). See [the blocker record and unblock checklist](004-v2-writes-input.md#v2-command-blocker-tracking). Keep it blocked until the required contract and regression evidence are verified. + +## Subsequent roadmap + +2. Onboarding/adoption: guided configuration, compact profile, specialist discovery, tested examples and registries. +3. Recovery/observation: caller idempotency keys, reattachment, progress, retention/export. +4. Isolation/verification: managed worktrees, structured artifacts, revision-linked verification and benchmarks. +5. Operational controls: usage, enforceable limits, concurrency, policies. +6. Demand-led remote transport, dashboard, orchestration/routing. + +No telemetry infrastructure this milestone. Adoption begins with observed onboarding and voluntary feedback. No release publication is authorized by this implementation task. + +2026-09-18 continuation: exact-archive native and doctor verification added. Latest full Node 22 suite: 755 passed, 3 skipped. See [follow-up evidence and unresolved transient failure](verification.md#follow-up-results). + +2026-09-18 delivery update: the user authorized pushing the implementation branch and opening a draft PR. This supersedes the original local-only/no-push boundary in the work packages; npm publication and merging remain outside this task. Pre-PR regression, coverage, docs, audit and staged whitespace checks passed; see [PR preparation evidence](verification.md#pr-preparation-2026-09-18). diff --git a/plans/upstream-v2-command-issue.md b/plans/upstream-v2-command-issue.md new file mode 100644 index 0000000..69f4705 --- /dev/null +++ b/plans/upstream-v2-command-issue.md @@ -0,0 +1,24 @@ +### Feature hasn't been suggested before. + +- [x] I have verified this feature I'm about to request hasn't been suggested before. + +### Describe the enhancement you want to request + +Please expose a stable invocation identity and result correlation for V2 `session.command`, or document an existing supported way to obtain them. + +While adding V2 support to an MCP bridge, I found that `@opencode/client@2.0.6` accepts `sessionID`, `name`, `text`, attachments and `delivery`, but no caller-assigned ID. Its result is `void`; the HTTP endpoint returns 204. The current V2 branch still has this contract: + +- [Client input/output types](https://github.com/anomalyco/opencode/blob/90112f52db59a8f2ec412c66c6677193bf5dc7b8/packages/client/src/effect/api/api.ts#L271-L281) +- [HTTP endpoint](https://github.com/anomalyco/opencode/blob/90112f52db59a8f2ec412c66c6677193bf5dc7b8/packages/protocol/src/groups/session.ts#L413-L429) + +This makes it difficult for an external client to associate a command with inputs it admits and their eventual output/error. For example, if a command enqueues a prompt while another client submits work to the same session, selecting the latest message or waiting for session inactivity cannot reliably identify the command's result. A lost HTTP response also leaves the caller without an invocation handle to inspect. + +The desired contract would provide: + +- A caller-assigned invocation ID, or another recoverable identity. +- A receipt linking the invocation to any admitted input IDs, plus documented status/log correlation. +- Explicit semantics for callback completion versus completion of admitted agent work, including commands that admit no input. + +`session.prompt` already accepts an ID and returns an inbox receipt. An equivalent correlation path for commands would let bridges observe results without guessing or replaying uncertain mutations. + +This is a contract inspection and integration requirement, not a claim that command execution itself is broken or that a live concurrency race was reproduced. Related: the completed implementation in #34429 / #34849; this request concerns its externally observable invocation/result identity. diff --git a/plans/verification.md b/plans/verification.md new file mode 100644 index 0000000..0882c9d --- /dev/null +++ b/plans/verification.md @@ -0,0 +1,134 @@ +# Milestone 1 review evidence + +Historical checkpoints are retained below. The final candidate evidence at the end supersedes their pending test results. The original 65-tool V2 gate remains blocked by the documented command API incompatibility. + +- Worktree baseline: `0de817a`; original checkout unchanged. +- Dependency audit after adding pinned V2 client: `npm audit --json`, zero vulnerabilities (203 dependencies). +- Initial V1 adapter checkpoint: build found an incorrect `timeoutMs` field; executor notified to preserve `RequestOptions.timeout`. +- Initial focused V1 check: `npx vitest run tests/tools.test.ts tests/resources.test.ts tests/contracts.test.ts --reporter=dot`; 125 passed, 7 failed due to changed omitted-option call shapes. Executor notified; final regression run required. + +## Review cases + +- Discovery must not interpret hidden authentication errors as service absence. Pinned SDK Service.discover returns undefined for failed registration probes; inspect the public registration contract read-only to distinguish absence from authentication/incompatibility before V1 opt-in startup. +- V2 native permission form decline/cancel must not silently cause session-wide rejection. +- Persist log cursor and correlation state together; restart after delivery must retain job ownership. +- Upstream V2 shutdown interruption clears retry state instead of projecting terminal interruption; do not conflate it with explicit cancellation. +- V2 session.command returns void without a caller-assigned ID; prove attribution before reporting completion. V2 session.shell accepts an ID. +- Existing V2 sessions must not inherit configured model defaults. +- Resource templates must migrate along with static resources. +- Fixture tests are not live/client verification; report tested versions and unexercised capabilities. + +## Detection/doctor independent checkpoint + +Reviewer reran `npx vitest run tests/backend-detection.test.ts tests/doctor.test.ts tests/server-manager.test.ts tests/stdio-integration.test.ts --reporter=dot`: 4 files, 96 tests passed. Covers authentication terminality, read-only diagnostics, redaction, deadline, owned-child lifecycle/authentication, invalid CLI arguments and stdio framing. Final integrated build/regression remains required. + +## Confirmed upstream operation blocker + +V2 `session.command` input has no caller-assigned identity and returns `Promise`. Published `@opencode/core@2.0.6` SessionCommand.execute delegates to command execution without a result receipt. Concurrent session inputs prevent exact result attribution. The implementation must reject `opencode_command_execute` before V2 mutation and document this separately from the 22 planned upstream removals. Therefore the original 65-supported V2 gate is not met; independent work continues. + +## Temporary environment issue + +Live install initially failed ENOSPC at ~129 MiB free (V2 native binary ~180 MiB unpacked). Only task-created temp directories were cleaned. Later free space increased to ~3.1 GiB; live checks can resume. No live verification is claimed yet. + +## Integrated regression checkpoint + +`npm test -- --reporter=dot`: build passed; 559 passed, 3 skipped, 1 failed. The remaining permission-fallback fixture throws a plain string-based error instead of the real typed HTTP 404. Executor will characterize typed 404 fallback and ensure authentication failures/ambiguous writes never cause a second mutation. Final rerun required. + +## First isolated V2 startup checkpoint + +`node scripts/test-compat.mjs --backend v2` on macOS arm64, Node 26.0.0 installed exact `@opencode/cli@2.0.6`. First attempt found the CLI version string has a `v` prefix; parser corrected. Next attempt started the real server but unauthenticated readiness probes failed because V2 serve enables password authentication. Harness must supply an explicit isolated credential to backend, probes and bridge, and must never include raw startup output in reports. No live API operation is verified by these attempts. + +## Successful archive and pinned backend checks + +- `npm run test:package`: actual package 3.0.0 archive installed with production dependencies only, macOS arm64 / Node 26.0.0; executable help/version, stdio initialization, 87-tool catalog, fixture health/create/delete, and EOF shutdown passed. +- `npm exec --yes --package=node@22 -- node scripts/test-compat.mjs`: macOS arm64 / Node 22.23.2; exact isolated `opencode-ai@1.18.31` and `@opencode/cli@2.0.6` both passed authenticated runtime identity, archive stdio/catalog, health/project/path/agents/config/file reads, owned session create/get/update/list/delete, and bridge shutdown leaving the external backend alive. Owned backend cleanup completed. +- These runs did not exercise model inference, durable execution recovery, forms/permissions, OAuth, reverts or interactive GUI clients. Mocked/fixture coverage is recorded separately. +- Independent Node 22 checkpoint: jobs, capability catalog and harness suites passed (4 files, 92 tests). Additional tests are still being added; final integrated totals remain pending. +- `npm audit --json`: zero vulnerabilities across 203 dependencies; `git diff --check` passed at this checkpoint. + +## Final review findings addressed before candidate verification + +- Exact V2 prompt identity now requires a matching delivered user projection before exposing results/input or controlling active execution. Queued work does not require projected history. +- Native Tasks and ordinary fire/run preserve structured capability errors for unsupported preflight options. +- V2 global session APIs require bridge-side default-location resolution to preserve project-scoped listing/status behavior. +- The upstream V2 prompt and compaction operations implicitly commit staged reverts (`@opencode/core@2.0.6`, `Session.prompt` and `Session.compact`). Bridge guards must reject these operations when a revert is staged, avoiding implicit commit; no commit endpoint is exposed. +- A Node 24 checkpoint exposed an old hardcoded 3.0.0 startup-test string and a nested event-observation deadline race. Both require focused fixes and final reruns. + +## Final candidate evidence 2026-09-17 + +Implementation worktree: ``, branch `codex/v1-v2-foundation`, baseline `0de817a919969821eceb8f29059a0ad9d3518a18`. Changes remain uncommitted. No push, tag, publication, global CLI replacement or original-checkout modification was performed. + +### Required local checks + +All results below use the final candidate source on macOS arm64: + +| Check | Result | +|---|---| +| `npm test -- --reporter=dot` under Node 22.23.2 | 26 files; 742 passed, 3 skipped | +| Same suite under Node 24.21.0 | 26 files; 742 passed, 3 skipped | +| `npm run docs:check` | Passed; generated manifest and tool documentation agree | +| `npm run test:coverage` under Node 22.23.2 | Passed; 71.46% statements, 70.37% branches, 72.5% functions, 74.52% lines | +| `npm audit` | Passed; zero vulnerabilities across 203 dependencies | +| `git diff --check` | Passed | +| `test:package` harness, retained archive, Node 22.23.2 and 24.21.0 | Passed on both; production-only install, executable help/version, stdio initialization, 87 tools, fixture health/create/delete, clean EOF shutdown | +| `test:compat` harness, Node 22.23.2 and 24.21.0 | Both exact native backends passed on each Node version | + +The three suite skips are one pre-existing skipped HTTP client stub and two Windows-only cases on macOS. The Node 24 event deadline regression and version assertion noted above were fixed before both final full runs. + +### Native integration scope + +Each compatibility run installed exact `opencode-ai@1.18.31` and `@opencode/cli@2.0.6` in isolated temporary environments and exercised the installed `3.1.0-rc.1` package. Passed: executable and authenticated HTTP runtime identity; stdio initialization/catalog; health/project/path/agents/config/file reads; owned session create/get/update/list/delete; bridge shutdown preserving the external backend; owned test-process cleanup. No model inference or user provider credentials were used. + +Durable execution recovery, forms/permissions, revert behavior and native MCP Tasks have fixture/contract coverage, not live inference verification. Interactive GUI clients and provider OAuth flows were not exercised. The configured Ubuntu/macOS/Windows × Node 22/24 GitHub Actions matrix has not been run on hosted runners; only local macOS evidence is claimed. + +### Retained release candidate + +- Archive: `artifacts/opencode-mcp-3.1.0-rc.1.tgz` (193487 bytes; 134 entries). +- Integrity: `sha512-rnGi4GwEjTSitIzuj/YT1Xrv5guImQuX/p73VD55KCTyvnGrRP0QgbtKYOTkWbvdwzIDzv6Yqdmuw67tH1BhWg==`. +- SHA-1: `5d57652801a380af57dca7defb8d53bfcef38242`. +- Evidence files: `artifacts/pack.json`, `artifacts/package-node22.json`, `artifacts/package-node24.json`, `artifacts/compat-node22.log`, `artifacts/compat-node24.log`. +- The same retained archive bytes were production-installed and tested under both Node versions. Archive inventory includes migration and compatibility documentation and excludes plans, tests, tool caches, artifacts and node_modules. +- `artifacts/` is ignored; candidate files are local review outputs, not a published release. + +### Acceptance decision + +The manifest accounts for all 87 tools, 10 resources, 4 resource templates and 6 prompts. V1 keeps 87 supported tools. V2 has 64 adapter-backed tools, 22 explicit planned exceptions, and one blocked operation. Unsupported/blocked V2 calls fail before mutation with `UNSUPPORTED_CAPABILITY`. + +**The original milestone gate is not complete.** V2 2.0.6 command submission has no caller identifier or result receipt, so exact completion attribution cannot be preserved. `opencode_command_execute` remains blocked instead of dispatching a weaker substitute. All independent work packages and local model-free verification are delivered. Live workflow/client evidence and hosted-platform CI remain explicitly unverified as detailed above. + +## Continued verification 2026-09-18 + +The retained `3.1.0-rc.1` archive was compared byte-for-byte against every corresponding current packaged file: all 134 files matched. Runtime and packaged documentation have not drifted from the candidate. + +A remaining evidence gap was identified: prior `test:compat` runs created their own archive, while exact retained-byte testing used the fixture-backed package harness. The follow-up adds explicit retained-archive selection to native compatibility checks and checks standalone doctor against the real isolated backends. Final results will be recorded below. + +Docker CLI was available, but `docker info` did not return within approximately one minute; only the probe process was terminated. No daemon or container was started/stopped. Linux and Windows remain unverified locally. Inference remains disabled without an explicitly selected provider/model. + +First retained-archive follow-up run on Node 22.23.2: V1 passed, including doctor reporting a configured native provider with 3 read requests, no startup, and no mutations. V2 reached the final backend-survival probe and failed there; the harness reports a generic operation failure without raw server output. This run is not accepted as a pass. Investigation is scoped to the final probe and process exit state; original failure evidence is retained in `artifacts/compat-retained-node22.log`. + +### Follow-up results + +- `npm exec --yes --package=node@22 -- npm test -- --reporter=dot`: **755 passed, 3 skipped**, 26 test files. Output: `artifacts/regression-followup-node22.log`. The runtime/package content is unchanged; 13 additional harness cases account for the increased total. +- Final focused harness suite: **27 passed** on Node 22.23.2 and Node 24.21.0. Node 24 output: `artifacts/harness-followup-node24.log`. +- Exact retained candidate native checks: V1 passed on Node 22.23.2 in the initial run; V2 passed on the diagnostic rerun (`artifacts/compat-retained-node22-v2-diagnostic.log`). Both backends passed together on Node 24.21.0 (`artifacts/compat-retained-node24.log`). Every report identifies the same retained archive and SHA-512 integrity recorded above. +- Live `doctor --json --directory` checks passed against each exact backend. V1 reported its configured native provider (3 read requests); isolated V2 correctly reported no configured provider and exit 1 (6 read requests). Provider readiness was cross-checked against native provider/integration responses. Neither diagnostic attempted HTTP mutations or native startup; raw and Basic-encoded server credentials were absent from stdout/stderr. Native backends survived bridge disconnect and owned test-process cleanup completed. +- The initial V2 failure did not reproduce. The previous phase label could also cover test cleanup, so its cause cannot be established from the retained original report. Safe allowlisted error/process diagnostics and a separate cleanup phase now preserve better evidence if it recurs. **No runtime fix or resolved root cause is claimed.** +- All 134 packaged files still exactly match the retained archive after the new regression build. Only `scripts/test-compat.mjs`, `tests/compat-harness.test.ts`, and these plan/evidence records changed during this continuation. + +To reproduce exact-candidate native checks without rebuilding/repacking: + +```sh +npm run test:compat -- --archive /absolute/path/to/opencode-mcp-3.1.0-rc.1.tgz +``` + +The original 65-tool compatibility gate remains blocked by the pinned V2 command API. No inference, live OAuth/GUI verification, hosted CI, publication, push or commit was performed. The transient initial native-test failure remains recorded for future investigation; it does not justify claiming full release readiness. + +## Upstream command tracking request 2026-09-18 + +Opened [anomalyco/opencode#49690](https://github.com/anomalyco/opencode/issues/49690): `[FEATURE]: V2 session.command invocation identity and result correlation`. The request uses the repository's feature template and includes immutable source links at V2 commit `90112f52db59a8f2ec412c66c6677193bf5dc7b8`, where the input still lacks a caller ID and output remains void/204. Duplicate searches found the earlier completed command implementation (#34429/#34849), which is linked for context; no matching correlation request was found. The report explicitly distinguishes contract inspection from a reproduced live concurrency bug. Body is retained in `plans/upstream-v2-command-issue.md`. Opening the issue does not resolve or change the capability blocker. + +## PR preparation 2026-09-18 + +The user requested pushing this work and opening a PR, superseding the original local-only delivery boundary. No npm publication or merge is authorized. The branch was fast-forwarded to remote main `6f1f62f`, whose file tree exactly matched baseline `0de817a`; implementation contents did not change. + +Before committing: Node 24.21.0 full suite passed (755 tests, 3 skipped), Node 22.23.2 coverage passed (71.95% statements, 70.70% branches, 72.40% functions, 75.19% lines), generated documentation checks passed, npm audit found zero vulnerabilities, and staged whitespace checks passed. Machine-specific absolute paths were removed from plan records. Local tool caches and retained archives are excluded from the commit. The PR is prepared as a draft with the upstream blocker and unverified live/hosted checks disclosed. diff --git a/scripts/generate-tool-docs.mjs b/scripts/generate-tool-docs.mjs index 4863f2e..1c3bdd5 100644 --- a/scripts/generate-tool-docs.mjs +++ b/scripts/generate-tool-docs.mjs @@ -5,9 +5,11 @@ import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { createServer } from "../dist/app.js"; +import { capabilities, capabilityFor } from "../dist/backends/capabilities.js"; const offline = new Proxy({}, { - get: (_target, property) => () => { throw new Error(`Documentation generation attempted runtime operation: ${String(property)}`); }, + get: (_target, property) => property === "getBackendIdentity" ? () => ({ kind: "v1", connectionSource: "explicit", processOwnership: "external", survivesDisconnect: true }) + : () => { throw new Error(`Documentation generation attempted runtime operation: ${String(property)}`); }, }); const full = createServer(offline, offline, "full").catalog; const essential = new Set(createServer(offline, offline, "essential").catalog.map((tool) => tool.name)); @@ -59,6 +61,7 @@ const names = new Set(); for (const tool of [...full].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { if (names.has(tool.name)) throw new Error(`Duplicate tool: ${tool.name}`); names.add(tool.name); + if (!capabilityFor(tool.name)) throw new Error(`Missing capability entry: ${tool.name}`); sections.get(groups.find(([, matches]) => matches(tool.name))[0]).push(tool); } const lines = [ @@ -66,6 +69,7 @@ const lines = [ "", "", `The full profile exposes **${full.length} tools**; the essential profile exposes **${essential.size} tools**. This reference is generated from the registered input/output schemas and behavior annotations.`, "", "Set `OPENCODE_TOOL_PROFILE=essential` for the smaller catalog; `full` is the default. Profile availability is listed for each tool. Profiles control discovery, not OpenCode permissions.", "", + "Tool names and schemas stay available across backends. Backend support is listed below and in the [generated compatibility inventory](compatibility.md); unavailable operations fail before mutation. Supported tools may have backend-specific option restrictions described in [migration](../MIGRATION.md).", "", "## Input and Output Contracts", "", "When present, `directory` means an absolute path on the OpenCode server. Global authentication tools omit it; local project initialization uses `path`. See [configuration](configuration.md) for scope and [examples](examples.md) for workflows.", "", "Results retain readable MCP `content` and provide `structuredContent`. Structured outputs include `text` and `isError`; generic JSON lives in `data`, while async tools provide identifiers and state fields. Tool errors also set the MCP result's `isError`. Inspect state rather than parsing text. An observation timeout can return a running state without an error or cancellation.", "", @@ -78,8 +82,10 @@ for (const [section, tools] of sections) { if (!tools.length) continue; lines.push(`## ${section}`, ""); for (const tool of tools) { + const capability = capabilityFor(tool.name); lines.push(`### \`${tool.name}\``, "", tool.description, "", `**Profiles:** ${essential.has(tool.name) ? "full, essential" : "full"}.`, "", + `**Backends:** V1 ${capability.v1}; V2 ${capability.v2}.${capability.reason ? ` ${capability.reason}` : ""}`, "", `**Annotations:** ${Object.entries(tool.annotations).map(([name, value]) => `\`${name}: ${value}\``).join(", ")}.`, "", "#### Parameters", "", properties(tool.inputSchema, true), "#### Structured output", "", properties(tool.outputSchema), @@ -88,15 +94,38 @@ for (const [section, tools] of sections) { } } const output = lines.join("\n"); -const destination = fileURLToPath(new URL("../docs/tools.md", import.meta.url)); -if (process.argv.includes("--check")) { - const current = await readFile(destination, "utf8"); - // Git may check out Markdown with CRLF on Windows; compare logical lines. - if (current.replaceAll("\r\n", "\n") !== output) { - console.error("Tool reference is stale. Run npm run docs:generate and commit docs/tools.md."); - process.exitCode = 1; +const toolCapabilities = capabilities.filter(item => item.kind === "tool"); +const countV2 = status => toolCapabilities.filter(item => item.v2 === status).length; +const compatibility = [ + "# Backend Compatibility", "", + "", "", + `The catalog contains **${toolCapabilities.length} tools**. V1 supports all ${toolCapabilities.length}; V2 has **${countV2("supported")} supported tools**, **${countV2("unsupported")} planned V1-only exceptions**, and **${countV2("blocked")} operation blocked by an upstream contract limitation**.`, "", + "The full and essential profiles keep their existing names and schemas. An unavailable V2 call returns a structured `UNSUPPORTED_CAPABILITY` error before mutation; it never falls back to a V1 route. Resource and prompt inventories are included below.", "", + "## Meaning of support", "", + "- **supported:** an adapter mapping exists. Individual options may still require explicit preflight rejection; see [migration](../MIGRATION.md). This status does not claim that every option or live execution has been verified.", + "- **unsupported:** an intentional V1-only exception for this milestone.", + "- **blocked:** a required operation could not preserve the public behavior with the pinned upstream contract. It remains an outstanding compatibility limitation.", "", + "The integration targets are OpenCode V1 `opencode-ai@1.18.31` and OpenCode V2 `@opencode/cli@2.0.6`, with `@opencode/client@2.0.6` pinned. Version recognition and fixture coverage are separate from [live verification evidence](releasing.md). Doctor reports the target version without claiming that a live integration check has passed.", "", + "V2 whole-message reverts remain staged and reversible. Because upstream 2.0.6 automatically commits staged reverts when prompting or compacting, the bridge rejects those operations until the revert is cleared with `opencode_session_unrevert` or explicitly committed outside the bridge.", "", + "## Required upstream follow-up", "", + ...toolCapabilities.filter(item => item.v2 === "blocked").map(item => `- **\`${item.name}\`**: ${item.reason} No detached command or guessed completion result is substituted.`), "", +]; +for (const [kind, title] of [["tool", "Tools"], ["resource", "Static resources"], ["template", "Resource templates"], ["prompt", "Prompts"]]) { + const entries = capabilities.filter(item => item.kind === kind).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0); + compatibility.push(`## ${title} (${entries.length})`, "", "| Name | V1 | V2 | Notes |", "|---|---|---|---|", + ...entries.map(item => `| \`${item.name}\` | ${item.v1} | ${item.v2} | ${cell(item.reason ?? "")} |`), ""); +} +for (const [file, contents] of [["tools.md", output], ["compatibility.md", compatibility.join("\n")]]) { + const destination = fileURLToPath(new URL(`../docs/${file}`, import.meta.url)); + if (process.argv.includes("--check")) { + const current = await readFile(destination, "utf8").catch(() => ""); + // Git may check out Markdown with CRLF on Windows; compare logical lines. + if (current.replaceAll("\r\n", "\n") !== contents) { + console.error(`Generated docs/${file} is stale. Run npm run docs:generate and commit generated documentation.`); + process.exitCode = 1; + } + } else { + await writeFile(destination, contents); + console.log(`Generated docs/${file}`); } -} else { - await writeFile(destination, output); - console.log(`Generated docs/tools.md (${full.length} full / ${essential.size} essential tools)`); } diff --git a/scripts/test-compat.mjs b/scripts/test-compat.mjs new file mode 100644 index 0000000..6f4580b --- /dev/null +++ b/scripts/test-compat.mjs @@ -0,0 +1,404 @@ +#!/usr/bin/env node + +// Real, isolated native backends. Never discovers/reuses a user's server. +// Default execution is model-free. All temporary installs/state are removed. +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { createServer, connect } from "node:net"; +import { createServer as createHttpServer } from "node:http"; +import { mkdtemp, mkdir, readFile, writeFile, rm, realpath, open, chmod, access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname, delimiter } from "node:path"; +import { pathToFileURL } from "node:url"; +import { repository, isolatedEnvironment, npmCommand, run, childProcess, stopProcess, + rpcConnection, packedInstall, packageOptions } from "./test-package.mjs"; + +// Windows cannot directly spawn a JavaScript shebang launcher. Resolve only +// the installed package's declared executable and preserve its native children. +export async function executableCommand(binary) { + const file = await open(binary, "r"); + try { + const buffer = Buffer.alloc(256); const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + const firstLine = buffer.subarray(0, bytesRead).toString("utf8").split(/\r?\n/, 1)[0]; + return /^#!.*\bnode(?:\s|$)/.test(firstLine) ? [process.execPath, binary] : [binary]; + } finally { await file.close(); } +} +async function assertPortClosed(port) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const closed = await new Promise((resolve) => { + const socket = connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { socket.destroy(); resolve(false); }); + socket.once("error", error => { socket.destroy(); resolve(error.code === "ECONNREFUSED"); }); + socket.setTimeout(500, () => { socket.destroy(); resolve(false); }); + }); + if (closed) return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error("Isolated backend port remained open after process-tree cleanup"); +} + +export const targets = Object.freeze({ + v1: { package: "opencode-ai", version: "1.18.31", health: "/global/health" }, + v2: { package: "@opencode/cli", version: "2.0.6", health: "/api/info" }, +}); + +export function compatibilityOptions(argv) { + const options = { backend: "all", inference: false, credentialEnv: [] }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag === "--inference") { options.inference = true; continue; } + if (!["--backend", "--provider", "--model", "--credential-env", "--archive"].includes(flag)) throw new Error(`Unknown option: ${flag}`); + const value = argv[++i]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}`); + if (flag === "--archive") { + if (options.archive !== undefined) throw new Error("--archive may only be provided once"); + options.archive = packageOptions(["--archive", value]).archive; + } + else if (flag === "--credential-env") options.credentialEnv.push(value); + else options[flag.slice(2)] = value; + } + if (!["all", "v1", "v2"].includes(options.backend)) throw new Error("--backend must be all, v1, or v2"); + if (options.inference && (!options.provider || !options.model)) throw new Error("--inference requires explicit --provider and --model"); + if (!options.inference && (options.provider || options.model || options.credentialEnv.length)) { + throw new Error("Provider, model, and credential options require --inference"); + } + for (const key of options.credentialEnv) { + if (!/^[A-Z][A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|ACCESS_KEY_ID|ACCESS_KEY)$/.test(key)) { + throw new Error("--credential-env must name a provider credential variable, not runtime/configuration settings"); + } + if (!process.env[key]) throw new Error(`Credential environment variable ${key} is empty`); + } + return options; +} + +async function unusedPort() { + const server = createServer(); + await new Promise((done, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", done); }); + const port = server.address().port; + await new Promise((done) => server.close(done)); + return port; +} + +const failurePhases = new WeakMap(); +const failureDiagnostics = new WeakMap(); +function withFailurePhase(error, phase) { + const value = error && typeof error === "object" ? error : new Error("Compatibility operation failed"); + if (!failurePhases.has(value)) failurePhases.set(value, phase); + return value; +} +export function safeFailureDetails(error, processInfo) { + const names = new Set(["Error", "TypeError", "AssertionError", "TimeoutError", "AbortError", "SystemError"]); + const codes = new Set(["ERR_ASSERTION", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOTFOUND", "ETIMEDOUT", "ESRCH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "ERR_STREAM_PREMATURE_CLOSE"]); + const signals = new Set(["SIGTERM", "SIGKILL", "SIGINT", "SIGABRT", "SIGSEGV"]); + const result = {}; + if (names.has(error?.name)) result.name = error.name; + let current = error; + for (let depth = 0; depth < 5 && current && typeof current === "object"; depth++, current = current.cause) { + if (codes.has(current.code)) { result.code = current.code; break; } + } + if (processInfo) { + result.backendExitCode = Number.isInteger(processInfo.child.exitCode) ? processInfo.child.exitCode : null; + result.backendSignal = signals.has(processInfo.child.signalCode) ? processInfo.child.signalCode : null; + } + return result; +} +function captureFailure(error, phase, backend) { + const value = withFailurePhase(error, phase); + if (!failureDiagnostics.has(value)) failureDiagnostics.set(value, safeFailureDetails(value, backend)); + return value; +} +export function compatibilityErrorSummary(error) { + const phase = error && typeof error === "object" ? failurePhases.get(error) : undefined; + const detail = phase ? `Phase: ${phase}. ` : ""; + return detail + failureSummary(error); +} +function failureSummary(error) { + if (error?.code === "BACKEND_AUTHENTICATION") return "The isolated server rejected its configured authentication. Raw diagnostics are omitted."; + if (error?.code === "BACKEND_STARTUP") return "The isolated server exited before readiness. Raw diagnostics are omitted."; + if (error?.code === "BACKEND_TIMEOUT") return "The isolated server did not become ready before its deadline. Raw diagnostics are omitted."; + if (error?.code === "ERR_ASSERTION") return "A compatibility assertion failed. Raw responses and diagnostics are omitted."; + return "A compatibility operation failed. Raw responses and diagnostics are omitted."; +} +/** V2's documented foreground discovery line; keep the extracted value in memory. */ +export function ownedServerPassword(stdout) { + const plain = stdout.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, ""); + return plain.match(/^\s*server password\s+(\S+)\s*$/m)?.[1]; +} +export async function waitForBackend(processInfo, baseUrl, target, headers, timeoutMs = 45_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (processInfo.child.exitCode !== null || processInfo.child.signalCode !== null) { + throw Object.assign(new Error("Isolated backend exited during startup."), { code: "BACKEND_STARTUP" }); + } + try { + const authentication = typeof headers === "function" ? headers() : headers; + if (!authentication) { await new Promise(done => setTimeout(done, 150)); continue; } + const response = await fetch(baseUrl + target.health, { headers: authentication, redirect: "error", signal: AbortSignal.timeout(Math.max(1, Math.min(1500, deadline - Date.now()))) }); + if (response.status === 401 || response.status === 403) throw Object.assign(new Error("Isolated backend authentication failed."), { code: "BACKEND_AUTHENTICATION" }); + if (response.ok) { + const body = await response.json(); + assert.equal(body.version, target.version, "Backend runtime identity must match pinned package version"); + return body; + } + } catch (error) { if (error.code === "ERR_ASSERTION" || error.code === "BACKEND_AUTHENTICATION") throw error; } + await new Promise((done) => setTimeout(done, 150)); + } + throw Object.assign(new Error("Isolated backend readiness timed out."), { code: "BACKEND_TIMEOUT" }); +} + +/** Assert only public diagnostic fields; never interpolate doctor output into errors. */ +export function assertDoctorResult(result, target, secrets = []) { + const output = result.stdout + result.stderr; + assert.ok(secrets.filter(Boolean).every(value => !output.includes(value)), "Doctor output leaked an isolated credential"); + const report = JSON.parse(result.stdout); + assert.equal(report.schemaVersion, 1); + const checks = new Map(report.checks.map(check => [check.id, check])); + for (const id of ["node", "configuration", "backend", "authentication", "supported_version", "project_access", "lifecycle"]) { + assert.equal(checks.get(id)?.status, "pass", `Doctor ${id} must pass against the isolated native server`); + } + const provider = checks.get("provider_configuration"); + // A free/built-in provider may be configured without supplied credentials. + // A failed provider read is not equivalent to successfully reading an empty list. + assert.ok(provider?.status === "pass" || provider?.status === "fail" && provider.message === "No configured provider was found.", "Doctor must distinguish absent providers from failed provider reads"); + const ready = provider.status === "pass"; + assert.equal(report.ready, ready); assert.equal(result.code, ready ? 0 : 1); + assert.equal(report.backend?.version, target.version); + assert.equal(report.backend?.integrationTargetVersion, target.version); + assert.equal(report.backend?.matchesIntegrationTarget, true); + assert.equal(report.backend?.processOwnership, "external"); + assert.equal(report.backend?.connectionSource, "explicit"); + assert.equal(report.backend?.survivesDisconnect, true); + return { ready, providerConfigured: ready }; +} + +export function configuredProviderEvidence(target, responses) { + if (target === targets.v1) { + const value = responses.get("/provider"); + assert.ok(Array.isArray(value?.connected), "Native V1 provider response must contain connected IDs"); + return value.connected.length > 0; + } + const providers = responses.get("/api/provider")?.data; + const integrations = responses.get("/api/integration")?.data; + assert.ok(Array.isArray(providers) && Array.isArray(integrations), "Native V2 provider/integration responses must be available"); + return providers.some(provider => integrations.some(integration => integration.id === provider.integrationID && Array.isArray(integration.connections) && integration.connections.length > 0)); +} + +async function checkDoctor(installed, target, env, credentials, project, workspace, baseUrl) { + const requests = [], responses = new Map(); + // Only this doctor subprocess uses the proxy. A mutation is recorded and + // rejected before reaching the real server, so the verification stays read-only. + const proxy = createHttpServer(async (req, res) => { + requests.push({ method: req.method, path: req.url }); + if (!["GET", "HEAD"].includes(req.method)) { res.writeHead(405); res.end(); return; } + try { + const response = await fetch(baseUrl + req.url, { method: req.method, redirect: "manual", + headers: req.headers.authorization ? { Authorization: req.headers.authorization } : {}, signal: AbortSignal.timeout(16000) }); + const body = Buffer.from(await response.arrayBuffer()); + const path = new URL(req.url, "http://fixture").pathname; + if (["/provider", "/api/provider", "/api/integration"].includes(path) && response.ok) responses.set(path, JSON.parse(body.toString())); + res.writeHead(response.status, { "content-type": response.headers.get("content-type") ?? "application/json" }); + res.end(body); + } catch { if (res.headersSent) res.destroy(); else { res.writeHead(502); res.end(); } } + }); + let doctor; + const sentinel = join(workspace, "doctor-bin"), invoked = join(sentinel, "invoked"); + await mkdir(sentinel); + const script = join(sentinel, "sentinel.cjs"); + await writeFile(script, `require("node:fs").writeFileSync(${JSON.stringify(invoked)}, "startup attempted");process.exit(99);`); + await writeFile(join(sentinel, "opencode"), `#!${process.execPath}\n` + await readFile(script, "utf8")); + await chmod(join(sentinel, "opencode"), 0o755); + await writeFile(join(sentinel, "opencode.cmd"), `@"${process.execPath}" "${script}"\r\n`); + try { + await new Promise((done, reject) => { proxy.once("error", reject); proxy.listen(0, "127.0.0.1", done); }); + const endpoint = `http://127.0.0.1:${proxy.address().port}`; + const pathKey = Object.keys(env).find(key => key.toLowerCase() === "path") ?? "PATH"; + doctor = childProcess(process.execPath, [installed.executable, "doctor", "--json", "--directory", project], { cwd: project, + env: { ...env, ...credentials, [pathKey]: [sentinel, dirname(process.execPath)].join(delimiter), OPENCODE_BASE_URL: endpoint, + OPENCODE_BACKEND: "auto", OPENCODE_AUTO_SERVE: "true" }, ownedProcessGroup: true }); + let timer; + try { + const result = await Promise.race([doctor.exited, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Doctor deadline exceeded")), 20000); })]); + const secret = credentials.OPENCODE_SERVER_PASSWORD; + const report = assertDoctorResult({ ...result, stdout: doctor.stdout, stderr: doctor.stderr }, target, + [secret, Buffer.from(`opencode:${secret}`).toString("base64")]); + assert.equal(report.providerConfigured, configuredProviderEvidence(target, responses), "Doctor readiness must match native provider configuration evidence"); + assert.ok(requests.length > 0 && requests.every(request => ["GET", "HEAD"].includes(request.method)), "Doctor must make only read requests"); + await assert.rejects(access(invoked), { code: "ENOENT" }); + return { ...report, readRequests: requests.length, startupAttempted: false, mutations: 0 }; + } finally { clearTimeout(timer); } + } finally { + if (doctor) await stopProcess(doctor); + proxy.closeAllConnections(); await new Promise(done => proxy.close(done)); + } +} + +const textOf = (result) => (result.content ?? []).filter((part) => part.type === "text").map((part) => part.text).join("\n"); +const sessionIdOf = (result) => result.structuredContent?.sessionId ?? result.structuredContent?.session?.id + ?? result.structuredContent?.data?.id ?? textOf(result).match(/^ID: (\S+)$/m)?.[1]; + +async function checkBackend(kind, root, installed, options) { + let phase = "fixture-setup"; + try { + const target = targets[kind]; + const workspace = join(root, kind); + await mkdir(workspace, { recursive: true }); + const env = await isolatedEnvironment(workspace); + const installation = join(workspace, "installation"), project = join(workspace, "project"); + await mkdir(installation); await mkdir(project); + await writeFile(join(installation, "package.json"), JSON.stringify({ private: true, name: `compat-${kind}` })); + const marker = `opencode-mcp-live-${kind}-${target.version}`; + await writeFile(join(project, "README.md"), `${marker}\n`); + // Explicit config lives only inside this disposable project/home. + await writeFile(join(project, "opencode.json"), JSON.stringify(kind === "v1" + ? { permission: "deny", autoupdate: false, plugin: [] } + : { permissions: [{ action: "*", resource: "*", effect: "deny" }], update: "disable", warming: false, plugins: [] })); + await run("git", ["-c", "init.templateDir=", "init", "--quiet", project], { env, cwd: workspace }); + await run("git", ["-C", project, "add", "README.md", "opencode.json"], { env, cwd: workspace }); + await run("git", ["-C", project, "-c", "user.name=Compatibility Fixture", "-c", "user.email=fixture@example.invalid", + "-c", "commit.gpgsign=false", "-c", "core.hooksPath=", "commit", "--quiet", "-m", "Isolated compatibility fixture"], { env, cwd: workspace }); + console.log(`Installing isolated ${target.package}@${target.version} (${process.platform}/${process.arch}, Node ${process.version})`); + phase = "native-install"; + await run(installed.npm, [...installed.prefix, "install", "--omit=dev", "--no-audit", "--no-fund", "--save-exact", + `${target.package}@${target.version}`], { env, cwd: installation }, 240_000); + await rm(join(workspace, "npm-cache"), { recursive: true, force: true }); + const packageRoot = join(installation, "node_modules", target.package); + const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); + assert.equal(manifest.version, target.version); + const binary = join(packageRoot, manifest.bin.opencode); + const [command, ...commandPrefix] = await executableCommand(binary); + phase = "native-version"; + const reported = (await run(command, [...commandPrefix, "--version"], { env, cwd: project }, 30_000)).trim(); + assert.ok(reported.split(/\s+/).some(token => token.replace(/^v/, "") === target.version), `Unexpected executable version: ${reported}`); + const port = await unusedPort(), baseUrl = `http://127.0.0.1:${port}`; + let password = kind === "v1" ? randomBytes(32).toString("hex") : undefined; + const credentials = password ? { OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password } : {}; + let headers = password ? { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` } : undefined; + const backendEnv = { ...env, ...credentials }; + if (options.inference) for (const key of options.credentialEnv) backendEnv[key] = process.env[key]; + // `serve` starts the explicitly owned headless process; never invoke the + // default TUI/shared-service startup or install a global executable. + phase = "backend-launch"; + const backend = childProcess(command, [...commandPrefix, "serve", "--hostname", "127.0.0.1", "--port", String(port)], { env: backendEnv, cwd: project, ownedProcessGroup: true }); + let connection; + const ownedSessions = new Set(), checks = []; + let failure; + try { + phase = "backend-readiness"; + await waitForBackend(backend, baseUrl, target, () => { + if (kind === "v2" && !password) { + password = ownedServerPassword(backend.stdout); + if (password) { + credentials.OPENCODE_SERVER_USERNAME = "opencode"; + credentials.OPENCODE_SERVER_PASSWORD = password; + headers = { Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` }; + } + } + return headers; + }); + checks.push("native executable and HTTP runtime version"); + phase = "doctor-json"; + const doctor = await checkDoctor(installed, target, env, credentials, project, workspace, baseUrl); + assert.equal(backend.child.exitCode, null, "Doctor must leave the owned native server running"); + checks.push("archive doctor JSON/read-only readiness/no startup/no credential disclosure"); + connection = rpcConnection(process.execPath, [installed.executable], { cwd: project, env: { + ...env, ...credentials, OPENCODE_BASE_URL: baseUrl, OPENCODE_BACKEND: "auto", OPENCODE_TOOL_PROFILE: "full", + } }); + phase = "mcp-initialize"; + await connection.initialize(); + const call = (name, input = {}, timeout) => { phase = `tool:${name}`; return connection.call(name, input, timeout); }; + phase = "mcp-catalog"; + const catalog = await connection.request("tools/list"); + assert.ok(catalog.tools.some((tool) => tool.name === "opencode_session_create")); + await call("opencode_health"); + for (const name of ["opencode_project_current", "opencode_path_get", "opencode_agent_list", "opencode_config_get"]) { + await call(name, { directory: project }); + } + const file = await call("opencode_file_read", { path: "README.md", directory: project }); + assert.ok(textOf(file).includes(marker), "Remote file read must return the disposable project's marker"); + checks.push("archive stdio initialize/catalog/health/project/path/agents/config/file"); + const created = await call("opencode_session_create", { title: "Compatibility fixture", directory: project }); + const sessionId = sessionIdOf(created); + assert.equal(typeof sessionId, "string", "Session creation must return a stable ID"); + ownedSessions.add(sessionId); + await call("opencode_session_get", { id: sessionId, directory: project }); + await call("opencode_session_update", { id: sessionId, title: "Updated compatibility fixture", directory: project }); + const updated = await call("opencode_session_get", { id: sessionId, directory: project }); + assert.ok(textOf(updated).includes("Updated compatibility fixture")); + const sessions = await call("opencode_session_list", { directory: project }); + assert.ok(JSON.stringify(sessions).includes(sessionId)); + await call("opencode_session_delete", { id: sessionId, directory: project }); + ownedSessions.delete(sessionId); + checks.push("model-free session create/get/update/list/delete"); + if (options.inference) { + // Deny every model tool action; only a text response is requested. + const answer = await call("opencode_ask", { prompt: "Reply with exactly: compatibility-ok. Do not use tools.", + providerID: options.provider, modelID: options.model, directory: project }, 180_000); + const inferredSession = sessionIdOf(answer); + if (inferredSession) ownedSessions.add(inferredSession); + assert.match(textOf(answer), /compatibility-ok/); + checks.push(`explicit inference ${options.provider}/${options.model}`); + } + for (const id of ownedSessions) await call("opencode_session_delete", { id, directory: project }); + ownedSessions.clear(); + phase = "mcp-shutdown"; + await connection.close(); connection = undefined; + phase = "backend-survival"; + assert.equal((await fetch(baseUrl + target.health, { headers, redirect: "error", signal: AbortSignal.timeout(3000) })).ok, true, + "Closing the bridge must leave its externally owned backend alive"); + checks.push("bridge clean shutdown and backend ownership"); + return { backend: kind, package: target.package, version: target.version, evidence: "live isolated native backend + installed archive", + status: "passed", checks, doctor, skips: ["Durable jobs, input forms/permissions, revert and model execution recovery: fixture coverage only", "Interactive GUI clients and provider OAuth: not exercised", ...(options.inference ? [] : ["Inference: not explicitly enabled", "Provider credentials: not supplied"])] }; + } catch (error) { + failure = captureFailure(error, phase, backend); + throw failure; + } finally { + try { + if (connection) await stopProcess(connection.process); + await stopProcess(backend); + await assertPortClosed(port); + } catch (error) { + if (failure) failureDiagnostics.set(failure, { ...failureDiagnostics.get(failure), cleanup: safeFailureDetails(error, backend) }); + else throw captureFailure(error, "backend-cleanup", backend); + } + } + } catch (error) { throw withFailurePhase(error, phase); } +} + +export async function compatibilityCheck(options) { + if (options.archive !== undefined) packageOptions(["--archive", options.archive]); + const root = await realpath(await mkdtemp(join(tmpdir(), "opencode-compat-"))); + const results = []; + let phase = "fixture-setup"; + try { + const env = await isolatedEnvironment(root); + const [npm, ...prefix] = await npmCommand(); + if (!options.archive) { + phase = "build"; + await run(npm, [...prefix, "run", "build"], { env, cwd: repository }); + } + phase = "archive-install"; + const installed = await packedInstall(root, env, options.archive); + for (const kind of options.backend === "all" ? ["v1", "v2"] : [options.backend]) { + try { results.push(await checkBackend(kind, root, installed, options)); } + catch (error) { results.push({ backend: kind, ...targets[kind], status: "failed", error: compatibilityErrorSummary(error), diagnostics: failureDiagnostics.get(error) ?? safeFailureDetails(error) }); } + finally { await rm(join(root, kind), { recursive: true, force: true }); } + } + const report = { check: "compatibility", packageVersion: installed.manifest.version, + archive: installed.sourceArchive, integrity: installed.integrity, + node: process.version, platform: process.platform, architecture: process.arch, results }; + let serialized = JSON.stringify(report, null, 2); + for (const key of options.credentialEnv) serialized = serialized.replaceAll(process.env[key], "[REDACTED]"); + console.log(serialized); + phase = "compatibility-results"; + assert.ok(results.every((result) => result.status === "passed"), "One or more pinned live backend checks failed (see report)"); + return report; + } catch (error) { throw withFailurePhase(error, phase); } + finally { await rm(root, { recursive: true, force: true }); } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + Promise.resolve().then(() => compatibilityCheck(compatibilityOptions(process.argv.slice(2)))).catch((error) => { + console.error(`Compatibility verification FAILED: ${compatibilityErrorSummary(error)}`); process.exitCode = 1; + }); +} diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs new file mode 100644 index 0000000..c4ef7c3 --- /dev/null +++ b/scripts/test-package.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node + +// Exercise the published archive, without importing repository/dev dependencies. +// Helpers are shared with test-compat; importing this module does not run checks. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdtemp, mkdir, readFile, writeFile, rm, access, realpath, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve, isAbsolute } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const repository = fileURLToPath(new URL("../", import.meta.url)); +const delay = (ms) => new Promise((done) => setTimeout(done, ms)); + +export async function isolatedEnvironment(root) { + // Deliberately allowlist inherited OS necessities. No provider credentials, + // user npm config, OpenCode settings, NODE_OPTIONS, or shared-service settings. + const env = {}; + const allowed = new Set(["path", "systemroot", "windir", "comspec", "pathext", "lang", "lc_all"]); + for (const [key, value] of Object.entries(process.env)) { + if (allowed.has(key.toLowerCase()) && value !== undefined) env[key] = value; + } + for (const dir of ["home", "config", "data", "state", "cache", "tmp", "npm-cache", "appdata", "localappdata"]) { + await mkdir(join(root, dir), { recursive: true }); + } + const npmrc = join(root, "npmrc"); + await writeFile(npmrc, ""); + const globalNpmrc = join(root, "npmrc-global"); + await writeFile(globalNpmrc, ""); + return { ...env, HOME: join(root, "home"), USERPROFILE: join(root, "home"), + XDG_CONFIG_HOME: join(root, "config"), XDG_DATA_HOME: join(root, "data"), + XDG_STATE_HOME: join(root, "state"), XDG_CACHE_HOME: join(root, "cache"), + APPDATA: join(root, "appdata"), LOCALAPPDATA: join(root, "localappdata"), + TMPDIR: join(root, "tmp"), TMP: join(root, "tmp"), TEMP: join(root, "tmp"), + npm_config_userconfig: npmrc, npm_config_globalconfig: globalNpmrc, + npm_config_cache: join(root, "npm-cache"), npm_config_update_notifier: "false", + OPENCODE_AUTO_SERVE: "false", OPENCODE_DISABLE_AUTOUPDATE: "true", + OPENCODE_DISABLE_MODELS_FETCH: "true", OPENCODE_TASK_STORE: join(root, "jobs"), + CI: "true", NO_COLOR: "1" }; +} + +export async function npmCommand() { + const candidates = [process.env.npm_execpath, + join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"), + join(dirname(process.execPath), "../lib/node_modules/npm/bin/npm-cli.js")].filter(Boolean); + for (const candidate of candidates) { + if (!candidate.endsWith("npm-cli.js")) continue; + try { await access(candidate); return [process.execPath, candidate]; } catch { /* next layout */ } + } + // Resolve the installed npm entry without executing a shell (also works on Windows). + for (const part of (process.env.PATH ?? process.env.Path ?? "").split(process.platform === "win32" ? ";" : ":")) { + for (const candidate of [join(part, "npm"), join(part, "node_modules/npm/bin/npm-cli.js")]) { + try { + const target = await realpath(candidate); + if (target.endsWith("npm-cli.js")) return [process.execPath, target]; + } catch { /* next PATH entry */ } + } + } + throw new Error("Cannot locate npm-cli.js; run this check through npm run test:package or npm run test:compat"); +} + +export function childProcess(command, args, options) { + const { ownedProcessGroup = false, ...spawnOptions } = options ?? {}; + const child = spawn(command, args, { ...spawnOptions, ...(ownedProcessGroup && process.platform !== "win32" ? { detached: true } : {}), stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + let stdout = "", stderr = ""; + child.stdout.on("data", (chunk) => { stdout = (stdout + chunk).slice(-100_000); }); + child.stderr.on("data", (chunk) => { stderr = (stderr + chunk).slice(-100_000); }); + const exited = new Promise((done, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => done({ code, signal })); + }); + // Attach a rejection handler now, even when a caller first waits for readiness. + exited.catch(() => {}); + return { child, exited, ownedProcessGroup, get stdout() { return stdout; }, get stderr() { return stderr; } }; +} + +export async function stopProcess(processInfo) { + if (processInfo.stopped) return; + const { child, exited } = processInfo; + if (processInfo.ownedProcessGroup && child.pid) { + // Only explicitly isolated children have their own process group. This + // handles launchers whose native child outlives the JavaScript wrapper. + if (process.platform === "win32") { + const killer = childProcess("taskkill", ["/pid", String(child.pid), "/t", "/f"], {}); + await killer.exited; + } else { + const alive = () => { try { process.kill(-child.pid, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; throw error; } }; + if (alive()) process.kill(-child.pid, "SIGTERM"); + const deadline = Date.now() + 3000; + while (alive() && Date.now() < deadline) await delay(50); + if (alive()) process.kill(-child.pid, "SIGKILL"); + } + await exited; + processInfo.stopped = true; + return; + } + if (child.exitCode !== null || child.signalCode !== null) return; + child.stdin.end(); + if (await Promise.race([exited.then(() => true), delay(1500).then(() => false)])) return; + child.kill("SIGTERM"); + if (await Promise.race([exited.then(() => true), delay(3000).then(() => false)])) return; + if (process.platform === "win32") { + const killer = childProcess("taskkill", ["/pid", String(child.pid), "/t", "/f"], {}); + await killer.exited; + } else child.kill("SIGKILL"); + await exited; +} + +export async function run(command, args, options = {}, timeoutMs = 120_000) { + const p = childProcess(command, args, options); + let timer; + try { + const result = await Promise.race([p.exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Command timed out: ${args[0] ?? command}`)), timeoutMs); + })]); + assert.equal(result.code, 0, `Command failed (${result.code ?? result.signal}): ${p.stderr.slice(-3000)}\n${p.stdout.slice(-1000)}`); + return p.stdout; + } finally { clearTimeout(timer); await stopProcess(p); } +} + +export function rpcConnection(command, args, options = {}) { + const p = childProcess(command, args, options); + const pending = new Map(); + let nextId = 0, buffer = "", protocolError; + function rejectPending(error) { + for (const handler of pending.values()) { clearTimeout(handler.timer); handler.reject(error); } + pending.clear(); + } + p.child.stdout.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + if (!line.trim()) continue; + let message; + try { message = JSON.parse(line); } + catch { protocolError = new Error("Executable wrote non-JSON text to MCP stdout"); rejectPending(protocolError); continue; } + const handler = pending.get(message.id); + if (!handler) continue; + clearTimeout(handler.timer); pending.delete(message.id); + if (message.error) handler.reject(new Error(`MCP ${handler.method} failed: ${JSON.stringify(message.error)}`)); + else handler.resolve(message.result); + } + }); + p.exited.then(() => rejectPending(new Error(`MCP executable exited: ${p.stderr.slice(-1500)}`)), rejectPending); + const notify = (method, params = {}) => p.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n"); + return { + process: p, + notify, + async request(method, params = {}, timeoutMs = 15_000) { + if (protocolError) throw protocolError; + if (p.child.exitCode !== null || p.child.signalCode !== null) throw new Error("MCP executable already exited"); + const id = ++nextId; + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error(`MCP ${method} timed out: ${p.stderr.slice(-1000)}`)); }, timeoutMs); + pending.set(id, { resolve, reject, timer, method }); + }); + p.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + return response; + }, + async initialize() { + const info = await this.request("initialize", { protocolVersion: "2025-11-25", capabilities: {}, + clientInfo: { name: "opencode-mcp-release-check", version: "1.0.0" } }); + assert.equal(info.serverInfo.name, "opencode-mcp"); + notify("notifications/initialized"); + return info; + }, + async call(name, args = {}, timeoutMs) { + const result = await this.request("tools/call", { name, arguments: args }, timeoutMs); + assert.notEqual(result.isError, true, `${name}: ${JSON.stringify(result)}`); + return result; + }, + async close() { + p.child.stdin.end(); + let timer; + try { + const result = await Promise.race([p.exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("MCP executable failed to exit after stdin EOF")), 8000); + })]); + assert.equal(result.code, 0, "MCP executable must exit cleanly"); + assert.equal(protocolError, undefined); + assert.equal(buffer.trim(), "", "MCP stdout ended with an incomplete frame"); + } finally { clearTimeout(timer); await stopProcess(p); } + }, + }; +} + +export function packageOptions(argv) { + if (argv.length === 0) return {}; + if (argv.length !== 2 || argv[0] !== "--archive") throw new Error("Usage: test-package.mjs [--archive /absolute/path.tgz]"); + if (!isAbsolute(argv[1]) || !argv[1].toLowerCase().endsWith(".tgz")) throw new Error("--archive must name an absolute .tgz file path"); + return { archive: argv[1] }; +} + +export async function packedInstall(root, env, suppliedArchive) { + const [npm, ...prefix] = await npmCommand(); + const archiveDir = join(root, "archive"), consumer = join(root, "consumer"); + await mkdir(archiveDir, { recursive: true }); await mkdir(consumer, { recursive: true }); + await writeFile(join(consumer, "package.json"), JSON.stringify({ name: "opencode-mcp-release-consumer", private: true })); + let archive, sourceArchive; + if (suppliedArchive !== undefined) { + packageOptions(["--archive", suppliedArchive]); + sourceArchive = await realpath(suppliedArchive); + assert.ok((await stat(sourceArchive)).isFile(), "--archive must be a regular file"); + // Capture the exact bytes once; npm reads this isolated immutable candidate. + archive = join(archiveDir, "candidate.tgz"); + await writeFile(archive, await readFile(sourceArchive), { flag: "wx", mode: 0o600 }); + } else { + const packed = JSON.parse(await run(npm, [...prefix, "pack", "--ignore-scripts", "--json", "--pack-destination", archiveDir], { cwd: repository, env })); + assert.equal(packed.length, 1); + archive = join(archiveDir, packed[0].filename); + } + const integrity = "sha512-" + createHash("sha512").update(await readFile(archive)).digest("base64"); + await run(npm, [...prefix, "install", "--omit=dev", "--no-audit", "--no-fund", archive], { cwd: consumer, env }, 180_000); + const manifest = JSON.parse(await readFile(join(consumer, "node_modules/opencode-mcp/package.json"), "utf8")); + assert.equal(manifest.name, "opencode-mcp", "Archive must contain the opencode-mcp package"); + const executable = join(consumer, "node_modules/opencode-mcp", manifest.bin["opencode-mcp"]); + await access(executable); + await access(join(consumer, "node_modules/.bin", process.platform === "win32" ? "opencode-mcp.cmd" : "opencode-mcp")); + // Assert dev-only packages are not installed at the consumer root. + for (const dependency of ["vitest", "typescript"]) { + await assert.rejects(access(join(consumer, "node_modules", dependency))); + } + return { manifest, archive, sourceArchive, integrity, consumer, executable, npm, prefix }; +} + +export async function packageCheck(options = {}) { + if (options.archive !== undefined) packageOptions(["--archive", options.archive]); + const root = await mkdtemp(join(tmpdir(), "opencode-package-")); + let fixture, connection; + try { + const env = await isolatedEnvironment(root); + const [npm, ...prefix] = await npmCommand(); + if (!options.archive) await run(npm, [...prefix, "run", "build"], { cwd: repository, env }); + const installed = await packedInstall(root, env, options.archive); + const execArgs = [...prefix, "exec", "--offline", "--", "opencode-mcp"]; + const help = await run(npm, [...execArgs, "--help"], { cwd: installed.consumer, env }, 10_000); + assert.match(help, /opencode-mcp/i); + const version = await run(npm, [...execArgs, "--version"], { cwd: installed.consumer, env }, 10_000); + assert.ok(version.includes(installed.manifest.version), "--version must match archive package.json"); + const seen = []; + fixture = createServer((req, res) => { + const path = new URL(req.url, "http://fixture").pathname; + seen.push(`${req.method} ${path}`); + res.setHeader("content-type", "application/json"); + if (path === "/global/health") res.end(JSON.stringify({ healthy: true, version: "1.18.31" })); + else if (path === "/session" && req.method === "POST") res.end(JSON.stringify({ id: "ses_archive_fixture", title: "Archive fixture" })); + else if (path === "/session/ses_archive_fixture" && req.method === "DELETE") res.end("true"); + else { res.statusCode = 404; res.end('{"error":"unknown fixture route"}'); } + }); + await new Promise((done) => fixture.listen(0, "127.0.0.1", done)); + const baseUrl = `http://127.0.0.1:${fixture.address().port}`; + connection = rpcConnection(npm, execArgs, { cwd: installed.consumer, env: { ...env, + OPENCODE_BASE_URL: baseUrl, OPENCODE_BACKEND: "auto", OPENCODE_TOOL_PROFILE: "full" } }); + const initialized = await connection.initialize(); + assert.equal(initialized.serverInfo.version, installed.manifest.version); + const catalog = await connection.request("tools/list"); + assert.ok(catalog.tools.some((tool) => tool.name === "opencode_health")); + assert.ok(catalog.tools.some((tool) => tool.name === "opencode_session_create")); + const health = await connection.call("opencode_health"); + assert.match(JSON.stringify(health), /1\.18\.31/); + const created = await connection.call("opencode_session_create", { title: "Archive fixture" }); + assert.equal(created.structuredContent.sessionId, "ses_archive_fixture"); + await connection.call("opencode_session_delete", { id: "ses_archive_fixture" }); + await connection.close(); connection = undefined; + assert.ok(seen.includes("POST /session")); assert.ok(seen.includes("DELETE /session/ses_archive_fixture")); + assert.equal((await fetch(`${baseUrl}/global/health`)).ok, true, "MCP shutdown must leave externally owned backends alive"); + console.log(JSON.stringify({ check: "package", evidence: "actual archive; production install; fixture backend", status: "passed", + packageVersion: installed.manifest.version, archive: installed.sourceArchive, integrity: installed.integrity, node: process.version, platform: process.platform, + toolCount: catalog.tools.length, checks: ["npm executable help/version", "initialize", "tools/list", "fixture health/create/delete", "stdin EOF shutdown"] }, null, 2)); + } finally { + if (connection) await stopProcess(connection.process); + if (fixture) { fixture.closeAllConnections(); await new Promise((done) => fixture.close(done)); } + await rm(root, { recursive: true, force: true }); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + Promise.resolve().then(() => packageCheck(packageOptions(process.argv.slice(2)))).catch((error) => { console.error(`Package verification FAILED: ${error.message}`); process.exitCode = 1; }); +} diff --git a/src/app.ts b/src/app.ts index 3480ab7..bd1e5c0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,3 +1,4 @@ +import { packageVersion } from "./version.js"; import { McpServer, type ToolProfile } from "./mcp-server.js"; import { OpenCodeClient } from "./client.js"; import { JobService } from "./jobs.js"; @@ -22,7 +23,7 @@ import { registerPrompts } from "./prompts.js"; /** Build registrations without connecting or performing network/filesystem I/O. */ export function createServer(client: OpenCodeClient, jobs: JobService, profile: ToolProfile = "full") { - const server = new McpServer({ name: "opencode-mcp", version: "3.0.0", + const server = new McpServer({ name: "opencode-mcp", version: packageVersion, description: "Delegate coding work to OpenCode with durable jobs, explicit input, and project-scoped tools." }, { capabilities: { extensions: { [TASKS_EXTENSION]: {} } }, cacheHints: { "tools/list": { ttlMs: 60000, cacheScope: "private" }, "prompts/list": { ttlMs: 60000, cacheScope: "private" }, @@ -30,7 +31,7 @@ export function createServer(client: OpenCodeClient, jobs: JobService, profile: "resources/read": { ttlMs: 0, cacheScope: "private" } }, instructions: [ "Start with opencode_setup. Discover configured providers/models using opencode_provider_list and opencode_provider_models; pass their IDs or configure OPENCODE_DEFAULT_PROVIDER/MODEL.", - "Use opencode_ask for a short task and opencode_reply to continue a session. Model output may be constrained with format: {type: 'json_schema', schema: {...}}.", + "Use opencode_ask for a short task and opencode_reply to continue a session. V1 model output may be constrained with format: {type: 'json_schema', schema: {...}}. V2 rejects schema formats, nonempty system overrides, and noReply. Existing V2 sessions preserve their model and agent; defaults apply only to new sessions.", "Use opencode_run for long work. July 2026 clients advertising the Tasks extension receive a native task handle; poll tasks/get, supply explicit input via tasks/update, and abort via tasks/cancel.", "Other clients receive an observed result from opencode_run. opencode_fire returns immediately. Keep jobId, sessionId, and messageId to resume observation with opencode_job_get, opencode_check, or opencode_wait.", "Observation timeouts and disconnects do not cancel OpenCode work. An unknown submission outcome must be checked before any retry. Local job records expire after 24 hours; they do not keep a stopped OpenCode process alive.", @@ -42,6 +43,7 @@ export function createServer(client: OpenCodeClient, jobs: JobService, profile: ].join("\n"), }); server.profile = profile; + server.backendIdentity = client.getBackendIdentity?.(); // ── Low-level API tools ───────────────────────────────────────────── registerGlobalTools(server, client); registerConfigTools(server, client); diff --git a/src/backend-detection.ts b/src/backend-detection.ts new file mode 100644 index 0000000..0a53a7a --- /dev/null +++ b/src/backend-detection.ts @@ -0,0 +1,168 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { Service, type Endpoint } from "@opencode/client/service"; +import { createRequestContext, throwIfAborted, withAbort, type RequestOptions } from "./async.js"; +import { ensureServer, ServerAuthenticationError, stopServer } from "./server-manager.js"; +import type { BackendIdentity, BackendKind } from "./backends/contracts.js"; + +export const DEFAULT_BASE_URL = "http://127.0.0.1:4096"; +export interface BackendConfig { + backend: "auto" | BackendKind; + baseUrl?: string; + username?: string; + password?: string; + autoServe: boolean; + profile: "full" | "essential"; + defaultProvider?: string; + defaultModel?: string; + taskStore?: string; +} +export type DetectionCode = "config" | "authentication" | "unavailable" | "incompatible" | "timeout"; +export class BackendDetectionError extends Error { + constructor(readonly code: DetectionCode, message: string, readonly status?: number) { + super(message); this.name = "BackendDetectionError"; + } +} +export function validateBaseUrl(value: string): string { + let url: URL; + try { url = new URL(value); } catch { throw new BackendDetectionError("config", "OPENCODE_BASE_URL must be an absolute HTTP(S) URL."); } + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new BackendDetectionError("config", "The server URL must use HTTP(S), without embedded credentials, query, or fragment. Use the server authentication environment variables."); + } + return url.toString().replace(/\/$/, ""); +} +export function readBackendConfig(env: NodeJS.ProcessEnv = process.env): BackendConfig { + const backend = env.OPENCODE_BACKEND ?? "auto"; + if (backend !== "auto" && backend !== "v1" && backend !== "v2") throw new BackendDetectionError("config", "OPENCODE_BACKEND must be auto, v1, or v2."); + const profile = env.OPENCODE_TOOL_PROFILE ?? "full"; + if (profile !== "full" && profile !== "essential") throw new BackendDetectionError("config", "OPENCODE_TOOL_PROFILE must be full or essential."); + if (env.OPENCODE_AUTO_SERVE !== undefined && !["true", "false"].includes(env.OPENCODE_AUTO_SERVE)) throw new BackendDetectionError("config", "OPENCODE_AUTO_SERVE must be true or false."); + if (Boolean(env.OPENCODE_DEFAULT_PROVIDER) !== Boolean(env.OPENCODE_DEFAULT_MODEL)) throw new BackendDetectionError("config", "Set OPENCODE_DEFAULT_PROVIDER and OPENCODE_DEFAULT_MODEL together."); + return { backend, profile, autoServe: env.OPENCODE_AUTO_SERVE === "true", + baseUrl: env.OPENCODE_BASE_URL === undefined ? undefined : validateBaseUrl(env.OPENCODE_BASE_URL), + username: env.OPENCODE_SERVER_USERNAME, password: env.OPENCODE_SERVER_PASSWORD, + defaultProvider: env.OPENCODE_DEFAULT_PROVIDER, defaultModel: env.OPENCODE_DEFAULT_MODEL, taskStore: env.OPENCODE_TASK_STORE }; +} +export interface DetectedBackend { + baseUrl: string; + username?: string; + password?: string; + identity: BackendIdentity; +} +interface Registration { url: string; pid: number; password?: string; version?: string } +export interface DetectionDependencies { + fetch?: typeof fetch; + discover?: () => Promise; + registration?: () => Promise; + start?: typeof ensureServer; +} +/** The pinned 2.0.6 SDK documents service.json as the complete discovery contract. + * Its discover() hides authentication failures, so preflight the registration + * before discovery and never fall through a present/inconclusive registration. */ +async function readRegistration(): Promise { + const path = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode", "service.json"); + let text: string; + try { text = await readFile(path, "utf8"); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw new BackendDetectionError("unavailable", "Cannot read the local OpenCode service registration; configure an explicit server URL."); + } + try { + const value = JSON.parse(text); + if (!value || typeof value.url !== "string" || !Number.isInteger(value.pid) || value.pid < 0 || + (value.password !== undefined && typeof value.password !== "string") || (value.version !== undefined && typeof value.version !== "string")) throw new Error(); + return { ...value, url: validateBaseUrl(value.url) }; + } catch { throw new BackendDetectionError("incompatible", "The local OpenCode service registration is invalid; inspect it or configure an explicit server URL."); } +} +function authHeaders(username?: string, password?: string): Record { + return password === undefined ? {} : { Authorization: `Basic ${Buffer.from(`${username ?? "opencode"}:${password}`).toString("base64")}` }; +} +function v1Info(value: unknown): value is { healthy: true; version?: string } { + return !!value && typeof value === "object" && (value as any).healthy === true && + ((value as any).version === undefined || typeof (value as any).version === "string"); +} +function v2Info(value: unknown): value is { version: string; pid: number; urls: string[]; paths: { tmp: string } } { + const info = value as any; + return !!info && typeof info === "object" && typeof info.version === "string" && /^2\./.test(info.version) && + Number.isInteger(info.pid) && info.pid >= 0 && Array.isArray(info.urls) && info.urls.every((url: unknown) => typeof url === "string") && typeof info.paths?.tmp === "string"; +} +export async function detectBackend(config: BackendConfig, options: RequestOptions & { allowStartup?: boolean } = {}, dependencies: DetectionDependencies = {}): Promise { + const context = createRequestContext(options, 15000); + const fetcher = dependencies.fetch ?? globalThis.fetch; + let sawResponse = false; + const probe = async (baseUrl: string, kind: BackendKind, username?: string, password?: string) => { + throwIfAborted(context.signal); + const probeContext = createRequestContext({ signal: context.signal, deadline: context.deadline, timeout: 2000 }); + try { + const response = await withAbort(fetcher(`${baseUrl}${kind === "v1" ? "/global/health" : "/api/info"}`, { + method: "GET", headers: authHeaders(username, password), redirect: "error", signal: probeContext.signal, + }), probeContext.signal); + sawResponse = true; + if (response.status === 401 || response.status === 403) throw new BackendDetectionError("authentication", "Server authentication failed. Check OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD.", response.status); + if (!response.ok) return undefined; + const value = await withAbort(response.json(), probeContext.signal); + if (kind === "v1" ? v1Info(value) : v2Info(value)) return value as { version?: string; pid?: number }; + return undefined; + } catch (error) { + if (error instanceof BackendDetectionError) throw error; + throwIfAborted(context.signal); + return undefined; + } finally { probeContext.dispose(); } + }; + const find = async (url: string, source: BackendIdentity["connectionSource"], username?: string, password?: string): Promise => { + const kinds: BackendKind[] = config.backend === "auto" ? ["v1", "v2"] : [config.backend]; + for (const kind of kinds) { + const info = await probe(url, kind, username, password); + if (info) return { baseUrl: url, username, password, identity: { kind, version: info.version, + connectionSource: source, processOwnership: source === "discovered" ? "shared" : "external", survivesDisconnect: true } }; + } + return undefined; + }; + try { + const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL; + const selected = await find(baseUrl, config.baseUrl ? "explicit" : "loopback", config.username, config.password); + if (selected) return selected; + // A responding authoritative URL cannot be replaced by a different process. + if (config.baseUrl && sawResponse) throw new BackendDetectionError("incompatible", "The configured endpoint does not satisfy the selected OpenCode backend contract."); + if (!config.baseUrl) { + const read = dependencies.registration ?? readRegistration; + const registration = await withAbort(read(), context.signal); + if (registration) { + const info = await probe(registration.url, "v2", "opencode", registration.password); + if (!info || info.pid !== registration.pid || (registration.version !== undefined && info.version !== registration.version)) { + throw new BackendDetectionError("incompatible", "A local service registration exists but its server contract could not be verified. No alternate server was started."); + } + if (config.backend === "v1") throw new BackendDetectionError("incompatible", "The registered local service is V2 but OPENCODE_BACKEND=v1. Configure an explicit V1 URL."); + const endpoint = await withAbort((dependencies.discover ?? (() => Service.discover()))(), context.signal); + if (!endpoint || validateBaseUrl(endpoint.url) !== registration.url) throw new BackendDetectionError("unavailable", "Local service discovery changed or could not confirm the registered server; retry after checking the service."); + const confirmed = await probe(registration.url, "v2", endpoint.auth?.username, endpoint.auth?.password); + if (!confirmed || confirmed.pid !== info.pid || confirmed.version !== info.version) throw new BackendDetectionError("unavailable", "The local service changed during discovery; retry after checking the service."); + return { baseUrl: registration.url, username: endpoint.auth?.username, password: endpoint.auth?.password, + identity: { kind: "v2", version: confirmed.version, connectionSource: "discovered", processOwnership: "shared", survivesDisconnect: true } }; + } + // With no registration the SDK has nothing to discover. Avoid its opaque + // probe so an authentication failure can never be mistaken for absence. + } + if (options.allowStartup && config.autoServe && config.backend !== "v2" && !sawResponse) { + const started = await withAbort((dependencies.start ?? ensureServer)({ baseUrl, autoServe: true, username: config.username, password: config.password, timeoutMs: Math.max(1, context.deadline - Date.now()) }), context.signal); + const actual = validateBaseUrl(started.url ?? baseUrl); + let info: Awaited>; + try { + info = await probe(actual, "v1", config.username, config.password); + if (!info) throw new BackendDetectionError("incompatible", "The started server does not expose the V1 health contract."); + } catch (error) { + if (started.managedByUs) stopServer(started.url ?? baseUrl); + throw error; + } + return { baseUrl: actual, username: config.username, password: config.password, + identity: { kind: "v1", version: info.version, connectionSource: started.managedByUs ? "owned-child" : "loopback", processOwnership: started.managedByUs ? "owned" : "external", survivesDisconnect: !started.managedByUs } }; + } + throw new BackendDetectionError(sawResponse ? "incompatible" : "unavailable", "No compatible OpenCode server is available. Start it separately or set OPENCODE_BASE_URL. Doctor never starts a server."); + } catch (error) { + if (context.signal.aborted) throw new BackendDetectionError("timeout", "Backend detection exceeded its time budget."); + if (error instanceof ServerAuthenticationError) throw new BackendDetectionError("authentication", "Server authentication failed; no alternate server was started.", error.status); + if (error instanceof BackendDetectionError) throw error; + throw new BackendDetectionError("unavailable", "OpenCode discovery failed. Check the server and service registration."); + } finally { context.dispose(); } +} diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts new file mode 100644 index 0000000..3050d46 --- /dev/null +++ b/src/backends/adapter.ts @@ -0,0 +1,84 @@ +import { BackendCapabilityError } from "./contracts.js"; +import type { OperationInputs, OperationResults } from "./operations.js"; +import type { OpenCodeClient } from "../client.js"; +import { normalizeDirectory, applyModelDefaults } from "../helpers.js"; +import { createRequestContext, withAbort, throwIfAborted, type RequestOptions } from "../async.js"; +import { v1Routes, type Operation } from "./v1-routes.js"; +import { V2Adapter } from "./v2.js"; +export type { Operation } from "./v1-routes.js"; +/** Semantic operation inputs shared by the transport adapters. Paths stay server-side. */ +export interface OperationArgs extends RequestOptions { + sessionId?: string; messageId?: string; requestId?: string; providerId?: string; + directory?: string; query?: Record; body?: unknown; +} +export interface BackendAdapter { execute(operation: K, args: OperationInputs[K]): Promise } +export class V1Adapter implements BackendAdapter { + constructor(private readonly client: OpenCodeClient) {} + execute(operation: K, args: OperationInputs[K]): Promise { + return this.request(operation, args) as Promise; + } + private request(operation: Operation, args: OperationArgs): Promise { + if (operation === "sessions.diff" && (args.query?.from || args.query?.to)) throw new BackendCapabilityError("session_diff.range", "Explicit from/to ranges are V2-only; V1 accepts messageID.", "v1"); + if (operation === "providers.callback" && typeof (args.body as { method?: unknown })?.method !== "number") throw new Error("V1 OAuth callback requires the method index used for authorization"); + if (operation === "forms.reply" && !Array.isArray((args.body as { answers?: unknown })?.answers)) throw new Error("V1 question replies require answers arrays"); + const route = v1Routes[operation]; + const path = route.path.replace(/:(sessionId|messageId|requestId|providerId)/g, (_, key: keyof OperationArgs) => { + const value = args[key]; if (typeof value !== "string" || !value) throw new Error(`${key} is required`); + return encodeURIComponent(value); + }); + const { directory, query, body, signal, deadline, timeout } = args; + const options = { ...(signal ? { signal } : {}), ...(deadline !== undefined ? { deadline } : {}), ...(timeout !== undefined ? { timeout } : {}) }; + const bounded = signal !== undefined || deadline !== undefined || timeout !== undefined; + switch (route.method) { + case "GET": return bounded ? this.client.get(path, query, directory, options) : "directory" in args || query !== undefined ? this.client.get(path, query, directory) : this.client.get(path); + case "POST": return bounded || "directory" in args ? this.client.post(path, body, { ...options, directory }) : this.client.post(path, body); + case "PATCH": return bounded ? this.client.patch(path, body, directory, options) : this.client.patch(path, body, directory); + case "PUT": return bounded ? this.client.put(path, body, directory, options) : directory !== undefined ? this.client.put(path, body, directory) : this.client.put(path, body); + case "DELETE": return bounded ? this.client.delete(path, query, directory, options) : this.client.delete(path, query, directory); + } + } +} +const adapters = new WeakMap(); +export function backend(client: OpenCodeClient): BackendAdapter { + let adapter = adapters.get(client); + if (!adapter) { adapter = client.getBackendIdentity?.().kind === "v2" ? new V2Adapter(client) : new V1Adapter(client); adapters.set(client, adapter); } + return adapter; +} +export function operate(client: OpenCodeClient, operation: K, args: OperationInputs[K] = {} as OperationInputs[K]): Promise { + client.assertBackendAvailable?.(); + normalizeDirectory(args.directory); + return backend(client).execute(operation, args); +} + +/** Configured model defaults belong only to new V2 sessions. */ +export function selectModel(client: OpenCodeClient, providerID?: string, modelID?: string, newSession = false) { + if (client.getBackendIdentity?.().kind === "v2" && [providerID, modelID].some(value => value !== undefined && !value.trim())) throw new Error("V2 providerID and modelID must be nonempty when supplied"); + if (client.getBackendIdentity?.().kind === "v2" && Boolean(providerID) !== Boolean(modelID)) throw new Error("V2 model selection requires both providerID and modelID"); + if (client.getBackendIdentity?.().kind === "v2" && !newSession) return providerID && modelID ? { providerID, modelID } : undefined; + return applyModelDefaults(providerID, modelID); +} + +/** Shared event contract. V2 global transport is filtered by the resolved server location. */ +export async function* events(client: OpenCodeClient, scope: "project" | "global", args: OperationArgs = {}) { + client.assertBackendAvailable?.(); + const adapter = backend(client); + if (!(adapter instanceof V2Adapter)) { yield* client.subscribeSSE(scope === "global" ? "/global/event" : "/event", args); return; } + const context = createRequestContext(args); + let iterator: AsyncIterator | undefined; + try { + throwIfAborted(context.signal); + const directory = scope === "project" ? (await withAbort(adapter.api.location.get({ location: args.directory ? { directory: normalizeDirectory(args.directory) } : undefined }, { signal: context.signal }), context.signal)).directory : undefined; + iterator = adapter.api.event.subscribe({ signal: context.signal })[Symbol.asyncIterator](); + while (true) { + const next = await withAbort(iterator.next(), context.signal); + if (next.done) return; + const event = next.value; + if (scope === "project" && (!("location" in event) || event.location?.directory !== directory)) continue; + yield { event: event.type, data: JSON.stringify(event) }; + } + } finally { + context.abort(); + if (iterator?.return) void iterator.return().catch(() => {}); + context.dispose(); + } +} diff --git a/src/backends/capabilities.ts b/src/backends/capabilities.ts new file mode 100644 index 0000000..f768c70 --- /dev/null +++ b/src/backends/capabilities.ts @@ -0,0 +1,113 @@ +/** One inventory for registration coverage, compatibility reporting and generated documentation. */ +export interface Capability { name: string; kind: "tool" | "resource" | "template" | "prompt"; v1: "supported"; v2: "supported" | "unsupported" | "blocked"; reason?: string } +export const capabilities: readonly Capability[] = [ + { name: "opencode_job_get", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_job_cancel", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_project_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_project_init", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_project_current", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_setup", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_ask", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_reply", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_conversation", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_sessions_overview", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_context", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_wait", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_review_changes", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_provider_test", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_run", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_fire", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_check", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_status", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_tui_append_prompt", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_submit_prompt", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_clear_prompt", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_execute_command", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_show_toast", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_open_help", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_open_sessions", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_open_models", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tui_open_themes", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_question_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_question_reply", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_question_reject", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_job_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_job_input", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_find_text", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_find_file", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_find_symbol", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_file_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_file_read", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_file_status", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_message_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_message_get", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_message_send", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_message_send_async", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_command_execute", kind: "tool", v1: "supported", v2: "blocked", reason: "V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely." }, + { name: "opencode_shell_execute", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_create", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_get", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_delete", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_update", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_children", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_status", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_todo", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_session_init", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_session_abort", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_fork", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_share", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_session_unshare", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_session_diff", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_summarize", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_revert", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_unrevert", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_permission_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_permission", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_session_search", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_events_poll", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_health", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_provider_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_provider_models", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_provider_auth_methods", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_provider_oauth_authorize", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_provider_oauth_callback", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_auth_set", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_config_get", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_config_update", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_config_providers", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_path_get", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_vcs_info", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_instance_dispose", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_agent_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_command_list", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_lsp_status", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_formatter_status", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_mcp_status", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_mcp_add", kind: "tool", v1: "supported", v2: "supported" }, + { name: "opencode_tool_ids", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_tool_list", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "opencode_log", kind: "tool", v1: "supported", v2: "unsupported", reason: "No equivalent published V2 API; V1-only capability." }, + { name: "project-current", kind: "resource", v1: "supported", v2: "supported" }, + { name: "config", kind: "resource", v1: "supported", v2: "supported" }, + { name: "providers", kind: "resource", v1: "supported", v2: "supported" }, + { name: "agents", kind: "resource", v1: "supported", v2: "supported" }, + { name: "commands", kind: "resource", v1: "supported", v2: "supported" }, + { name: "health", kind: "resource", v1: "supported", v2: "supported" }, + { name: "vcs", kind: "resource", v1: "supported", v2: "supported" }, + { name: "sessions", kind: "resource", v1: "supported", v2: "supported" }, + { name: "mcp-servers", kind: "resource", v1: "supported", v2: "supported" }, + { name: "file-status", kind: "resource", v1: "supported", v2: "supported" }, + { name: "project-scoped", kind: "template", v1: "supported", v2: "supported" }, + { name: "sessions-scoped", kind: "template", v1: "supported", v2: "supported" }, + { name: "session-scoped", kind: "template", v1: "supported", v2: "supported" }, + { name: "session-messages-scoped", kind: "template", v1: "supported", v2: "supported" }, + { name: "opencode-code-review", kind: "prompt", v1: "supported", v2: "supported" }, + { name: "opencode-debug", kind: "prompt", v1: "supported", v2: "supported" }, + { name: "opencode-project-setup", kind: "prompt", v1: "supported", v2: "supported" }, + { name: "opencode-implement", kind: "prompt", v1: "supported", v2: "supported" }, + { name: "opencode-best-practices", kind: "prompt", v1: "supported", v2: "supported" }, + { name: "opencode-session-summary", kind: "prompt", v1: "supported", v2: "supported" }, + ]; +export function capabilityFor(name: string) { return capabilities.find(capability => capability.name === name); } +export const v2UnsupportedTools = new Set(capabilities.filter(c => c.kind === "tool" && c.v2 === "unsupported").map(c => c.name)); diff --git a/src/backends/contracts.ts b/src/backends/contracts.ts new file mode 100644 index 0000000..65121a1 --- /dev/null +++ b/src/backends/contracts.ts @@ -0,0 +1,33 @@ +/** Bridge-owned contracts. Backend raw values are always explicitly labeled. */ +export type BackendKind = "v1" | "v2"; +export interface BackendIdentity { + kind: BackendKind; + resolved?: false; + version?: string; + connectionSource: "explicit" | "loopback" | "discovered" | "owned-child"; + processOwnership: "external" | "shared" | "owned"; + survivesDisconnect: boolean; +} +export interface Page { items: T[]; complete: boolean; cursor?: string } +export interface BackendRaw { backend: BackendKind; value: unknown } +export interface Session extends Record { id: string; title?: string; parentID?: string; directory?: string; time: { created: number; updated: number }; model?: { providerID: string; modelID: string; variant?: string }; agent?: string; raw?: BackendRaw } +export interface Message extends Record { info: { id: string; sessionID: string; role: string; parentID?: string; time: { created: number; completed?: number }; finish?: string; error?: unknown }; parts: Array<{ type: string; text?: string; [key: string]: unknown }>; raw?: BackendRaw } +export class BackendCapabilityError extends Error { + readonly code = "UNSUPPORTED_CAPABILITY"; + constructor(public readonly capability: string, message: string, public readonly backend: BackendKind = "v2") { super(message); this.name = "BackendCapabilityError"; } +} +export interface FileEntry extends Record { path: string; type: "file" | "directory"; name?: string; raw?: BackendRaw } +export interface FileContent extends Record { type: "text" | "binary"; content: string; encoding?: "base64"; backend?: BackendKind } +export interface FileStatus extends Record { file: string; additions: number; deletions: number; status: string; raw?: BackendRaw } +export interface ProviderModel { id: string; name: string; limit?: {context:number;input?:number;output:number}; status?: string; enabled?: boolean } +export interface Provider extends Record { id: string; name: string; integrationID?: string; activation?: string; configured?: boolean; source?: string; models: Record; raw?: BackendRaw } +export interface Providers extends Record { all: Provider[]; connected: string[]; default: Record } +export interface Permission extends Record { id: string; sessionID: string; permission: string; patterns: string[]; always?: string[]; backend?: BackendKind; approvalScope?: "project"; rejectionScope?: "session"; raw?: BackendRaw } +export interface PromptSelection { model?: {providerID:string;modelID:string}; variant?: string; agent?: string } +export interface SessionCreate extends PromptSelection { title?: string; parentID?: string } +export interface Prompt extends PromptSelection { messageID?: string; parts?: Array<{type:"text";text:string}>; format?: unknown; system?: string; noReply?: boolean } +export type InputValues = Record; +export interface InputResponse { answers?:string[][]; values?:InputValues; sessionId?:string } +export interface PermissionResponse { reply?:"once"|"always"|"reject"; scope?:"project"|"session"; message?:string; sessionId?:string } +export interface ConfigurationSources { backend:"v2"; sources:unknown[]; effectiveConfiguration:false } +export interface Project extends Record { id:string; directory?:string; worktree?:string; name?:string; raw?:BackendRaw } diff --git a/src/backends/normalization.ts b/src/backends/normalization.ts new file mode 100644 index 0000000..df76d64 --- /dev/null +++ b/src/backends/normalization.ts @@ -0,0 +1,38 @@ +import type { SessionInfo, SessionMessageInfo, ProviderInfo, ModelInfo, IntegrationInfo } from "@opencode/client"; +import type { Session, Message, Providers } from "./contracts.js"; +export function normalizeSession(value: SessionInfo): Session { + if (!value || typeof value.id !== "string" || !value.id || typeof value.location?.directory !== "string" || !Number.isFinite(value.time?.created) || !Number.isFinite(value.time?.updated)) throw new Error("Malformed V2 session response"); + return { id: value.id, title: value.title, parentID: value.parentID, directory: value.location.directory, + time: value.time, agent: value.agent, model: value.model ? { providerID: value.model.providerID, modelID: value.model.id, variant: value.model.variant } : undefined, + raw: { backend: "v2", value } }; +} +export function normalizeMessage(value: SessionMessageInfo, sessionID: string, parentID?: string): Message { + if (!value || typeof value.id !== "string" || !value.id || typeof value.type !== "string" || !Number.isFinite(value.time?.created)) throw new Error("Malformed V2 message response"); + const info: Message["info"] = { id: value.id, sessionID, role: value.type, time: value.time }; + let parts: Message["parts"] = []; + if (value.type === "user") parts = [{ type: "text", text: value.text }, ...(value.files ?? []).map(file => ({ type: "file", url: `data:${file.mime};base64,${file.data}`, filename: file.name }))]; + if (value.type === "assistant") { info.parentID = parentID; info.finish = value.finish; info.error = value.error; + parts = value.content.map(part => part.type === "tool" ? { type: "tool", tool: part.name, callID: part.id, state: part.state, time: part.time } : { ...part }); } + if (value.type === "shell") parts = [{ type: "text", text: `${value.command}\n${value.output?.output ?? ""}\nStatus: ${value.status}; exit: ${value.exit ?? "unknown"}${value.output?.truncated ? " (output truncated by backend)" : ""}` }]; + return { info, parts, raw: { backend: "v2", value } }; +} +export function normalizeMessages(values: SessionMessageInfo[], sessionID: string): Message[] { + return [...values].sort((a,b) => a.time.created - b.time.created || a.id.localeCompare(b.id)).map(value => { + return normalizeMessage(value, sessionID); + }); +} +export type PageArray = T[] & { pagination: { complete: boolean; cursor?: string } }; +export function pageArray(values: T[], cursor?: string | null): PageArray { + return Object.assign(values, { pagination: { complete: !cursor, ...(cursor ? { cursor } : {}) } }); +} +export function normalizeProviders(providers: ProviderInfo[], models: ModelInfo[], integrations: IntegrationInfo[]): Providers { + return { all: providers.map(provider => { + const integration = integrations.find(item => item.id === provider.integrationID); + const connected = !!integration?.connections.length; + return { id: provider.id, name: provider.name, integrationID: provider.integrationID, activation: provider.activation, + // Activation is not credential verification. Only configured connections are reported. + configured: connected, source: connected ? "integration" : undefined, + models: Object.fromEntries(models.filter(model => model.providerID === provider.id).map(model => [model.id, { id: model.id, name: model.name, limit: model.limit, status: model.status, enabled: model.enabled }])), + raw: { backend: "v2", value: { id: provider.id, activation: provider.activation, integrationID: provider.integrationID } } }; + }), connected: providers.filter(provider => integrations.some(item => item.id === provider.integrationID && item.connections.length)).map(provider => provider.id), default: {} }; +} diff --git a/src/backends/operations.ts b/src/backends/operations.ts new file mode 100644 index 0000000..728b3e3 --- /dev/null +++ b/src/backends/operations.ts @@ -0,0 +1,55 @@ +import type { RequestOptions } from "../async.js"; +import type { Operation } from "./v1-routes.js"; +import type { Session, Message, SessionCreate, Prompt, Providers, Provider, FileEntry, FileContent, FileStatus, Permission, PermissionResponse, InputResponse, ConfigurationSources, Project } from "./contracts.js"; +/** Inputs are named domain operations; transport paths never cross this boundary. */ +export interface CommonInput extends RequestOptions { directory?:string; query?:Record; sessionId?:string; messageId?:string; requestId?:string; providerId?:string } +type Input = CommonInput & ([B] extends [never] ? {body?:never} : {body?:B}); +interface Contract { input:I; output:O } +interface Definitions { + "lifecycle.health": Contract; + "configuration.get": Contract>; + "configuration.agents": Contract>>; + "configuration.commands": Contract>>; + "configuration.mcp": Contract>; + "configuration.mcpAdd": Contract}>, unknown>; + "projects.list": Contract; + "projects.current": Contract; + "files.paths": Contract>; + "files.vcs": Contract>; + "files.list": Contract; + "files.read": Contract; + "files.find": Contract; + "files.status": Contract; + "providers.list": Contract; + "providers.configured": Contract}>; + "providers.authMethods": Contract>; + "providers.authorize": Contract;values?:import('./contracts.js').InputValues;integrationId?:string;methodId?:string}>, unknown>; + "providers.callback": Contract, unknown>; + "providers.setAuth": Contract, unknown>; + "sessions.list": Contract; + "sessions.children": Contract; + "sessions.get": Contract; + "sessions.status": Contract>; + "sessions.create": Contract, Session>; + "sessions.update": Contract, Session>; + "sessions.remove": Contract; + "sessions.abort": Contract>, unknown>; + "sessions.fork": Contract, Session>; + "sessions.compact": Contract, unknown>; + "sessions.diff": Contract; + "sessions.revert": Contract, unknown>; + "sessions.unrevert": Contract; + "messages.list": Contract; + "messages.get": Contract; + "messages.send": Contract, Message>; + "messages.enqueue": Contract, {id:string;sessionID:string}|undefined>; + "messages.command": Contract, unknown>; + "messages.shell": Contract, Message & {isError?:boolean}>; + "permissions.list": Contract; + "permissions.reply": Contract, unknown>; + "forms.list": Contract>; + "forms.reply": Contract, unknown>; + "forms.reject": Contract; +} +export type OperationInputs = {[K in Operation]: K extends keyof Definitions ? Definitions[K]["input"] : CommonInput & {body?:unknown}}; +export type OperationResults = {[K in Operation]: K extends keyof Definitions ? Definitions[K]["output"] : unknown}; diff --git a/src/backends/v1-routes.ts b/src/backends/v1-routes.ts new file mode 100644 index 0000000..28bc212 --- /dev/null +++ b/src/backends/v1-routes.ts @@ -0,0 +1,272 @@ +/** Private V1 wire mapping; callers use semantic operation names. */ +export const v1Routes = { + "lifecycle.health": { + "method": "GET", + "path": "/global/health" + }, + "configuration.get": { + "method": "GET", + "path": "/config" + }, + "configuration.update": { + "method": "PATCH", + "path": "/config" + }, + "providers.configured": { + "method": "GET", + "path": "/config/providers" + }, + "providers.list": { + "method": "GET", + "path": "/provider" + }, + "providers.authMethods": { + "method": "GET", + "path": "/provider/auth" + }, + "projects.list": { + "method": "GET", + "path": "/project" + }, + "projects.current": { + "method": "GET", + "path": "/project/current" + }, + "files.paths": { + "method": "GET", + "path": "/path" + }, + "files.vcs": { + "method": "GET", + "path": "/vcs" + }, + "sessions.list": { + "method": "GET", + "path": "/session" + }, + "sessions.create": { + "method": "POST", + "path": "/session" + }, + "sessions.status": { + "method": "GET", + "path": "/session/status" + }, + "sessions.get": { + "method": "GET", + "path": "/session/:sessionId" + }, + "sessions.remove": { + "method": "DELETE", + "path": "/session/:sessionId" + }, + "sessions.update": { + "method": "PATCH", + "path": "/session/:sessionId" + }, + "sessions.children": { + "method": "GET", + "path": "/session/:sessionId/children" + }, + "sessions.todo": { + "method": "GET", + "path": "/session/:sessionId/todo" + }, + "sessions.init": { + "method": "POST", + "path": "/session/:sessionId/init" + }, + "sessions.abort": { + "method": "POST", + "path": "/session/:sessionId/abort" + }, + "sessions.fork": { + "method": "POST", + "path": "/session/:sessionId/fork" + }, + "sessions.share": { + "method": "POST", + "path": "/session/:sessionId/share" + }, + "sessions.unshare": { + "method": "DELETE", + "path": "/session/:sessionId/share" + }, + "sessions.diff": { + "method": "GET", + "path": "/session/:sessionId/diff" + }, + "sessions.compact": { + "method": "POST", + "path": "/session/:sessionId/summarize" + }, + "sessions.revert": { + "method": "POST", + "path": "/session/:sessionId/revert" + }, + "sessions.unrevert": { + "method": "POST", + "path": "/session/:sessionId/unrevert" + }, + "messages.list": { + "method": "GET", + "path": "/session/:sessionId/message" + }, + "messages.get": { + "method": "GET", + "path": "/session/:sessionId/message/:messageId" + }, + "messages.send": { + "method": "POST", + "path": "/session/:sessionId/message" + }, + "messages.enqueue": { + "method": "POST", + "path": "/session/:sessionId/prompt_async" + }, + "messages.command": { + "method": "POST", + "path": "/session/:sessionId/command" + }, + "messages.shell": { + "method": "POST", + "path": "/session/:sessionId/shell" + }, + "permissions.list": { + "method": "GET", + "path": "/permission" + }, + "permissions.reply": { + "method": "POST", + "path": "/permission/:requestId/reply" + }, + "permissions.legacyReply": { + "method": "POST", + "path": "/session/:sessionId/permissions/:requestId" + }, + "forms.list": { + "method": "GET", + "path": "/question" + }, + "forms.reply": { + "method": "POST", + "path": "/question/:requestId/reply" + }, + "forms.reject": { + "method": "POST", + "path": "/question/:requestId/reject" + }, + "files.list": { + "method": "GET", + "path": "/file" + }, + "files.read": { + "method": "GET", + "path": "/file/content" + }, + "files.status": { + "method": "GET", + "path": "/file/status" + }, + "files.findText": { + "method": "GET", + "path": "/find" + }, + "files.find": { + "method": "GET", + "path": "/find/file" + }, + "files.findSymbol": { + "method": "GET", + "path": "/find/symbol" + }, + "configuration.agents": { + "method": "GET", + "path": "/agent" + }, + "configuration.commands": { + "method": "GET", + "path": "/command" + }, + "configuration.mcp": { + "method": "GET", + "path": "/mcp" + }, + "configuration.mcpAdd": { + "method": "POST", + "path": "/mcp" + }, + "lifecycle.lsp": { + "method": "GET", + "path": "/lsp" + }, + "lifecycle.formatter": { + "method": "GET", + "path": "/formatter" + }, + "lifecycle.tools": { + "method": "GET", + "path": "/experimental/tool" + }, + "lifecycle.toolIds": { + "method": "GET", + "path": "/experimental/tool/ids" + }, + "lifecycle.log": { + "method": "POST", + "path": "/log" + }, + "lifecycle.dispose": { + "method": "POST", + "path": "/instance/dispose" + }, + "providers.authorize": { + "method": "POST", + "path": "/provider/:providerId/oauth/authorize" + }, + "providers.callback": { + "method": "POST", + "path": "/provider/:providerId/oauth/callback" + }, + "providers.setAuth": { + "method": "PUT", + "path": "/auth/:providerId" + }, + "tui.append-prompt": { + "method": "POST", + "path": "/tui/append-prompt" + }, + "tui.submit-prompt": { + "method": "POST", + "path": "/tui/submit-prompt" + }, + "tui.clear-prompt": { + "method": "POST", + "path": "/tui/clear-prompt" + }, + "tui.execute-command": { + "method": "POST", + "path": "/tui/execute-command" + }, + "tui.show-toast": { + "method": "POST", + "path": "/tui/show-toast" + }, + "tui.open-help": { + "method": "POST", + "path": "/tui/open-help" + }, + "tui.open-sessions": { + "method": "POST", + "path": "/tui/open-sessions" + }, + "tui.open-models": { + "method": "POST", + "path": "/tui/open-models" + }, + "tui.open-themes": { + "method": "POST", + "path": "/tui/open-themes" + } +} as const; +export type Operation = keyof typeof v1Routes; diff --git a/src/backends/v2-forms.ts b/src/backends/v2-forms.ts new file mode 100644 index 0000000..5d8a98b --- /dev/null +++ b/src/backends/v2-forms.ts @@ -0,0 +1,64 @@ +import { ElicitRequestFormParamsSchema } from "@modelcontextprotocol/core"; +import { isDeepStrictEqual } from "node:util"; +import type { FormField } from "@opencode/client"; +export type FormValues = Record; +/** The manual and native input paths share this validator; no defaults imply consent. */ +export function validateFormValues(fields: readonly FormField[], input: unknown): FormValues { + if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("V2 forms require field-keyed values"); + const values = input as Record; + for (const key of Object.keys(values)) if (!fields.some(field => field.key === key && field.type !== "external")) throw new Error(`Unknown form field: ${key}`); + const result: FormValues = {}; + for (const field of fields) { + if (field.type === "external") continue; + const active = !field.when?.length || field.when.every(condition => condition.op === "eq" ? values[condition.key] === condition.value : values[condition.key] !== condition.value); + const value = values[field.key]; + if (!active) { if (value !== undefined) throw new Error(`Field ${field.key} is not currently active`); continue; } + if (value === undefined) { if (field.required) throw new Error(`Missing required field: ${field.key}`); continue; } + const invalid = () => { throw new Error(`Invalid value for form field ${field.key}`); }; + switch (field.type) { + case "string": { + if (typeof value !== "string") { invalid(); break; } + if (field.minLength !== undefined && value.length < field.minLength || field.maxLength !== undefined && value.length > field.maxLength) invalid(); + if (field.pattern && !new RegExp(field.pattern).test(value)) invalid(); + if (field.options?.length && !field.custom && !field.options.some(option => option.value === value)) invalid(); + if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) invalid(); + if (field.format === "uri") { try { new URL(value); } catch { invalid(); } } + if (field.format === "date" && (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(value)) || new Date(value).toISOString().slice(0,10) !== value)) invalid(); + if (field.format === "date-time" && (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || Number.isNaN(Date.parse(value)))) invalid(); + result[field.key] = value; break; + } + case "integer": case "number": { + if (typeof value !== "number" || !Number.isFinite(value) || field.type === "integer" && !Number.isInteger(value)) { invalid(); break; } + if (field.minimum !== undefined && (Number.isNaN(Number(field.minimum)) || value < Number(field.minimum)) || field.maximum !== undefined && (Number.isNaN(Number(field.maximum)) || value > Number(field.maximum))) invalid(); + result[field.key] = value; break; + } + case "boolean": if (typeof value !== "boolean") invalid(); else result[field.key] = value; break; + case "multiselect": { + if (!Array.isArray(value) || value.some(item => typeof item !== "string") || new Set(value).size !== value.length) { invalid(); break; } + if (field.minItems !== undefined && value.length < field.minItems || field.maxItems !== undefined && value.length > field.maxItems) invalid(); + if (!field.custom && value.some(item => !field.options.some(option => option.value === item))) invalid(); + result[field.key] = value; break; + } + } + } + return result; +} +/** Advanced conditional/external forms require manual responses with the original contract. */ +export function nativeFormSchema(fields: readonly FormField[]): Record | undefined { + if (fields.some(field => field.type === "external" || field.when?.length || field.type === "string" && (field.pattern || field.options?.length && (field.minLength !== undefined || field.maxLength !== undefined || field.format !== undefined)) || field.type === "multiselect" && field.custom)) return undefined; + const properties: Record = {}, required: string[] = []; + for (const field of fields) { + if (field.type === "external") return undefined; + const schema: Record = { title: field.title ?? field.key, description: field.description }; + if (field.type === "multiselect") { schema.type = "array"; schema.items = { type: "string", ...(!field.custom ? { enum: field.options.map(option => option.value) } : {}) }; if (field.minItems !== undefined) schema.minItems = field.minItems; if (field.maxItems !== undefined) schema.maxItems = field.maxItems; } + else { schema.type = field.type; + if (field.type === "string") { if (field.options?.length && !field.custom) schema.enum = field.options.map(option => option.value); for (const key of ["format","minLength","maxLength","pattern"] as const) if (field[key] !== undefined) schema[key] = field[key]; } + if (field.type === "integer" || field.type === "number") { if (typeof field.minimum === "number") schema.minimum = field.minimum; if (typeof field.maximum === "number") schema.maximum = field.maximum; } + } + properties[field.key] = schema; if (field.required) required.push(field.key); + } + const schema = JSON.parse(JSON.stringify({ type: "object", properties, required })); + const parsed = ElicitRequestFormParamsSchema.safeParse({ mode: "form", message: "OpenCode input", requestedSchema: schema }); + if (!parsed.success || !isDeepStrictEqual(schema, parsed.data.requestedSchema)) return undefined; + return schema; +} diff --git a/src/backends/v2-guards.ts b/src/backends/v2-guards.ts new file mode 100644 index 0000000..d03b5dc --- /dev/null +++ b/src/backends/v2-guards.ts @@ -0,0 +1,21 @@ +import type { SessionInfo } from "@opencode/client"; +import { BackendCapabilityError } from "./contracts.js"; +export interface PromptOptions { model?: { providerID: string; modelID: string }; variant?: string; agent?: string; format?: unknown; system?: string; noReply?: boolean } +export function assertPromptOptions(body: PromptOptions): void { + for (const field of ["agent", "variant"] as const) if (body[field] !== undefined && !body[field]?.trim()) throw new Error(`V2 ${field} must be a nonempty string`); + if (body.model && (!body.model.providerID?.trim() || !body.model.modelID?.trim())) throw new Error("V2 model selection requires nonempty providerID and modelID"); + if (body.format && (body.format as {type?:string}).type !== "text") throw new BackendCapabilityError("prompt.format", "V2 cannot guarantee JSON-schema output; format must be omitted or text."); + if (body.system) throw new BackendCapabilityError("prompt.system", "V2 does not support a per-prompt system override."); + if (body.noReply === true) throw new BackendCapabilityError("prompt.noReply", "V2 cannot guarantee noReply: true."); +} +export function assertSessionSelection(session: SessionInfo, body: PromptOptions): void { + assertPromptOptions(body); + if (body.model && (body.model.providerID !== session.model?.providerID || body.model.modelID !== session.model?.id)) throw new BackendCapabilityError("session.model", "Existing V2 sessions preserve their model; create a new session to select a different model."); + if (body.variant !== undefined && body.variant !== session.model?.variant) throw new BackendCapabilityError("session.variant", "Existing V2 sessions preserve their model variant."); + if (body.agent !== undefined && body.agent !== session.agent) throw new BackendCapabilityError("session.agent", "Existing V2 sessions preserve their agent."); +} + +/** Upstream 2.0.6 prompt/compact implicitly commit staged reverts. Never trigger that hidden write. */ +export function assertNoStagedRevert(session: SessionInfo): void { + if (session.revert) throw new BackendCapabilityError("session.staged_revert", "V2 2.0.6 would automatically commit this staged revert when prompting or compacting. Clear it with session_unrevert, or explicitly commit it outside this bridge, before continuing."); +} diff --git a/src/backends/v2-mcp.ts b/src/backends/v2-mcp.ts new file mode 100644 index 0000000..b3aed49 --- /dev/null +++ b/src/backends/v2-mcp.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import type { McpAddInput } from "@opencode/client"; +import { BackendCapabilityError } from "./contracts.js"; +const timeout = z.object({ startup: z.number().positive().optional(), catalog: z.number().positive().optional(), execution: z.number().positive().optional() }).strict(); +const oauth = z.object({ client_id: z.string().optional(), client_secret: z.string().optional(), scope: z.string().optional(), callback_port: z.number().int().positive().optional(), redirect_uri: z.string().optional(), auth_server_metadata_url: z.string().optional() }).strict(); +const shared = { disabled: z.boolean().optional(), codemode: z.boolean().optional(), timeout: timeout.optional(), protocol: z.enum(["legacy", "auto", "2026-07-28"]).optional() }; +const schema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("local"), command: z.array(z.string()).min(1), cwd: z.string().optional(), environment: z.record(z.string(), z.string()).optional(), ...shared }).strict(), + z.object({ type: z.literal("remote"), url: z.string().url(), headers: z.record(z.string(), z.string()).optional(), oauth: z.union([oauth, z.literal(false)]).optional(), ...shared }).strict(), +]); +export function translateMcpConfig(value: unknown): McpAddInput["config"] { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("MCP config must be an object"); + const config = structuredClone(value) as Record; + if (config.enabled !== undefined) { + if (typeof config.enabled !== "boolean") throw new Error("MCP enabled must be boolean"); + if (config.disabled !== undefined && config.disabled !== !config.enabled) throw new BackendCapabilityError("mcp.config", "Conflicting enabled and disabled fields"); + config.disabled = !config.enabled; delete config.enabled; + } + // V1 timeout covers tool/catalog calls. Both are translated explicitly. + if (typeof config.timeout === "number") config.timeout = { catalog: config.timeout, execution: config.timeout }; + if (config.oauth && typeof config.oauth === "object") { + const auth = config.oauth as Record; + for (const [oldName, nextName] of Object.entries({ clientId: "client_id", clientSecret: "client_secret", redirectUri: "redirect_uri", callbackPort: "callback_port", authServerMetadataUrl: "auth_server_metadata_url" })) { + if (auth[oldName] !== undefined) { if (auth[nextName] !== undefined && auth[nextName] !== auth[oldName]) throw new BackendCapabilityError("mcp.config.oauth", `Conflicting ${oldName} and ${nextName}`); auth[nextName] = auth[oldName]; delete auth[oldName]; } + } + } + const result = schema.safeParse(config); + if (!result.success) throw new BackendCapabilityError("mcp.config", "Unsupported or invalid MCP configuration fields; use the documented V2-compatible fields."); + return result.data; +} diff --git a/src/backends/v2-observation.ts b/src/backends/v2-observation.ts new file mode 100644 index 0000000..dbedbbb --- /dev/null +++ b/src/backends/v2-observation.ts @@ -0,0 +1,218 @@ +import type { OpenCode, SessionLogOutput, SessionMessageAssistant } from "@opencode/client"; +import type { JobSnapshot, JobStatus } from "../jobs.js"; +import { createRequestContext, withAbort, type RequestOptions } from "../async.js"; +import { formatMessageResponse } from "../helpers.js"; +import { normalizeMessage, normalizeSession } from "./normalization.js"; + +export type V2Api = ReturnType; +/** Persisted evidence, not reconstructed parentage based on message timestamps. */ +export interface V2ObservationState { + version: 1; + cursor: number; + messageId: string; + admitted?: boolean; + delivered?: boolean; + userProjectionValidated?: boolean; + queued?: boolean; + latestDeliveredId?: string; + executionStart?: number; + targetExecution?: number; + ownsExecution?: boolean; + ownershipLost?: boolean; + assistantIds: string[]; + completedAssistantIds: string[]; + interruption?: "user" | "shutdown" | "superseded" | "inactivity"; + outcome?: "completed" | "failed" | "cancelled"; + error?: unknown; + synced?: boolean; + pendingKinds?: Record; + pendingDeliveries?: Record; + finalAssistantId?: string; +} + +export const initialV2Observation = (messageId: string): V2ObservationState => ({ + version: 1, cursor: 0, messageId, assistantIds: [], completedAssistantIds: [], +}); + +function observeEvent(state: V2ObservationState, event: Exclude): void { + const data = event.data as Record; + // An established terminal receipt remains valid when later work is submitted. + if (state.outcome) return; + switch (event.type) { + case "session.inbox.enqueued": + (state.pendingKinds ??= {})[data.inboxID] = data.item?.type; + (state.pendingDeliveries ??= {})[data.inboxID] = data.item?.delivery; + if (data.inboxID === state.messageId) { state.admitted = true; state.queued = true; } + break; + case "session.inbox.cancelled": + if (data.inboxID === state.messageId) { state.queued = false; state.outcome = "cancelled"; } + break; + case "session.execution.started": { + state.executionStart = event.durable.seq; + const resume = state.delivered && state.latestDeliveredId === state.messageId && !state.ownershipLost && + ["shutdown", "inactivity"].includes(state.interruption ?? ""); + state.ownsExecution = !!resume; + if (resume) { state.targetExecution = event.durable.seq; delete state.interruption; } + break; + } + case "session.inbox.delivery.changed": + (state.pendingDeliveries ??= {})[data.inboxID] = data.delivery; + break; + case "session.inbox.delivered": { + const delivery = state.pendingDeliveries?.[data.inboxID]; + if (state.pendingDeliveries) delete state.pendingDeliveries[data.inboxID]; + // Explicit compaction is queued control work, not a competing user turn. + if (state.pendingKinds?.[data.inboxID] === "compaction") { delete state.pendingKinds[data.inboxID]; break; } + if (state.pendingKinds) delete state.pendingKinds[data.inboxID]; + state.latestDeliveredId = data.inboxID; + if (data.inboxID === state.messageId) { + state.admitted = true; state.queued = false; state.delivered = true; + state.targetExecution = state.executionStart; + state.ownsExecution = state.executionStart !== undefined; + state.ownershipLost = false; + } else if (state.delivered) { + if (state.ownsExecution && state.finalAssistantId && delivery === "queue") { + state.outcome = "completed"; state.ownsExecution = false; break; + } + // A terminal event has only sessionID. It cannot safely attribute a + // shared execution to this job after another input has been delivered. + state.ownsExecution = false; state.ownershipLost = true; + } + break; + } + case "session.step.started": + if (state.ownsExecution && !state.ownershipLost && typeof data.assistantMessageID === "string") { + delete state.finalAssistantId; + if (!state.assistantIds.includes(data.assistantMessageID)) state.assistantIds.push(data.assistantMessageID); + } + break; + case "session.step.ended": + if (state.ownsExecution && !state.ownershipLost && state.assistantIds.includes(data.assistantMessageID)) { + if (!state.completedAssistantIds.includes(data.assistantMessageID)) state.completedAssistantIds.push(data.assistantMessageID); + if (["stop", "length", "content-filter"].includes(data.finish)) state.finalAssistantId = data.assistantMessageID; + } + break; + case "session.execution.succeeded": + if (state.ownsExecution && !state.ownershipLost && state.targetExecution === state.executionStart) { + state.outcome = "completed"; state.ownsExecution = false; + } + break; + case "session.execution.failed": + if (state.ownsExecution && !state.ownershipLost && state.targetExecution === state.executionStart) { + state.outcome = "failed"; state.error = data.error; state.ownsExecution = false; + } + break; + case "session.execution.interrupted": + if (state.ownsExecution && !state.ownershipLost && state.targetExecution === state.executionStart) { + state.interruption = data.reason; state.ownsExecution = false; + if (data.reason === "user") state.outcome = "cancelled"; + if (data.reason === "superseded") state.ownershipLost = true; + } + break; + } +} + +/** Replay the finite durable session log. Global volatile events/idle are not completion evidence. */ +export async function observeV2Session( + api: V2Api, sessionId: string, messageId: string, options: RequestOptions = {}, persistedState?: V2ObservationState, +): Promise<{ snapshot: JobSnapshot; state: V2ObservationState }> { + if (!sessionId || !messageId) throw new Error("V2 observation requires sessionId and messageId"); + if (persistedState && (persistedState.version !== 1 || persistedState.messageId !== messageId || + !Number.isSafeInteger(persistedState.cursor) || persistedState.cursor < 0)) throw new Error("Invalid V2 observation state"); + const state = structuredClone(persistedState ?? initialV2Observation(messageId)); + const snapshot: JobSnapshot = { backend: "v2", sessionId, messageId, status: "unknown", inputs: [] }; + const context = createRequestContext(options); + const request = { signal: context.signal }; + const read = (operation: Promise) => withAbort(operation, context.signal); + try { + state.synced = false; + let count = 0; + const iterator = api.session.log({ sessionID: sessionId, after: state.cursor, follow: false }, request)[Symbol.asyncIterator](); + try { + while (count++ < 10_000) { + const next = await withAbort(iterator.next(), context.signal); + if (next.done) break; + const event = next.value; + if (event.type === "log.synced") { + if (event.aggregateID !== sessionId) throw new Error("V2 log sync belongs to a different session"); + if (event.seq !== undefined && (!Number.isSafeInteger(event.seq) || event.seq < state.cursor)) throw new Error("V2 log returned an invalid sync cursor"); + // The native log filters obsolete event types; its sync cursor may advance past emitted rows. + if (event.seq !== undefined) state.cursor = event.seq; + state.synced = true; break; + } + if (event.durable.aggregateID !== sessionId || (event.data as { sessionID?: string }).sessionID !== sessionId) { + throw new Error("V2 log returned an event from another session"); + } + if (!Number.isSafeInteger(event.durable.seq) || event.durable.seq < 0) throw new Error("V2 log returned an invalid cursor"); + if (event.durable.seq <= state.cursor) continue; + observeEvent(state, event); + state.cursor = event.durable.seq; + } + } finally { + // The SDK stream uses the request abort signal; cancelling this iterator + // only closes observation, never interrupts backend execution. + if (iterator.return) await withAbort(iterator.return(), context.signal).catch(() => {}); + } + if (!state.synced) { + snapshot.error = { name: "IncompleteSessionLog", message: "Session replay is incomplete; observe again without resubmitting." }; + return { snapshot, state }; + } + const session = await read(api.session.get({ sessionID: sessionId }, request)); + snapshot.session = normalizeSession(session); snapshot.directory = session.location.directory; + const inbox = await read(api.session.inbox.list({ sessionID: sessionId }, request)); + const queued = inbox.find(item => item.id === messageId); + if (queued) { state.admitted = true; state.queued = true; } + else if (state.delivered || state.outcome) state.queued = false; + + // Admission and delivery are separate from history projection. Validate + // their shared public identity before exposing output or input controls. + // A delayed 404 remains recoverable and never causes prompt resubmission. + if (state.delivered && !state.userProjectionValidated) { + const user = await read(api.session.message.get({ sessionID: sessionId, messageID: messageId }, request)); + if (user.id !== messageId || user.type !== "user") throw new Error("V2 returned a mismatched user message projection"); + state.userProjectionValidated = true; + } + + let assistant: SessionMessageAssistant | undefined; + const lastAssistant = (state.outcome === "completed" ? state.completedAssistantIds : state.assistantIds).at(-1); + if (lastAssistant) { + const message = await read(api.session.message.get({ sessionID: sessionId, messageID: lastAssistant }, request)); + if (message.id !== lastAssistant || message.type !== "assistant") throw new Error("V2 returned a mismatched assistant message"); + assistant = message; + const normalized = normalizeMessage(message, sessionId, messageId); + snapshot.result = normalized; snapshot.text = formatMessageResponse(normalized); + } + let status: JobStatus = "unknown"; + if (state.outcome === "completed") { + if (assistant && typeof assistant.time.completed === "number") status = "completed"; + else snapshot.error = { name: "MissingCorrelatedResult", message: "Execution ended but its completed assistant result is not available yet." }; + } else if (state.outcome) { status = state.outcome; if (state.error) snapshot.error = state.error; } + else if (state.ownershipLost) snapshot.error = { name: "TurnOwnershipLost", message: "Another input owns the session; this job cannot claim its output or control it." }; + else if (state.interruption) snapshot.error = { name: "ExecutionInterrupted", reason: state.interruption, + message: "Execution was interrupted by the backend; observe for recovery without resubmitting." }; + else if (state.queued) status = "accepted"; + else if (state.ownsExecution) { + status = "running"; + const [forms, permissions] = await Promise.all([ + read(api.session.form.list({ sessionID: sessionId }, request)), + read(api.permission.list({ sessionID: sessionId }, request)), + ]); + snapshot.inputs = [ + ...forms.filter(form => form.sessionID === sessionId).map(form => ({ ...form, kind: "question" as const, backend: "v2" })), + ...permissions.filter(permission => permission.sessionID === sessionId).map(permission => ({ ...permission, + kind: "permission" as const, backend: "v2", permission: permission.action, patterns: permission.resources, + always: permission.save, approvalScope: "project", projectID: session.projectID })), + ]; + if (snapshot.inputs.length) status = "input_required"; + } + snapshot.status = status; + return { snapshot, state }; + } catch (error) { + // Cursor/correlation survives outages and deadlines. Never replay a write + // or infer failure from a queued message's absent history projection. + snapshot.status = "unknown"; + snapshot.error = { name: error instanceof Error ? error.name : "ObservationError", + message: "V2 observation is incomplete; observe this job again without resubmitting." }; + return { snapshot, state }; + } finally { context.dispose(); } +} diff --git a/src/backends/v2.ts b/src/backends/v2.ts new file mode 100644 index 0000000..36ea8d7 --- /dev/null +++ b/src/backends/v2.ts @@ -0,0 +1,308 @@ +import type { OperationInputs, OperationResults } from "./operations.js"; +import { observeV2Session, type V2ObservationState } from "./v2-observation.js"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { OpenCode, type SessionInfo, type SessionMessageInfo } from "@opencode/client"; +import { OpenCodeClient, OpenCodeSubmissionError, OpenCodeError } from "../client.js"; +import { createMessageId } from "../jobs.js"; +import { assertPromptOptions, assertSessionSelection, assertNoStagedRevert } from "./v2-guards.js"; +import { validateFormValues } from "./v2-forms.js"; +import { translateMcpConfig } from "./v2-mcp.js"; +import type { BackendAdapter, OperationArgs, Operation } from "./adapter.js"; +import { BackendCapabilityError } from "./contracts.js"; +import { normalizeSession, normalizeMessage, normalizeMessages, normalizeProviders, pageArray } from "./normalization.js"; +import { createRequestContext, withAbort, abortableSleep, throwIfAborted } from "../async.js"; +import { normalizeDirectory, applyModelDefaults } from "../helpers.js"; + +const object = (value: unknown): Record => value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +const required = (value: string | undefined, name: string): string => { if (!value) throw new Error(`${name} is required`); return value; }; +export class V2Adapter implements BackendAdapter { + readonly api: ReturnType; + private readonly mutation = new AsyncLocalStorage<{ submitted: boolean; method?: string; path?: string; recovery?: {sessionId:string;messageId?:string} }>(); + constructor(client: OpenCodeClient) { + const base = new URL(client.getBaseUrl()); + this.api = OpenCode.make({ baseUrl: client.getBaseUrl(), headers: client.connectionHeaders, fetch: async (input, init) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + const prefix = base.pathname.replace(/\/$/, ""); + if (prefix && prefix !== "/" && url.origin === base.origin) url.pathname = prefix + url.pathname; + const method = init?.method ?? (input instanceof Request ? input.method : "GET"); + const write = method !== "GET" && method !== "HEAD"; + throwIfAborted(init?.signal ?? undefined); + const state = this.mutation.getStore(); + if (write && state) { state.submitted = true; state.method = method; state.path = url.pathname; } + try { + const response = await fetch(input instanceof Request ? new Request(url, input) : url, init); + if (!response.ok) throw new OpenCodeError(`V2 ${method} request rejected (${response.status})`, response.status, method, url.pathname, await response.text()); + return response; + } catch (error) { + if (write && !(error instanceof OpenCodeError)) throw new OpenCodeSubmissionError(method, url.pathname, error); + throw error; + } + } }); + } + execute(operation: K, args: OperationInputs[K]): Promise { + return this.request(operation, args) as Promise; + } + private async request(operation: Operation, args: OperationArgs): Promise { + const context = createRequestContext(args); + const state: {submitted:boolean;method:string;path:string;recovery?:{sessionId:string;messageId?:string}} = { submitted: false, method: "POST", path: operation as string, ...(args.sessionId ? {recovery:{sessionId:args.sessionId}} : {}) }; + return this.mutation.run(state, async () => { + try { throwIfAborted(context.signal); return await withAbort(this.perform(operation, args, { signal: context.signal }), context.signal); } + catch (error) { const normalized = unwrapV2Error(error); if (state.submitted && (!(normalized instanceof OpenCodeError) || normalized.method === "GET")) { const uncertain = normalized instanceof OpenCodeSubmissionError ? normalized : new OpenCodeSubmissionError(state.method, state.path, normalized); throw Object.assign(uncertain, state.recovery); } throw normalized; } + finally { context.dispose(); } + }); + } + private async perform(operation: Operation, args: OperationArgs, options: { signal: AbortSignal }): Promise { + const api = this.api, location = args.directory ? { directory: args.directory } : undefined; + const q = args.query ?? {}, body = object(args.body); + const sessionID = () => required(args.sessionId, "sessionId"); + switch (operation) { + case "lifecycle.health": { const info = await api.server.info(options); if (!info || typeof info.version !== "string" || typeof info.pid !== "number" || !Array.isArray(info.urls)) throw new Error("Malformed V2 server information"); return { healthy: true, version: info.version, backend: "v2" }; } + case "configuration.get": return { backend: "v2", sources: await api.config.get({ location }, options), effectiveConfiguration: false }; + case "projects.list": return (await api.project.list(options)).map(project => ({ ...project, worktree: project.canonical })); + case "projects.current": { const value = await api.location.get({ location }, options); return { ...value.project, worktree: value.project.directory, directory: value.directory }; } + case "files.paths": { const value = await api.location.get({ location }, options); return { directory: value.directory, worktree: value.project.directory, backend: "v2" }; } + case "files.vcs": return (await api.vcs.get({ location }, options)).data; + case "files.status": return (await api.vcs.status({ location }, options)).data.map(file => ({ file: file.file, status: file.status, additions: file.additions, deletions: file.deletions, raw: { backend: "v2", value: file } })); + case "files.list": return (await api.file.list({ location, path: q.path }, options)).data.map(entry => ({ path: entry.path, type: entry.type, name: entry.path.split(/[\\/]/).at(-1), raw: { backend: "v2", value: entry } })); + case "files.find": { + const directory = normalizeDirectory(q.directory ?? args.directory); + const limit = q.limit !== undefined ? Number(q.limit) : 100; + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error("File search limit must be an integer from 1 to 200"); + const result = await api.file.find({ location: directory ? { directory } : location, query: q.query ?? "", type: q.type as "file" | "directory" | undefined, limit }, options); + return Object.assign(result.data.slice(0, limit).map(entry => entry.path), { pagination: { complete: result.data.length < limit, bounded: result.data.length >= limit, continuationAvailable: false } }); + } + case "files.read": { + const bytes = await api.file.read({ location, path: required(q.path, "path") }, options); + try { return { type: "text", content: new TextDecoder("utf-8", { fatal: true }).decode(bytes), backend: "v2" }; } + catch { return { type: "binary", encoding: "base64", content: Buffer.from(bytes).toString("base64"), backend: "v2" }; } + } + case "configuration.agents": return (await api.agent.list({ location }, options)).data; + case "configuration.commands": return (await api.command.list({ location }, options)).data; + case "configuration.mcp": return Object.fromEntries((await api.mcp.list({ location }, options)).data.map(server => [server.name, { name: server.name, status: server.status.status, integrationID: server.integrationID, raw: { backend: "v2", value: server } }])); + case "providers.list": case "providers.configured": { + const providers = await api.provider.list({ location }, options); + const models = await api.model.list({ location }, options); + const integrations = await api.integration.list({ location }, options); + const result = normalizeProviders(providers.data, models.data, integrations.data); + return operation === "providers.configured" ? { providers: result.all.filter(provider => provider.configured), default: result.default } : result; + } + case "providers.authMethods": { + const providers = await api.provider.list({ location }, options); + const integrations = await api.integration.list({ location }, options); + return Object.fromEntries(providers.data.map(provider => [provider.id, integrations.data.find(integration => integration.id === provider.integrationID)?.methods.map(method => ({ ...method, integrationID: provider.integrationID })) ?? []])); + } + case "sessions.list": case "sessions.children": { + const currentLocation = await api.location.get({ location }, options); + const values: SessionInfo[] = []; let cursor: string | undefined = q.cursor; const seen = new Set(); + const limit = q.limit !== undefined ? Number(q.limit) : undefined; + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) throw new Error("limit must be a positive integer"); + do { + const page = await api.session.list({ directory: currentLocation.directory, parentID: operation === "sessions.children" ? sessionID() : undefined, cursor, limit: limit ? Math.min(100, limit-values.length) : 100, search: q.search }, options); + values.push(...page.data); cursor = page.cursor.next ?? undefined; + if (!cursor || (limit && values.length >= limit)) break; + if (seen.has(cursor)) throw new Error("V2 returned a repeated session pagination cursor"); seen.add(cursor); + } while (true); + return pageArray(values.map(normalizeSession), cursor); + } + case "sessions.get": return normalizeSession(await this.scopedSession(args, options)); + case "sessions.status": { + const currentLocation = await api.location.get({ location }, options); + const statuses = await api.session.active(options); + const entries = await Promise.all(Object.entries(statuses).map(async ([id, state]) => { + const info = await api.session.get({ sessionID: id }, options); return info.location.directory === currentLocation.directory ? [id, state] as const : undefined; + })); + return Object.fromEntries(entries.filter(entry => entry !== undefined)); + } + case "messages.list": { + if (args.directory) await this.scopedSession(args, options); + const values: SessionMessageInfo[] = []; let cursor: string | undefined = q.cursor; const seen = new Set(); + const limit = q.limit !== undefined ? Number(q.limit) : undefined; + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) throw new Error("limit must be a positive integer"); + do { + const page = await api.message.list({ sessionID: sessionID(), cursor, limit: limit ? Math.min(100, limit-values.length) : 100 }, options); + values.push(...page.data); cursor = page.cursor.next ?? undefined; + if (!cursor || (limit && values.length >= limit)) break; + if (seen.has(cursor)) throw new Error("V2 returned a repeated message pagination cursor"); seen.add(cursor); + } while (true); + return pageArray(normalizeMessages(values, sessionID()), cursor); + } + case "messages.get": { + if (args.directory) await this.scopedSession(args, options); + return normalizeMessage(await api.session.message.get({ sessionID: sessionID(), messageID: required(args.messageId, "messageId") }, options), sessionID()); + } + case "permissions.list": return (await api.permission.request.list({ location }, options)).data.map(request => ({ id: request.id, sessionID: request.sessionID, permission: request.action, patterns: request.resources, always: request.save, backend: "v2", approvalScope: "project", rejectionScope: "session", raw: { backend: "v2", value: request } })); + case "forms.list": return (await api.form.list({ location }, options)).data.map(form => ({ ...form, backend: "v2" })); + case "sessions.create": { + assertPromptOptions(body); + if (body.parentID !== undefined) throw new BackendCapabilityError("session_create.parentID", "V2 session creation has no parentID equivalent; use an explicit fork instead."); + const model = body.model ?? applyModelDefaults(); + if (body.variant && !model) throw new BackendCapabilityError("session.variant", "Select a provider and model before requesting a V2 model variant."); + return normalizeSession(await api.session.create({ title: body.title, agent: body.agent, + model: model ? { id: model.modelID, providerID: model.providerID, ...(body.variant ? { variant: body.variant } : {}) } : undefined, location }, options)); + } + case "sessions.remove": await this.scopedSession(args, options); return api.session.remove({ sessionID: sessionID() }, options); + case "sessions.update": await this.scopedSession(args, options); await api.session.update({ sessionID: sessionID(), title: body.title }, options); return normalizeSession(await api.session.get({ sessionID: sessionID() }, options)); + case "sessions.abort": await this.scopedSession(args, options); return api.session.interrupt({ sessionID: sessionID() }, options); + case "sessions.fork": { + await this.scopedSession(args, options); + if (body.messageID !== undefined) await api.session.message.get({ sessionID: sessionID(), messageID: required(body.messageID, "messageID") }, options); + const value = await api.session.fork({ sessionID: sessionID(), before: body.messageID }, options); + return { ...normalizeSession(value), forkBoundary: body.messageID ? { type: "before", messageId: body.messageID } : { type: "through-current-history" } }; + } + case "sessions.compact": { + const session = await this.scopedSession(args, options); + assertNoStagedRevert(session); + assertSessionSelection(session, { model: body.providerID && body.modelID ? { providerID: body.providerID, modelID: body.modelID } : undefined, variant: body.variant }); + return api.session.compact({ sessionID: sessionID(), delivery: "queue" }, options); + } + case "sessions.revert": { + if (body.partID !== undefined) throw new BackendCapabilityError("session_revert.partID", "V2 cannot revert individual message parts."); + await this.scopedSession(args, options); + const staged = await api.session.revert.stage({ sessionID: sessionID(), messageID: required(body.messageID, "messageID"), files: true }, options); + return { staged, committed: false, reversible: true }; + } + case "sessions.unrevert": await this.scopedSession(args, options); return api.session.revert.clear({ sessionID: sessionID() }, options); + case "sessions.diff": { + if (q.messageID && (q.from || q.to)) throw new Error("Supply a V2 from/to range without the V1 messageID parameter"); + if (!q.from || !q.to) throw new BackendCapabilityError("session_diff.range", "V2 requires explicit from and to message IDs; its default is only the newest turn, not the entire session."); + await this.scopedSession(args, options); + const history = await this.perform("messages.list", args, options) as ReturnType; + const from = history.findIndex(message => message.info.id === q.from), to = history.findIndex(message => message.info.id === q.to); + if (from < 0 || to < 0 || from > to) throw new Error("Invalid diff range; from and to must identify ordered messages in this session"); + if (history.slice(from, to + 1).some(message => message.info.role === "location-switched")) throw new BackendCapabilityError("session_diff.location", "V2 cross-location diff ranges are unsupported."); + const diffs = await api.session.diff({ sessionID: sessionID(), from: q.from, to: q.to }, options); + return Object.assign(diffs, { attribution: "turn", range: { from: q.from, to: q.to } }); + } + case "messages.enqueue": case "messages.send": { + assertPromptOptions(body); + const session = await this.scopedSession(args, options); assertSessionSelection(session, body); + assertNoStagedRevert(session); + const parts = Array.isArray(body.parts) ? body.parts : []; + if (parts.some(part => part.type !== "text")) throw new BackendCapabilityError("prompt.parts", "This V2 compatibility prompt accepts text parts only."); + const id = body.messageID ?? createMessageId(); + const dispatchState = this.mutation.getStore(); if (dispatchState) dispatchState.recovery = { sessionId: sessionID(), messageId: id }; + const receipt = await api.session.prompt({ sessionID: sessionID(), id, text: parts.map(part => part.text ?? "").join("\n"), delivery: "queue" }, options); + if (receipt.id !== id) throw new OpenCodeSubmissionError("POST", "session.prompt", new Error("V2 inbox receipt does not match the persisted prompt identifier")); + if (operation === "messages.enqueue") return receipt; + return this.waitPrompt(sessionID(), id, options); + } + case "messages.command": throw new BackendCapabilityError("command_execute", "V2 2.0.6 command submission returns no receipt and accepts no caller identifier. Exact execution completion cannot be correlated safely; no command was submitted."); + case "messages.shell": { + const session = await this.scopedSession(args, options); assertSessionSelection(session, body); + const id = createMessageId(); + const dispatchState = this.mutation.getStore(); if (dispatchState) dispatchState.recovery = { sessionId: sessionID(), messageId: id }; + await api.session.shell({ sessionID: sessionID(), id, command: required(body.command, "command") }, options); + while (true) { + try { + const result = await api.session.message.get({ sessionID: sessionID(), messageID: id }, options); + if (result.type === "shell" && result.status !== "running") return { ...normalizeMessage(result, sessionID()), execution: { completed: true, status: result.status, exitCode: result.exit }, isError: result.status !== "exited" || result.exit !== 0 }; + } catch (error) { if (!isV2NotFound(error)) throw error; } + await abortableSleep(100, options.signal); + } + } + case "providers.authorize": { + const integration = await this.resolveIntegration(required(args.providerId, "providerId"), body.integrationId, options); + const method = body.methodId ? integration.methods.find(method => "id" in method && method.id === body.methodId) : integration.methods[body.method ?? 0]; + if (!method || method.type !== "oauth") throw new Error("Select an OAuth method returned by provider_auth_methods"); + const values = body.values ?? body.inputs; + if (method.form) validateFormValues(method.form, values ?? {}); + const result = await api.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, answer: values }, options); + return { ...result.data, attemptId: result.data.attemptID, integrationId: integration.id, methodId: method.id }; + } + case "providers.callback": { + const attemptID = required(body.attemptId, "attemptId from the matching OAuth authorization"); + const integration = await this.resolveIntegration(required(args.providerId, "providerId"), body.integrationId, options); + return api.integration.oauth.complete({ integrationID: integration.id, attemptID, code: body.code }, options); + } + case "providers.setAuth": { + if (body.type !== "api") throw new BackendCapabilityError("auth_set.type", "V2 auth_set supports API keys; use the OAuth authorization workflow for OAuth."); + const integration = await this.resolveIntegration(required(args.providerId, "providerId"), body.integrationId, options); + if (!integration.methods.some(method => method.type === "key")) throw new BackendCapabilityError("auth_set.method", "This integration does not publish a key authentication method."); + return api.integration.connect.key({ integrationID: integration.id, key: required(body.key, "key") }, options); + } + case "permissions.reply": { + if (!["once", "always", "reject"].includes(body.reply)) throw new Error("Choose an explicit permission reply: once, always, or reject"); + const sessionId = args.sessionId ?? body.sessionId; + if (!sessionId) throw new Error("V2 permission replies require explicit session identity"); + if (body.reply === "always" && body.scope !== "project") throw new BackendCapabilityError("permission.scope", "V2 always saves matching approvals for the entire project; explicitly acknowledge scope: project."); + if (body.reply === "reject" && body.scope !== "session") throw new BackendCapabilityError("permission.scope", "V2 reject rejects every pending permission in this session; explicitly acknowledge scope: session."); + await this.scopedSession({ ...args, sessionId }, options); + await api.permission.reply({ sessionID: sessionId, requestID: required(args.requestId, "requestId"), decision: body.reply, message: body.message }, options); + return { reply: body.reply, scope: body.reply === "always" ? "project" : body.reply === "reject" ? "session" : "request" }; + } + case "forms.reply": case "forms.reject": { + const requestId = required(args.requestId, "requestId"); + let sessionId = args.sessionId ?? body.sessionId; + if (!sessionId) { + const matches = (await api.form.list({ location }, options)).data.filter(form => form.id === requestId); + if (matches.length !== 1) throw new Error("Provide sessionId to identify the V2 form unambiguously"); + sessionId = matches[0].sessionID; + } + await this.scopedSession({ ...args, sessionId }, options); + if (operation === "forms.reject") return api.session.form.cancel({ sessionID: sessionId, formID: requestId }, options); + const form = await api.session.form.get({ sessionID: sessionId, formID: requestId }, options); + if (form.state.status !== "pending") throw new Error("This form is no longer pending"); + if (body.answers !== undefined && body.values !== undefined) throw new Error("Supply values or answers, not both"); + if (body.answers !== undefined && body.values === undefined) throw new BackendCapabilityError("question.answers", "V2 forms require typed field-keyed values; use the fields returned by question_list."); + const values = validateFormValues(form.fields, body.values); + return api.session.form.reply({ sessionID: sessionId, formID: requestId, answer: values }, options); + } + case "configuration.mcpAdd": { + const config = translateMcpConfig(body.config); + await api.mcp.add({ server: required(body.name, "name"), location, config }, options); + return { name: body.name, configured: true }; + } + default: throw new BackendCapabilityError(operation, `${operation} has no implemented V2 mapping`); + } + } + private async scopedSession(args: OperationArgs, options: { signal: AbortSignal }) { + const session = await this.api.session.get({ sessionID: required(args.sessionId, "sessionId") }, options); + if (!session || session.id !== args.sessionId || typeof session.location?.directory !== "string") throw new Error("V2 returned a malformed or mismatched session"); + if (args.directory) { + const location = await this.api.location.get({ location: { directory: args.directory } }, options); + if (location.directory !== session.location.directory) throw new Error("Session location does not match the supplied directory"); + } + return session; + } + private async resolveIntegration(providerId: string, explicit: string | undefined, options: { signal: AbortSignal }) { + const providers = await this.api.provider.list(undefined, options); + const matches = providers.data.filter(provider => provider.id === providerId); + if (matches.length !== 1 || !matches[0].integrationID) throw new Error("Provider has no unambiguous integration mapping"); + const integrationID = matches[0].integrationID; + if (explicit && explicit !== integrationID) throw new Error("Explicit integration does not match the provider mapping"); + const integrations = await this.api.integration.list(undefined, options); + const candidates = integrations.data.filter(value => value.id === integrationID); + if (candidates.length !== 1) throw new Error("Provider integration mapping is missing or ambiguous"); + return candidates[0]; + } + private async waitPrompt(sessionID: string, messageID: string, options: { signal: AbortSignal }): Promise { + let state: V2ObservationState | undefined; + while (true) { + throwIfAborted(options.signal); + const observed = await observeV2Session(this.api, sessionID, messageID, options, state); + state = observed.state; + if (observed.snapshot.status === "completed") return observed.snapshot.result; + if (["failed", "cancelled", "input_required"].includes(observed.snapshot.status)) return { + info: { id: messageID, sessionID, role: "assistant", error: { name: observed.snapshot.status, message: observed.snapshot.text ?? "OpenCode needs input or execution stopped; inspect the session before retrying." } }, + parts: [{ type: "text", text: observed.snapshot.text ?? JSON.stringify(observed.snapshot.inputs ?? observed.snapshot.error) }], + observation: observed.snapshot, + }; + await abortableSleep(100, options.signal); + } + } +} +function isV2NotFound(error: unknown): boolean { + const normalized = unwrapV2Error(error); + return normalized instanceof OpenCodeError && normalized.status === 404 || !!normalized && typeof normalized === "object" && "_tag" in normalized && String(normalized._tag).includes("NotFound"); +} + + +export function unwrapV2Error(error: unknown): unknown { + let current = error; + for (let depth = 0; depth < 8; depth++) { + if (current instanceof OpenCodeError || current instanceof OpenCodeSubmissionError) return current; + if (!current || typeof current !== "object" || !("cause" in current)) break; + current = current.cause; + } + return error; +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..3ef64e5 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,31 @@ +export type CliArgs = { command: "stdio" | "help" | "version" } | { command: "doctor"; json: boolean; directory?: string }; +export class CliUsageError extends Error {} +export const CLI_HELP = `Usage: opencode-mcp [doctor | --check] [--json] [--directory PATH] + +No arguments starts the MCP stdio server. + doctor, --check Read-only readiness checks; never starts a server or model + --json Print versioned diagnostic JSON (doctor only) + --directory PATH Check a project path on the OpenCode server (doctor only) + --help Show this help + --version Show the package version + +Doctor exits 0 when ready, 1 when checks fail, and 2 for invalid arguments. +Configure OPENCODE_BACKEND=auto|v1|v2 and optional OPENCODE_BASE_URL. +`; +export function parseCliArgs(args: string[]): CliArgs { + if (!args.length) return { command: "stdio" }; + if (args.length === 1 && args[0] === "--help") return { command: "help" }; + if (args.length === 1 && args[0] === "--version") return { command: "version" }; + if (args[0] !== "doctor" && args[0] !== "--check") throw new CliUsageError("Expected doctor, --check, --help, or --version."); + let json = false; + let directory: string | undefined; + for (let i = 1; i < args.length; i++) { + if (args[i] === "--json" && !json) { json = true; continue; } + if (args[i] === "--directory" && directory === undefined && args[i + 1] && !args[i + 1].startsWith("--")) { directory = args[++i]; continue; } + throw new CliUsageError("Invalid or duplicate doctor option. Use --json and --directory PATH."); + } + try { directory = normalizeDirectory(directory); } + catch { throw new CliUsageError("--directory must be an absolute path on the OpenCode server, without control characters."); } + return { command: "doctor", json, directory }; +} +import { normalizeDirectory } from "./helpers.js"; diff --git a/src/client.ts b/src/client.ts index 29104e2..804826a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,3 +1,4 @@ +import type { BackendIdentity, BackendKind } from "./backends/contracts.js"; import { createOpencodeClient, OpencodeClient as NativeClient } from "@opencode-ai/sdk"; import { normalizeDirectory } from "./helpers.js"; import { ensureServer, isServerRunning } from "./server-manager.js"; @@ -9,6 +10,9 @@ export interface OpenCodeClientOptions { username?: string; password?: string; autoServe?: boolean; + backend?: BackendKind; + identity?: BackendIdentity; + unavailableReason?: string; } export class OpenCodeError extends Error { @@ -74,6 +78,9 @@ function buildBasicAuthHeader(username?: string, password?: string): string | un export class OpenCodeClient { public api: NativeClient; + readonly backendIdentity: BackendIdentity; + private readonly unavailableReason?: string; + readonly connectionHeaders: Record; private baseUrl: string; private autoServe: boolean; private reconnectAttempts = 0; @@ -81,6 +88,10 @@ export class OpenCodeClient { private password?: string; constructor(options: OpenCodeClientOptions) { + this.unavailableReason = options.unavailableReason; + this.backendIdentity = options.identity ?? { kind: options.backend ?? "v1", connectionSource: "explicit", ...(options.unavailableReason ? { resolved: false as const } : {}), processOwnership: "external", survivesDisconnect: true }; + const authorization = buildBasicAuthHeader(options.username, options.password); + this.connectionHeaders = authorization ? { Authorization: authorization } : {}; this.baseUrl = options.baseUrl.replace(/\/$/, ""); this.autoServe = options.autoServe ?? false; this.username = options.username; @@ -103,6 +114,10 @@ export class OpenCodeClient { return createOpencodeClient({ baseUrl: this.baseUrl, headers }); } + assertBackendAvailable(): void { if (this.unavailableReason) throw Object.assign(new Error(this.unavailableReason), { code: "BACKEND_UNAVAILABLE" }); } + + getBackendIdentity(): BackendIdentity { return this.backendIdentity; } + getBaseUrl(): string { return this.baseUrl; } @@ -112,6 +127,7 @@ export class OpenCodeClient { path: string, opts: RequestOptions & { query?: Record; body?: unknown; directory?: string } = {}, ): Promise { + this.assertBackendAvailable(); // Validate before submission. A malformed local argument cannot be an // ambiguous remote mutation and must never trigger retries. const normalized = normalizeDirectory(opts.directory); @@ -212,6 +228,7 @@ export class OpenCodeClient { } async *subscribeSSE(path: string, opts: RequestOptions & { directory?: string } = {}): AsyncGenerator<{ event: string; data: string }, void, undefined> { + this.assertBackendAvailable(); const url = new URL(path, this.baseUrl).toString(); const headers: Record = { Accept: "text/event-stream", "Cache-Control": "no-cache" }; const normalized = normalizeDirectory(opts.directory); diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..6a4918b --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,112 @@ +import { OpenCodeClient } from "./client.js"; +import { createRequestContext, withAbort } from "./async.js"; +import { isProviderConfigured, normalizeDirectory } from "./helpers.js"; +import { operate } from "./backends/adapter.js"; +import { capabilities } from "./backends/capabilities.js"; +import { BackendDetectionError, detectBackend, readBackendConfig, type BackendConfig, type DetectedBackend, type DetectionDependencies } from "./backend-detection.js"; +import type { BackendIdentity } from "./backends/contracts.js"; + +export interface DoctorCheck { id: string; status: "pass" | "warn" | "fail" | "skipped"; message: string; remedy?: string } +export interface DoctorReport { + schemaVersion: 1; + ready: boolean; + checks: DoctorCheck[]; + backend?: BackendIdentity & { integrationTargetVersion: string; matchesIntegrationTarget: boolean; capabilities: { supported: number; unsupported: number; blocked: number } }; +} +export interface DoctorOptions { + directory?: string; + env?: NodeJS.ProcessEnv; + nodeVersion?: string; + /** Injectable lower budget for deadline tests; production always uses 15 s. */ + timeoutMs?: number; + dependencies?: DetectionDependencies; + detect?: typeof detectBackend; + client?: (connection: DetectedBackend) => OpenCodeClient; +} +const ids = ["node", "configuration", "backend", "authentication", "supported_version", "provider_configuration", "project_access", "lifecycle"]; +function statusCode(error: unknown): number | undefined { + if (!error || typeof error !== "object") return undefined; + const candidate = error as { _tag?: string; status?: unknown; statusCode?: unknown; response?: { status?: unknown }; cause?: { status?: unknown } }; + if (candidate._tag === "UnauthorizedError") return 401; + const status = candidate.status ?? candidate.statusCode ?? candidate.response?.status ?? candidate.cause?.status; + return typeof status === "number" ? status : undefined; +} +export async function runDoctor(options: DoctorOptions = {}): Promise { + const context = createRequestContext({ timeout: Math.min(options.timeoutMs ?? 15000, 15000) }); + const report: DoctorReport = { schemaVersion: 1, ready: false, checks: [] }; + const add = (id: string, status: DoctorCheck["status"], message: string, remedy?: string) => report.checks.push({ id, status, message, ...(remedy ? { remedy } : {}) }); + const finish = () => { + for (const id of ids) if (!report.checks.some(check => check.id === id)) add(id, "skipped", "Not checked because an earlier requirement failed."); + report.ready = !report.checks.some(check => check.status === "fail" || check.status === "skipped"); + return report; + }; + try { + const major = Number((options.nodeVersion ?? process.versions.node).split(".")[0]); + add("node", major >= 22 ? "pass" : "fail", major >= 22 ? "Node.js meets the >=22 requirement." : "Node.js 22 or newer is required.", major >= 22 ? undefined : "Install a supported Node.js version."); + let config: BackendConfig; + let directory: string | undefined; + try { config = readBackendConfig(options.env); directory = normalizeDirectory(options.directory); } + catch { + add("configuration", "fail", "Invalid backend, URL, profile, model defaults, or directory configuration.", "Use --help; check OPENCODE_BACKEND, OPENCODE_BASE_URL, model defaults, and an absolute server directory."); + return finish(); + } + add("configuration", "pass", "Environment and directory configuration are valid."); + if (major < 22) return finish(); + let connection: DetectedBackend; + try { + connection = await withAbort((options.detect ?? detectBackend)(config, { signal: context.signal, deadline: context.deadline, allowStartup: false }, options.dependencies), context.signal); + } catch (error) { + const auth = error instanceof BackendDetectionError && error.code === "authentication"; + add("backend", "fail", auth ? "The server rejected authentication." : context.signal.aborted ? "The 15-second diagnostic deadline expired." : "No backend satisfying the configured contract could be detected.", auth ? "Check server authentication configuration." : "Start OpenCode separately or configure an explicit OPENCODE_BASE_URL; doctor never starts it."); + if (auth) add("authentication", "fail", "Server authentication failed.", "Check OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD."); + return finish(); + } + const identity = connection.identity; + const integrationTargetVersion = identity.kind === "v1" ? "1.18.31" : "2.0.6"; + const matchesIntegrationTarget = identity.version === integrationTargetVersion; + const safeVersion = identity.version && /^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(identity.version) ? identity.version : undefined; + report.backend = { ...identity, version: safeVersion, integrationTargetVersion, matchesIntegrationTarget, + capabilities: { supported: capabilities.filter(c => c[identity.kind] === "supported").length, + unsupported: capabilities.filter(c => c[identity.kind] === "unsupported").length, + blocked: capabilities.filter(c => c[identity.kind] === "blocked").length } }; + add("backend", "pass", `Detected the ${identity.kind.toUpperCase()} server contract.`); + add("authentication", "pass", "The server accepted the read-only connection probe; model credentials have not been tested."); + const recognizedVersion = safeVersion?.startsWith(identity.kind === "v1" ? "1." : "2.") === true; + add("supported_version", matchesIntegrationTarget ? "pass" : recognizedVersion ? "warn" : "fail", + matchesIntegrationTarget ? `Server contract recognized and version matches the ${integrationTargetVersion} integration target.` : recognizedVersion ? `Server contract recognized; the integration target is ${integrationTargetVersion}.` : "The server version is absent, malformed, or outside the recognized backend major.", + matchesIntegrationTarget ? undefined : `Use the ${integrationTargetVersion} target and consult documented live verification evidence.`); + add("lifecycle", "pass", identity.survivesDisconnect ? "The external/shared server survives MCP disconnect while its owning process remains alive. A local executable is not required." : "The owned child stops with MCP; background execution does not survive disconnect."); + const client = (options.client ?? (found => new OpenCodeClient({ baseUrl: found.baseUrl, username: found.username, password: found.password, backend: found.identity.kind, identity: found.identity })))(connection); + const request = { directory, signal: context.signal, deadline: context.deadline }; + // Keep reads sequential: any 401/403 ends diagnostics without another request. + try { + const raw: unknown = await withAbort(operate(client, "providers.list", request), context.signal); + const record = raw && typeof raw === "object" ? raw as Record : undefined; + const list = Array.isArray(raw) ? raw : Array.isArray(record?.all) ? record.all : []; + const connected = Array.isArray(record?.connected) ? record.connected : []; + const configured = list.some(provider => provider && typeof provider === "object" && (isProviderConfigured(provider) || connected.includes(provider.id))); + add("provider_configuration", configured ? "pass" : "fail", configured ? "At least one provider is configured. Credentials were not exercised and no inference was run." : "No configured provider was found.", configured ? undefined : "Configure a provider in OpenCode, then rerun doctor."); + } catch (error) { + const auth = [401, 403].includes(statusCode(error) ?? 0); + add("provider_configuration", "fail", auth ? "The provider read was denied by server authentication." : context.signal.aborted ? "The diagnostic deadline expired while reading providers." : "Provider configuration could not be read.", "Check the server connection and provider configuration."); + if (auth) { report.checks.find(check => check.id === "authentication")!.status = "fail"; return finish(); } + } + if (!directory) { + add("project_access", "pass", "Project access was not requested. Supply --directory to check an explicit server project."); + } else if (!context.signal.aborted) { + try { + const project: unknown = await withAbort(operate(client, "projects.current", request), context.signal); + if (!project || typeof project !== "object" || typeof (project as { worktree?: unknown }).worktree !== "string") throw new Error("Invalid project response"); + add("project_access", "pass", "The requested/default project is accessible on the OpenCode server."); + } catch (error) { + const auth = [401, 403].includes(statusCode(error) ?? 0); + add("project_access", "fail", auth ? "The project read was denied by server authentication." : context.signal.aborted ? "The diagnostic deadline expired while reading the project." : "The requested/default project could not be accessed.", "Pass --directory with an existing absolute path on the OpenCode server."); + if (auth) report.checks.find(check => check.id === "authentication")!.status = "fail"; + } + } + return finish(); + } finally { context.dispose(); } +} +export function formatDoctor(report: DoctorReport): string { + return [report.ready ? "OpenCode MCP is ready." : "OpenCode MCP is not ready.", ...report.checks.map(check => `${check.status.toUpperCase()} ${check.id}: ${check.message}${check.remedy ? ` ${check.remedy}` : ""}`)].join("\n"); +} diff --git a/src/helpers.ts b/src/helpers.ts index 46e1458..856a1b5 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,3 +1,4 @@ +import { BackendCapabilityError } from "./backends/contracts.js"; /** * Smart response formatting helpers. * @@ -42,7 +43,7 @@ export const outputFormatParam = z.discriminatedUnion("type", [ schema: z.record(z.string(), z.unknown()), retryCount: z.number().int().nonnegative().optional(), }), -]).optional().describe("Response format: plain text or JSON constrained by a JSON Schema. JSON Schema requires OpenCode permission for the StructuredOutput tool."); +]).optional().describe("Response format: plain text, or on V1 only JSON constrained by a JSON Schema. V2 rejects JSON Schema. V1 JSON Schema requires OpenCode permission for the StructuredOutput tool."); // ── Default Provider/Model ──────────────────────────────────────────── @@ -350,7 +351,8 @@ export function safeStringify( if (!Number.isInteger(maxLength) || maxLength < 32) { throw new Error("JSON output budget must be an integer of at least 32 characters."); } - const json = JSON.stringify(value ?? null, null, 2); + const serialized = Array.isArray(value) && "pagination" in value ? { data: value, ...(value.pagination as object) } : value; + const json = JSON.stringify(serialized ?? null, null, 2); if (json.length <= maxLength) return json; const envelope = (length: number) => JSON.stringify({ truncated: true, @@ -540,6 +542,7 @@ export function redactSecrets(value: unknown): unknown { * - Everything else → not configured */ export function isProviderConfigured(p: Record): boolean { + if (p.source === "integration") return p.configured === true; const source = p.source as string | undefined; if (source === "env" || source === "config" || source === "api") return true; if (source === "custom") { @@ -579,6 +582,9 @@ export function resolveSessionStatus(raw: unknown): string { * Standard tool response builder. */ export function toolResult(text: string, isError = false, structuredContent?: Record) { + const data = structuredContent?.data; + if (Array.isArray(data) && "pagination" in data) structuredContent = { ...structuredContent, ...(data.pagination as object) }; + if (Array.isArray(data) && "attribution" in data) { structuredContent = { ...structuredContent, attribution: data.attribution, range: (data as unknown as {range:unknown}).range }; text += "\nV2 attribution is by turn across the explicit message range."; } return { content: [{ type: "text" as const, text }], ...(isError ? { isError: true } : {}), @@ -592,7 +598,11 @@ export function toolError(e: unknown) { const text = suggestions ? `Error: ${msg}\n\n**Suggestions:**\n${suggestions}` : `Error: ${msg}`; - return toolResult(text, true); + if (e && typeof e === "object" && "submissionOutcome" in e && e.submissionOutcome === "unknown") { + const recovery = e as {sessionId?:string;messageId?:string}; + return toolResult(text, true, { error: { code: "SUBMISSION_OUTCOME_UNKNOWN", message: msg }, ...recovery.sessionId ? { sessionId: recovery.sessionId } : {}, ...recovery.messageId ? { messageId: recovery.messageId } : {} }); + } + return toolResult(text, true, e instanceof BackendCapabilityError ? { error: { code: e.code, capability: e.capability, backend: e.backend, message: e.message } } : undefined); } /** diff --git a/src/index.ts b/src/index.ts index 9b23128..a489e07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,31 +1,54 @@ #!/usr/bin/env node +import { packageVersion } from "./version.js"; import { createHash } from "node:crypto"; import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { OpenCodeClient } from "./client.js"; import { JobService } from "./jobs.js"; import { TaskTransport } from "./task-transport.js"; import { createServer } from "./app.js"; -import { ensureServer, registerShutdownHandlers } from "./server-manager.js"; +import { registerShutdownHandlers } from "./server-manager.js"; import { setModelDefaults } from "./helpers.js"; +import { CLI_HELP, CliUsageError, parseCliArgs } from "./cli.js"; +import { runDoctor, formatDoctor } from "./doctor.js"; +import { BackendDetectionError, DEFAULT_BASE_URL, detectBackend, readBackendConfig, type DetectedBackend } from "./backend-detection.js"; async function main() { - const baseUrl = process.env.OPENCODE_BASE_URL ?? "http://127.0.0.1:4096"; - const username = process.env.OPENCODE_SERVER_USERNAME; - const password = process.env.OPENCODE_SERVER_PASSWORD; - const autoServe = process.env.OPENCODE_AUTO_SERVE === "true"; - const profile = process.env.OPENCODE_TOOL_PROFILE ?? "full"; - if (profile !== "full" && profile !== "essential") throw new Error("OPENCODE_TOOL_PROFILE must be full or essential"); - setModelDefaults(process.env.OPENCODE_DEFAULT_PROVIDER, process.env.OPENCODE_DEFAULT_MODEL); + const args = parseCliArgs(process.argv.slice(2)); + if (args.command === "help") { console.log(CLI_HELP); return; } + if (args.command === "version") { console.log(packageVersion); return; } + if (args.command === "doctor") { + const report = await runDoctor({ directory: args.directory }); + console.log(args.json ? JSON.stringify(report, null, 2) : formatDoctor(report)); + process.exitCode = report.ready ? 0 : 1; + return; + } + const config = readBackendConfig(); + const profile = config.profile; + setModelDefaults(config.defaultProvider, config.defaultModel); registerShutdownHandlers(); - const client = new OpenCodeClient({ baseUrl, username, password, autoServe }); + let connection: DetectedBackend; + let unavailableReason: string | undefined; + try { connection = await detectBackend(config, { allowStartup: true }); } + catch (error) { + if (!(error instanceof BackendDetectionError) || error.code === "authentication" || error.code === "config" || config.backend !== "auto") throw error; + unavailableReason = "Backend is unresolved. Run opencode-mcp doctor, then restart this MCP connection after OpenCode is ready."; + console.error(`Warning: ${unavailableReason}`); + connection = { baseUrl: config.baseUrl ?? DEFAULT_BASE_URL, username: config.username, password: config.password, + identity: { kind: "v1", connectionSource: config.baseUrl ? "explicit" : "loopback", processOwnership: "external", survivesDisconnect: true, resolved: false } }; + } + const { baseUrl, username, password, identity } = connection; + // Detection is the only startup decision. Ordinary client calls never start + // an alternate server after a failed request or stop a discovered service. + const client = new OpenCodeClient({ baseUrl, username, password, backend: identity.kind, identity, autoServe: false, unavailableReason }); const scope = createHash("sha256").update(JSON.stringify([baseUrl, username ?? "opencode", password ?? ""])).digest("hex"); - const jobs = new JobService(client, { storeRoot: process.env.OPENCODE_TASK_STORE, scope }); - try { await ensureServer({ baseUrl, autoServe, username, password }); } - catch (error) { console.error(`Warning: ${error instanceof Error ? error.message : String(error)}`); } + const jobs = new JobService(client, { storeRoot: config.taskStore, scope }); serveStdio(() => createServer(client, jobs, profile), { legacy: "serve", transport: new TaskTransport(new StdioServerTransport(), jobs), onerror: error => console.error("MCP transport error:", error.message), }); - console.error(`opencode-mcp v3.0.0 started (profile: ${profile})`); + console.error(`opencode-mcp v${packageVersion} started (profile: ${profile})`); } -main().catch(error => { console.error("Fatal error starting opencode-mcp:", error); process.exitCode = 1; }); +main().catch(error => { + console.error(error instanceof CliUsageError ? `${error.message}\nUse opencode-mcp --help.` : error instanceof BackendDetectionError ? error.message : "Failed to start opencode-mcp. Run opencode-mcp doctor for redacted diagnostics."); + process.exitCode = error instanceof CliUsageError ? 2 : 1; +}); diff --git a/src/jobs.ts b/src/jobs.ts index 4457e4b..dbc11d2 100644 --- a/src/jobs.ts +++ b/src/jobs.ts @@ -3,8 +3,15 @@ import { chmod, mkdir, open, readFile, readdir, rename, rm, rmdir } from "node:f import { homedir, hostname } from "node:os"; import { join } from "node:path"; import { OpenCodeClient, OpenCodeError, OpenCodeSubmissionError } from "./client.js"; -import { abortableSleep, createRequestContext, throwIfAborted, withRequestOptions, type RequestOptions } from "./async.js"; +import { abortableSleep, createRequestContext, throwIfAborted, withAbort, withRequestOptions, type RequestOptions } from "./async.js"; import { formatMessageResponse, normalizeDirectory } from "./helpers.js"; +import { backend, operate, selectModel } from "./backends/adapter.js"; +import { V2Adapter, unwrapV2Error } from "./backends/v2.js"; +import { assertPromptOptions } from "./backends/v2-guards.js"; +import { validateFormValues } from "./backends/v2-forms.js"; +import { BackendCapabilityError, type BackendIdentity } from "./backends/contracts.js"; +import { initialV2Observation, observeV2Session, type V2ObservationState } from "./backends/v2-observation.js"; +import type { FormField } from "@opencode/client"; export type JobStatus = "accepted" | "running" | "input_required" | "completed" | "failed" | "cancelled" | "unknown"; export interface PendingInput { @@ -14,6 +21,8 @@ export interface PendingInput { [key: string]: unknown; } export interface JobSnapshot { + backend?: "v1" | "v2"; + backendIdentity?: BackendIdentity; jobId?: string; sessionId?: string; messageId?: string; @@ -45,6 +54,9 @@ export interface JobInputResponse { reply?: "once" | "always" | "reject"; reject?: boolean; message?: string; + sessionId?: string; + values?: Record; + scope?: "project" | "session"; } export interface JobServiceOptions { /** null creates a process-local store for isolated tests or ephemeral clients. */ @@ -54,28 +66,40 @@ export interface JobServiceOptions { directory?: string; ttlMs?: number; } -interface StoredJob extends JobSnapshot { +interface StoredJobBase extends JobSnapshot { jobId: string; createdAt: number; updatedAt: number; expiresAt: number; - version: 1; /** Persisted before sending each reply: an uncertain reply is never replayed. */ responses: Record; cancelRequested?: boolean; } +interface StoredV1Job extends StoredJobBase { version: 1 } +interface StoredV2Job extends StoredJobBase { + version: 2; + backendIdentity: BackendIdentity; + observation: V2ObservationState; + receipt?: { id: string; sessionID: string }; +} +type StoredJob = StoredV1Job | StoredV2Job; const terminal = (status: JobStatus) => ["completed", "failed", "cancelled"].includes(status); const hash = (value: string) => createHash("sha256").update(value).digest("hex"); const segment = (value: string) => encodeURIComponent(value); function publicSnapshot(job: StoredJob): JobSnapshot { const { version: _version, responses: _responses, cancelRequested: _cancelRequested, ...snapshot } = job; + delete (snapshot as { observation?: V2ObservationState }).observation; + delete (snapshot as { receipt?: unknown }).receipt; + snapshot.backend = job.version === 2 ? "v2" : "v1"; return structuredClone(snapshot); } function requestError(error: unknown): { name: string; message: string; status?: number } { // Persist operational diagnostics, never the submitted prompt or request body. if (error instanceof OpenCodeSubmissionError) return { name: error.name, message: "Submission outcome unknown; check this job before retrying." }; if (error instanceof OpenCodeError) return { name: error.name, message: "OpenCode rejected the request", status: error.status }; + if (error instanceof BackendCapabilityError) return { name: error.name, message: error.message, + ...{ code: error.code, capability: error.capability, backend: error.backend } }; return { name: error instanceof Error ? error.name : "Error", message: "The request could not be completed" }; } @@ -111,6 +135,20 @@ export async function observeSession( client: OpenCodeClient, sessionId: string, directory?: string, messageId?: string, options: RequestOptions = {}, ): Promise { if (!sessionId) throw new Error("sessionId is required"); + if (client.getBackendIdentity?.().kind === "v2") { + const adapter = backend(client) as V2Adapter; + const context = createRequestContext(options); + const request = { signal: context.signal, deadline: context.deadline }; + try { + if (directory) { + const session = await withAbort(adapter.api.session.get({ sessionID: sessionId }, request), context.signal); + if (session.location.directory !== directory) throw new Error("Session belongs to a different server directory"); + } + const target = messageId ?? (await withAbort(adapter.api.message.list({ sessionID: sessionId, type: "user", order: "desc", limit: 1 }, request), context.signal)).data[0]?.id; + if (!target) return { backend: "v2", sessionId, directory, status: "unknown", inputs: [] }; + return (await observeV2Session(adapter.api, sessionId, target, request)).snapshot; + } finally { context.dispose(); } + } directory = normalizeDirectory(directory); const context = createRequestContext(options); const request = { signal: context.signal, deadline: context.deadline }; @@ -223,6 +261,10 @@ export class JobService { this.store = options.storeRoot === null ? null : join(options.storeRoot ?? join(process.platform === "win32" ? (process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local")) : (process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state")), "opencode-mcp", "tasks"), scope); } + selectModel(providerID?: string, modelID?: string, newSession = false) { + return selectModel(this.client, providerID, modelID, newSession); + } + private checkId(jobId: string): void { if (!/^job_[a-f0-9]{32}$/.test(jobId)) throw new Error("Invalid job ID"); } @@ -236,14 +278,22 @@ export class JobService { this.checkId(jobId); let job: StoredJob; try { - job = this.store ? JSON.parse(await readFile(join(this.store, `${jobId}.json`), "utf8")) : structuredClone(this.memory.get(jobId)); + if (!this.store) job = structuredClone(this.memory.get(jobId))!; + else { + try { job = JSON.parse(await readFile(join(this.store, `${jobId}.json`), "utf8")); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + job = JSON.parse(await readFile(join(this.store, "v2", `${jobId}.json`), "utf8")); + } + } } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("Job not found in this server and credential scope"); throw error; } - if (!job || job.version !== 1 || job.jobId !== jobId) throw new Error("Job not found or invalid job record"); + if (!job || ![1, 2].includes(job.version) || job.jobId !== jobId || + (job.version === 2 && (job.backendIdentity?.kind !== "v2" || job.observation?.version !== 1))) throw new Error("Job not found or invalid job record"); if (job.expiresAt <= Date.now()) { - if (this.store) await rm(join(this.store, `${jobId}.json`), { force: true }); + if (this.store) await rm(join(this.recordDirectory(job), `${jobId}.json`), { force: true }); else this.memory.delete(jobId); throw new Error("Job expired; observe its OpenCode session directly if still available"); } @@ -253,7 +303,9 @@ export class JobService { job.updatedAt = Date.now(); if (!this.store) { this.memory.set(job.jobId, structuredClone(job)); return; } await this.prepare(); - const path = join(this.store, `${job.jobId}.json`); + const directory = this.recordDirectory(job); + if (job.version === 2) { await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700); } + const path = join(directory, `${job.jobId}.json`); const temporary = `${path}.${randomUUID()}.tmp`; const handle = await open(temporary, "wx", 0o600); try { @@ -267,6 +319,17 @@ export class JobService { } } + private recordDirectory(job: StoredJob): string { + return job.version === 2 ? join(this.store!, "v2") : this.store!; + } + private assertActiveBackend(job: StoredJob): void { + const kind = this.client.getBackendIdentity?.().kind ?? "v1"; + if (kind !== (job.version === 2 ? "v2" : "v1")) { + throw new Error(`Active job belongs to ${job.version === 2 ? "v2" : "v1"}; connect to its recorded backend to observe or control it`); + } + this.client.assertBackendAvailable?.(); + } + private async locked(jobId: string, options: RequestOptions, action: () => Promise): Promise { this.checkId(jobId); const operation = createRequestContext(options); @@ -347,10 +410,14 @@ export class JobService { async start(args: JobStartArgs, options: RequestOptions = {}): Promise { if (typeof args.prompt !== "string" || !args.prompt.trim()) throw new Error("prompt is required"); + const v2 = this.client.getBackendIdentity?.().kind === "v2"; + if (v2) assertPromptOptions(args); const directory = normalizeDirectory(args.directory ?? this.directory); const now = Date.now(); + const messageId = createMessageId(); const job: StoredJob = { - version: 1, jobId: `job_${randomUUID().replaceAll("-", "")}`, sessionId: args.sessionId, messageId: createMessageId(), + ...(v2 ? { version: 2 as const, backendIdentity: this.client.getBackendIdentity(), observation: initialV2Observation(messageId) } : { version: 1 as const }), + jobId: `job_${randomUUID().replaceAll("-", "")}`, sessionId: args.sessionId, messageId, directory, status: "unknown", createdAt: now, updatedAt: now, expiresAt: now + this.ttl, responses: {}, }; return this.locked(job.jobId, options, async () => { @@ -361,19 +428,31 @@ export class JobService { await this.save(job); // Record ownership before the first remote mutation. try { if (!job.sessionId) { - const session = await this.client.post<{ id: string }>("/session", { title: args.title ?? "MCP delegated task" }, request); + const session = v2 + ? await operate(this.client, "sessions.create", { ...request, body: { + title: args.title ?? "MCP delegated task", model: args.model, variant: args.variant, agent: args.agent, + } }) + : await this.client.post<{ id: string }>("/session", { title: args.title ?? "MCP delegated task" }, request); if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode returned no session ID"); job.sessionId = session.id; await this.save(job); } - await this.client.post(`/session/${segment(job.sessionId)}/prompt_async`, { - messageID: job.messageId, parts: [{ type: "text", text: args.prompt }], + const body = { + messageID: job.messageId, parts: [{ type: "text" as const, text: args.prompt }], ...(args.model ? { model: args.model } : {}), ...(args.variant ? { variant: args.variant } : {}), ...(args.agent ? { agent: args.agent } : {}), ...(args.format ? { format: args.format } : {}), - }, request); + }; + if (job.version === 2) { + const receipt = await operate(this.client, "messages.enqueue", { + ...request, sessionId: job.sessionId, body, + }); + if (receipt?.id !== job.messageId || receipt?.sessionID !== job.sessionId) throw new Error("V2 prompt receipt identity mismatch"); + job.receipt = { id: receipt.id, sessionID: receipt.sessionID }; + job.observation.admitted = true; job.observation.queued = true; + } else await this.client.post(`/session/${segment(job.sessionId)}/prompt_async`, body, request); job.status = "accepted"; } catch (error) { - job.status = error instanceof OpenCodeError ? "failed" : "unknown"; + job.status = error instanceof OpenCodeError || error instanceof BackendCapabilityError ? "failed" : "unknown"; job.error = requestError(error); } await this.save(job); @@ -384,6 +463,17 @@ export class JobService { private async refresh(job: StoredJob, options: RequestOptions): Promise { if (terminal(job.status) || !job.sessionId) return job; + this.assertActiveBackend(job); + if (job.version === 2) { + const { snapshot, state } = await observeV2Session((backend(this.client) as V2Adapter).api, job.sessionId, job.messageId!, options, job.observation); + // A persisted uncertain write stays unknown unless durable evidence has + // established admission or execution. Never dispatch from observation. + if (job.status === "unknown" && snapshot.status === "accepted" && !state.admitted) snapshot.status = "unknown"; + if (job.cancelRequested && !terminal(snapshot.status)) snapshot.status = "unknown"; + const updated: StoredV2Job = { ...job, ...snapshot, observation: state }; + if (snapshot.status !== "unknown" && !snapshot.error) delete updated.error; + await this.save(updated); return updated; + } const observed = await observeSession(this.client, job.sessionId, job.directory, job.messageId, options); // Absence of a turn cannot prove that an ambiguous submission failed. if (job.status === "unknown" && (observed.status === "accepted" || (job.cancelRequested && !terminal(observed.status)))) observed.status = "unknown"; @@ -401,7 +491,10 @@ export class JobService { async list(limit = 50): Promise { if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("limit must be an integer between 1 and 1000"); await this.prepare(); - const ids = this.store ? (await readdir(this.store)).filter((name) => /^job_[a-f0-9]{32}\.json$/.test(name)).map((name) => name.slice(0, -5)) : [...this.memory.keys()]; + const names = this.store ? [...await readdir(this.store), ...await readdir(join(this.store, "v2")).catch(error => { + if (error.code === "ENOENT") return [] as string[]; throw error; + })] : []; + const ids = this.store ? [...new Set(names.filter((name) => /^job_[a-f0-9]{32}\.json$/.test(name)).map((name) => name.slice(0, -5)))] : [...this.memory.keys()]; const values: JobSnapshot[] = []; for (const id of ids) { try { values.push(publicSnapshot(await this.read(id))); } @@ -418,6 +511,45 @@ export class JobService { job = await this.refresh(job, options); if (terminal(job.status)) return publicSnapshot(job); if (!job.sessionId) throw new Error("No known session to cancel"); + if (job.version === 2) { + const api = (backend(this.client) as V2Adapter).api; + const state = job.observation; + if (!state.synced || state.ownershipLost || (!state.queued && (!state.ownsExecution || !state.userProjectionValidated))) { + throw new Error("V2 turn ownership is not established; refusing to cancel unrelated work"); + } + if (job.cancelRequested) return publicSnapshot(job); + const context = createRequestContext(options); + const request = { signal: context.signal }; + try { + // All ownership/refusal reads precede the persisted write intent. + if (!state.queued) { + const inbox = await withAbort(api.session.inbox.list({ sessionID: job.sessionId }, request), context.signal); + if (inbox.some(item => item.id !== job.messageId)) throw new Error("Other queued work belongs to this session; refusing to interrupt it"); + } + throwIfAborted(context.signal); + job.cancelRequested = true; + await this.save(job); + try { + if (state.queued) { + await withAbort(api.session.inbox.cancel({ sessionID: job.sessionId, inboxID: job.messageId! }, request), context.signal); + } else { + const interrupted = await withAbort(api.session.interrupt({ sessionID: job.sessionId, resume: false }, request), context.signal); + if (!interrupted.interrupted) { + // A definite no-op is retryable after a fresh ownership check. + job.cancelRequested = false; + throw new Error("V2 did not confirm an interrupted execution"); + } + } + job.status = "cancelled"; job.observation.outcome = "cancelled"; + delete job.error; + } catch (rawError) { + const error = unwrapV2Error(rawError); + job.status = "unknown"; job.error = requestError(error); + if (error instanceof OpenCodeError || error instanceof BackendCapabilityError) job.cancelRequested = false; + } + await this.save(job); return publicSnapshot(job); + } finally { context.dispose(); } + } // OpenCode's abort endpoint is session-wide: refuse to abort a newer turn. const messages = await this.client.get(`/session/${segment(job.sessionId)}/message`, { limit: "100" }, job.directory, options); const users = messages.filter((message) => message?.info?.role === "user"); @@ -449,26 +581,45 @@ export class JobService { for (const response of responses) { if (!response || typeof response.id !== "string" || !["question", "permission"].includes(response.kind)) throw new Error("Invalid input response"); const key = `${response.kind}:${response.id}`; - const digest = hash(JSON.stringify({ id: response.id, kind: response.kind, answers: response.answers, reply: response.reply, reject: response.reject, message: response.message })); + const digest = hash(JSON.stringify({ id: response.id, kind: response.kind, answers: response.answers, reply: response.reply, reject: response.reject, message: response.message, + ...(job.version === 2 ? { values: response.values, scope: response.scope, sessionId: response.sessionId } : {}) })); const previous = job.responses[key]; if (previous) { if (previous.digest !== digest) throw new Error("This input already has a different submitted response"); if (previous.state !== "sent") throw new Error("Previous reply outcome is unknown; it cannot be safely resent"); continue; } - if (!job.inputs?.some((input) => input.id === response.id && input.kind === response.kind)) throw new Error("Input does not belong to this job's pending requests"); - if (response.kind === "permission" && (!["once", "always", "reject"].includes(response.reply ?? "") || response.answers !== undefined || response.reject !== undefined)) throw new Error("Permission response requires once, always, or reject without question answers"); - if (response.kind === "question" && (response.reply !== undefined || (response.reject === true && response.answers !== undefined))) throw new Error("Question response requires answers or reject=true, exclusively"); - if (response.kind === "question" && !response.reject && (!Array.isArray(response.answers) || !response.answers.every((answers) => Array.isArray(answers) && answers.every((answer) => typeof answer === "string")))) throw new Error("Question response requires an array of answer arrays"); + const pending = job.inputs?.find((input) => input.id === response.id && input.kind === response.kind); + if (!pending) throw new Error("Input does not belong to this job's pending requests"); + if (response.sessionId && response.sessionId !== job.sessionId) throw new Error("Input response session does not match this job"); + if (response.kind === "permission" && (!["once", "always", "reject"].includes(response.reply ?? "") || response.answers !== undefined || response.values !== undefined || response.reject !== undefined)) throw new Error("Permission response requires once, always, or reject without question answers"); + if (response.kind === "question" && (response.reply !== undefined || (response.reject === true && (response.answers !== undefined || response.values !== undefined)))) throw new Error("Question response requires answers or reject=true, exclusively"); + if (job.version === 2) { + if (response.kind === "permission" && response.reply === "always" && response.scope !== "project") throw new Error("V2 always approval requires explicit scope: project"); + if (response.kind === "permission" && response.reply === "reject" && response.scope !== "session") throw new Error("V2 reject requires explicit scope: session"); + if (response.kind === "question" && !response.reject) { + if (response.answers !== undefined) throw new Error("V2 job forms require typed field-keyed values"); + validateFormValues(pending.fields as FormField[], response.values); + } + } else { + if (response.values !== undefined || response.scope !== undefined) throw new Error("Typed form values and permission scope require a V2 backend"); + if (response.kind === "question" && !response.reject && (!Array.isArray(response.answers) || !response.answers.every((answers) => Array.isArray(answers) && answers.every((answer) => typeof answer === "string")))) throw new Error("Question response requires an array of answer arrays"); + } job.responses[key] = { digest, state: "sending" }; await this.save(job); try { const path = `/${response.kind}/${segment(response.id)}/${response.kind === "question" && response.reject ? "reject" : "reply"}`; const body = response.kind === "permission" ? { reply: response.reply, ...(response.message ? { message: response.message } : {}) } : response.reject ? undefined : { answers: response.answers }; - await this.client.post(path, body, { ...options, directory: job.directory }); + if (job.version === 2) { + await operate(this.client, response.kind === "permission" ? "permissions.reply" : response.reject ? "forms.reject" : "forms.reply", { + ...options, directory: job.directory, sessionId: job.sessionId, requestId: response.id, + body: response.kind === "permission" ? { reply: response.reply, scope: response.scope, message: response.message, sessionId: job.sessionId } + : { values: response.values, sessionId: job.sessionId }, + }); + } else await this.client.post(path, body, { ...options, directory: job.directory }); job.responses[key].state = "sent"; } catch (error) { - if (error instanceof OpenCodeError) delete job.responses[key]; + if (error instanceof OpenCodeError || error instanceof BackendCapabilityError) delete job.responses[key]; else job.responses[key].state = "unknown"; job.error = requestError(error); await this.save(job); diff --git a/src/mcp-server.ts b/src/mcp-server.ts index 1ef8c08..58b1cf5 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,3 +1,5 @@ +import { capabilityFor } from "./backends/capabilities.js"; +import type { BackendIdentity } from "./backends/contracts.js"; /** Shared registration contract. SDK v2 owns protocol validation and transport serving. */ import { McpServer as SdkServer, ResourceTemplate, type ServerContext, type ToolAnnotations, type ResourceMetadata, type ReadResourceCallback, type ReadResourceTemplateCallback, @@ -47,6 +49,7 @@ export function withStructuredText(result: CallToolResult): CallToolResult { export class McpServer extends SdkServer { profile: ToolProfile = "full"; + backendIdentity?: BackendIdentity; readonly catalog: Array<{ name: string; description: string; inputSchema: Record; outputSchema: Record; annotations: ToolAnnotations }> = []; @@ -62,6 +65,10 @@ export class McpServer extends SdkServer { this.catalog.push({ name, description, inputSchema: z.toJSONSchema(input), outputSchema: z.toJSONSchema(output), annotations }); return this.registerTool(name, { description, inputSchema: input, outputSchema: output, annotations }, async (args, ctx) => withRequestOptions({ signal: ctx.mcpReq.signal }, async () => { + const capability = capabilityFor(name); + if (this.backendIdentity?.kind === "v2" && capability && capability.v2 !== "supported") { + return withStructuredText({ isError: true, content: [{ type: "text", text: `${name} is unavailable on V2: ${capability.reason}` }], structuredContent: { error: { code: "UNSUPPORTED_CAPABILITY", capability: name, backend: "v2", message: capability.reason } } }); + } const result = await handler(args, { signal: ctx.mcpReq.signal, _meta: ctx.mcpReq._meta, mcpReq: ctx.mcpReq }); return result.resultType === "input_required" ? result as InputRequiredResult : withStructuredText(result as CallToolResult); })); diff --git a/src/prompts.ts b/src/prompts.ts index d48d48d..3759aed 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -188,7 +188,7 @@ Steps: ## Writing Useful Requests - State the expected behavior, constraints, and relevant tests. - Use optional variant values supported by the selected model. -- Use format: {type: "json_schema", schema: {...}} when a machine-readable model answer is needed. +- On V1, use format: {type: "json_schema", schema: {...}} when a machine-readable model answer is needed. V2 rejects schema guarantees and preserves model/agent settings on existing sessions. ## Common Pitfalls - Use an absolute directory on the OpenCode host, including for follow-ups. A session alone is not filesystem isolation; parallel edits should use separate projects or worktrees. diff --git a/src/resources.ts b/src/resources.ts index 7f596e6..f3ca273 100644 --- a/src/resources.ts +++ b/src/resources.ts @@ -1,3 +1,4 @@ +import { operate } from "./backends/adapter.js"; /** * MCP Resources — expose OpenCode data as browseable resources. * @@ -21,7 +22,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { }, async () => { try { - const project = await client.get("/project/current"); + const project = await operate(client, "projects.current", { }); return { contents: [ { @@ -54,7 +55,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const config = await client.get("/config"); + const config = await operate(client, "configuration.get", { }); return { contents: [ { @@ -77,7 +78,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const providers = await client.get("/provider"); + const providers = await operate(client, "providers.list", { }); return { contents: [ { @@ -99,7 +100,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const agents = await client.get("/agent"); + const agents = await operate(client, "configuration.agents", { }); return { contents: [ { @@ -121,7 +122,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const commands = await client.get("/command"); + const commands = await operate(client, "configuration.commands", { }); return { contents: [ { @@ -143,7 +144,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const health = await client.get("/global/health"); + const health = await operate(client, "lifecycle.health", { }); return { contents: [ { @@ -166,7 +167,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { }, async () => { try { - const vcs = await client.get("/vcs"); + const vcs = await operate(client, "files.vcs", { }); return { contents: [ { @@ -199,7 +200,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const sessions = await client.get("/session"); + const sessions = await operate(client, "sessions.list", { }); return { contents: [ { @@ -221,7 +222,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const mcp = await client.get("/mcp"); + const mcp = await operate(client, "configuration.mcp", { }); return { contents: [ { @@ -243,7 +244,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { mimeType: "application/json", }, async () => { - const status = await client.get("/file/status"); + const status = await operate(client, "files.status", { }); return { contents: [ { @@ -263,15 +264,15 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { return decodeURIComponent(value); }; for (const [name, suffix, endpoint] of [ - ["project-scoped", "current", "/project/current"], - ["sessions-scoped", "sessions", "/session"], + ["project-scoped", "current", "projects.current"], + ["sessions-scoped", "sessions", "sessions.list"], ] as const) { server.resource(name, new ResourceTemplate(`opencode://projects/{directory}/${suffix}`, { list: undefined }), { description: `Project-scoped ${suffix}; percent-encode the absolute server directory`, mimeType: "application/json" }, async (uri, variables) => { const directory = normalizeDirectory(scalar(variables.directory, "directory")); - const data = await client.get(endpoint, undefined, directory); + const data = await operate(client, endpoint, { directory }); return { contents: [{ uri: uri.href, mimeType: "application/json", text: safeStringify(data) }] }; }, ); @@ -286,7 +287,7 @@ export function registerResources(server: McpServer, client: OpenCodeClient) { async (uri, variables) => { const directory = normalizeDirectory(scalar(variables.directory, "directory")); const sessionId = scalar(variables.sessionId, "sessionId"); - const data = await client.get(`/session/${encodeURIComponent(sessionId)}${endpointSuffix}`, undefined, directory); + const data = await operate(client, endpointSuffix ? "messages.list" : "sessions.get", { sessionId, directory }); return { contents: [{ uri: uri.href, mimeType: "application/json", text: safeStringify(data) }] }; }, ); diff --git a/src/server-manager.ts b/src/server-manager.ts index 6902968..de2de65 100644 --- a/src/server-manager.ts +++ b/src/server-manager.ts @@ -11,6 +11,8 @@ export interface ServerManagerOptions { */ username?: string; password?: string; + /** Bound the complete startup to the caller's remaining budget. */ + timeoutMs?: number; } export interface ServerStatus { @@ -97,6 +99,7 @@ export async function isServerRunning( baseUrl: string, username?: string, password?: string, + timeoutMs = 3000, ): Promise<{ healthy: boolean; version?: string }> { try { const headers: Record = {}; @@ -107,8 +110,11 @@ export async function isServerRunning( const response = await fetch(`${baseUrl.replace(/\/$/, "")}/global/health`, { method: "GET", headers, - signal: AbortSignal.timeout(3000), + signal: AbortSignal.timeout(timeoutMs), }); + if (response.status === 401 || response.status === 403) { + throw new ServerAuthenticationError(response.status); + } if (!response.ok) return { healthy: false }; const res = await response.json() as any; if (res && typeof res === 'object' && 'healthy' in res) { @@ -118,15 +124,26 @@ export async function isServerRunning( }; } return { healthy: false }; - } catch { + } catch (error) { + if (error instanceof ServerAuthenticationError) throw error; return { healthy: false }; } } +export class ServerAuthenticationError extends Error { + constructor(readonly status: number) { + super("OpenCode server authentication failed; no alternate server will be started."); + this.name = "ServerAuthenticationError"; + } +} + export async function startServer( baseUrl: string, timeoutMs: number = 30000, + username?: string, + password?: string, ): Promise<{ url: string; version?: string }> { + const deadline = Date.now() + timeoutMs; const target = new URL(baseUrl); if (target.protocol !== "http:" || !["127.0.0.1", "localhost", "[::1]"].includes(target.hostname) || target.pathname !== "/") { throw new Error("Auto-start requires a local loopback HTTP URL without a path prefix. Start remote servers manually and set OPENCODE_BASE_URL."); @@ -149,15 +166,22 @@ export async function startServer( registerShutdownHandlers(); - // Note: managed (in-process) SDK servers do not enforce auth, so this probe - // is unauthenticated by design. External servers, when present, are probed - // with credentials from `ensureServer`. - const status = await isServerRunning(created.url); - return { url: created.url, version: status.version }; + // SDK children inherit environment authentication. Use the same identity + // and never retry without credentials. + try { + if (Date.now() >= deadline) throw new Error("OpenCode startup exceeded its time budget."); + const status = await isServerRunning(created.url, username, password, Math.min(3000, Math.max(1, deadline - Date.now()))); + if (!status.healthy) throw new Error("Started OpenCode server does not expose a healthy V1 contract."); + return { url: created.url, version: status.version }; + } catch (error) { + created.close(); + if (managedServer === created) managedServer = null; + throw error; + } } -export function stopServer(): void { - if (managedServer) { +export function stopServer(ownedUrl?: string): void { + if (managedServer && (ownedUrl === undefined || managedServer.url === ownedUrl)) { managedServer.close(); managedServer = null; } @@ -169,7 +193,8 @@ export async function ensureServer( const baseUrl = opts.baseUrl; const autoServe = opts.autoServe === true; - const existing = await isServerRunning(baseUrl, opts.username, opts.password); + const startedAt = Date.now(); + const existing = await isServerRunning(baseUrl, opts.username, opts.password, Math.min(3000, opts.timeoutMs ?? 3000)); if (existing.healthy) { console.error( `OpenCode server already running at ${baseUrl} (v${existing.version ?? "unknown"})`, @@ -197,7 +222,7 @@ export async function ensureServer( const startupKey = baseUrl.replace(/\/$/, ""); let inFlight = startServerInFlight.get(startupKey); if (!inFlight) { - inFlight = startServer(startupKey).finally(() => { + inFlight = startServer(startupKey, Math.max(1, (opts.timeoutMs ?? 30000) - (Date.now() - startedAt)), opts.username, opts.password).finally(() => { startServerInFlight.delete(startupKey); }); startServerInFlight.set(startupKey, inFlight); diff --git a/src/task-input.ts b/src/task-input.ts index 85158ee..725ed5d 100644 --- a/src/task-input.ts +++ b/src/task-input.ts @@ -1,3 +1,5 @@ +import { nativeFormSchema, validateFormValues, type FormValues } from "./backends/v2-forms.js"; +import type { FormField } from "@opencode/client"; /** Translate OpenCode requests into MCP form requests; every response is validated again. */ import { inputRequired, type InputRequests } from "@modelcontextprotocol/server"; import { z } from "zod"; @@ -11,18 +13,23 @@ export interface InputAnswer { id: string; kind: "question" | "permission"; answers?: string[][]; + values?: FormValues; + sessionId?: string; + scope?: "project" | "session"; reply?: "once" | "always" | "reject"; reject?: boolean; } export const inputAnswerSchema = z.object({ id: z.string().min(1), kind: z.enum(["question", "permission"]), answers: z.array(z.array(z.string())).optional(), + values: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), + sessionId: z.string().optional(), scope: z.enum(["project", "session"]).optional(), reply: z.enum(["once", "always", "reject"]).optional(), reject: z.boolean().optional(), }).superRefine((a, ctx) => { - if (a.kind === "permission" && (!a.reply || a.answers || a.reject !== undefined)) + if (a.kind === "permission" && (!a.reply || a.answers || a.values || a.reject !== undefined)) ctx.addIssue({ code: "custom", message: "Permission input requires reply only" }); - if (a.kind === "question" && ((a.answers === undefined) === (a.reject !== true) || a.reply)) - ctx.addIssue({ code: "custom", message: "Question input requires answers or reject=true, exclusively" }); + if (a.kind === "question" && ([a.answers !== undefined, a.values !== undefined, a.reject === true].filter(Boolean).length !== 1 || a.reply)) + ctx.addIssue({ code: "custom", message: "Question input requires answers, values, or reject=true, exclusively" }); }); function questions(item: PendingRequest): Array<{ question?: string; options?: Array<{ label: string }>; multiple?: boolean }> { @@ -32,8 +39,15 @@ export function inputRequests(items: PendingRequest[]): InputRequests { return Object.fromEntries(items.map(item => { const key = `${item.kind}:${item.id}`; if (item.kind === "permission") { - return [key, inputRequired.elicit({ message: `OpenCode requests ${String(item.permission ?? "permission")} for ${JSON.stringify(item.patterns ?? [])}`, - requestedSchema: { type: "object", properties: { decision: { type: "string", enum: ["once", "always", "reject"], description: "Choose explicitly; no approval is selected automatically" } }, required: ["decision"] } })]; + const v2 = item.backend === "v2"; + return [key, inputRequired.elicit({ message: `OpenCode requests ${String(item.permission ?? item.action ?? "permission")} for ${JSON.stringify(item.patterns ?? item.resources ?? [])}${v2 ? `. Always saves project-wide approval patterns ${JSON.stringify(item.always ?? item.save ?? [])}. Reject denies ALL pending requests in this session.` : ""}`, + requestedSchema: { type: "object", properties: { decision: { type: "string", enum: ["once", "always", "reject"], description: "Choose explicitly; no approval is selected automatically" }, + ...(v2 ? { scope: { type: "string" as const, enum: ["request", "project", "session"], description: "Explicit acknowledgment: once=request, always=project, reject=session" } } : {}) }, required: v2 ? ["decision", "scope"] : ["decision"] } })]; + } + if (item.backend === "v2") { + const schema = nativeFormSchema(item.fields as FormField[]); + if (!schema) throw new Error("This V2 form needs manual field-keyed values through opencode_job_input"); + return [key, inputRequired.elicit({ message: String(item.title ?? "OpenCode needs your input"), requestedSchema: schema as Parameters[0]["requestedSchema"] })]; } const qs = questions(item); const properties = Object.fromEntries(qs.map((q, index) => [`answer_${index}`, { @@ -52,10 +66,21 @@ export function decodeInputResponses(items: PendingRequest[], responses: Record< const raw = responses[`${item.kind}:${item.id}`]; if (raw === undefined) return []; const response = answer.parse(raw); - if (response.action !== "accept") return [{ id: item.id, kind: item.kind, - ...(item.kind === "permission" ? { reply: "reject" as const } : { reject: true }) }]; - if (item.kind === "permission") return [{ id: item.id, kind: item.kind, - reply: z.enum(["once", "always", "reject"]).parse(response.content?.decision) }]; + if (response.action !== "accept") { + // Dismissing a V2 native form never authorizes a session-wide permission rejection. + if (item.backend === "v2" && item.kind === "permission") return []; + return [{ id: item.id, kind: item.kind, ...(item.kind === "permission" ? { reply: "reject" as const } : { reject: true }) }]; + } + if (item.kind === "permission") { + const reply = z.enum(["once", "always", "reject"]).parse(response.content?.decision); + if (item.backend === "v2") { + const scope = response.content?.scope; + if (scope !== (reply === "always" ? "project" : reply === "reject" ? "session" : "request")) throw new Error("Explicit permission scope acknowledgment does not match the selected effect"); + return [{ id: item.id, kind: item.kind, reply, sessionId: String(item.sessionID), ...(scope === "project" || scope === "session" ? { scope } : {}) }]; + } + return [{ id: item.id, kind: item.kind, reply }]; + } + if (item.backend === "v2") return [{ id: item.id, kind: item.kind, sessionId: String(item.sessionID), values: validateFormValues(item.fields as FormField[], response.content) }]; const answers = questions(item).map((q, index) => { const value = z.string().parse(response.content?.[`answer_${index}`]); return q.multiple ? z.array(z.string()).parse(JSON.parse(value)) : [value]; @@ -68,3 +93,7 @@ export function supportsForm(capabilities: unknown): boolean { const caps = capabilities as { elicitation?: { form?: unknown } } | undefined; return !!caps?.elicitation && typeof caps.elicitation.form === "object" && caps.elicitation.form !== null; } + +export function canUseNativeForms(items: PendingRequest[]): boolean { + return items.every(item => item.backend !== "v2" || item.kind === "permission" || Array.isArray(item.fields) && nativeFormSchema(item.fields as FormField[]) !== undefined); +} diff --git a/src/task-transport.ts b/src/task-transport.ts index 0d65caa..d0003a1 100644 --- a/src/task-transport.ts +++ b/src/task-transport.ts @@ -1,3 +1,4 @@ +import { BackendCapabilityError } from "./backends/contracts.js"; /** July 2026 Tasks extension bridge. SDK v2 currently handles core MCP only. * Keep extension dispatch at the stdio boundary; all core traffic uses serveStdio. */ @@ -5,9 +6,9 @@ import type { Transport, JSONRPCMessage } from "@modelcontextprotocol/server"; import { z } from "zod"; import { JobService, type JobSnapshot } from "./jobs.js"; import { runInputSchema } from "./job-contract.js"; -import { applyModelDefaults, toolResult } from "./helpers.js"; +import { applyModelDefaults, toolResult, toolError } from "./helpers.js"; import { withStructuredText } from "./mcp-server.js"; -import { decodeInputResponses, inputRequests, supportsForm } from "./task-input.js"; +import { decodeInputResponses, inputRequests, supportsForm, canUseNativeForms } from "./task-input.js"; export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; const VERSION = "2026-07-28"; @@ -35,7 +36,10 @@ export function taskFromJob(job: JobSnapshot, detailed = false): Record; if (run) { const args = runInputSchema.parse(request.params?.arguments ?? {}); - const job = await this.jobs.start({ ...args, model: applyModelDefaults(args.providerID, args.modelID) }, { signal: controller.signal }); + const job = await this.jobs.start({ ...args, model: this.jobs.selectModel(args.providerID, args.modelID, !args.sessionId) }, { signal: controller.signal }); result = taskFromJob(job); } else { const { taskId } = z.object({ taskId: z.string().min(1) }).parse(request.params); if (request.method === "tasks/get") { const job = await this.jobs.get(taskId, { signal: controller.signal }); - if (job.status === "input_required" && !supportsForm(caps)) { + if (job.status === "input_required" && !supportsForm(caps) && canUseNativeForms(job.inputs ?? [])) { await fail(-32021, "Pending input requires form elicitation", { requiredCapabilities: { elicitation: { form: {} } } }); return; } @@ -130,6 +134,10 @@ export class TaskTransport implements Transport { } await this.send({ jsonrpc: "2.0", id, result }); } catch (error) { + if (run && error instanceof BackendCapabilityError) { + await this.send({ jsonrpc: "2.0", id, result: { resultType: "complete", ...withStructuredText(toolError(error)) } }); + return; + } await fail(error instanceof z.ZodError ? -32602 : -32603, error instanceof z.ZodError ? "Invalid task parameters" : error instanceof Error ? error.message : "Task operation failed"); } finally { this.active.delete(id); } diff --git a/src/tools/config.ts b/src/tools/config.ts index 16c9651..ca38333 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; @@ -12,9 +13,10 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const config = await client.get("/config", undefined, directory); + const config = await operate(client, "configuration.get", { directory: directory }); const c = redactSecrets(config) as Record; + if (client.getBackendIdentity?.().kind === "v2") return toolResult(`## V2 Configuration Sources\nThese ordered sources are not an effective merged configuration.\n${safeStringify(c)}`, false, { data: c }); // Build a compact summary instead of dumping entire config const lines: string[] = []; for (const [k, v] of Object.entries(c)) { @@ -63,7 +65,7 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { }, async ({ config, directory }) => { try { - return toolJson(redactSecrets(await client.patch("/config", config, directory))); + return toolJson(redactSecrets(await operate(client, "configuration.update", { body: config, directory: directory }))); } catch (e) { return toolError(e); } @@ -78,7 +80,7 @@ export function registerConfigTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/config/providers", undefined, directory); + const raw = await operate(client, "providers.configured", { directory: directory }); const wrapper = raw as Record; // API returns { providers: [...], default: { providerId: modelId, ... } } diff --git a/src/tools/events.ts b/src/tools/events.ts index 1250df5..e823fbe 100644 --- a/src/tools/events.ts +++ b/src/tools/events.ts @@ -1,3 +1,4 @@ +import { events as backendEvents } from "../backends/adapter.js"; /** * Event streaming tools — subscribe to real-time events from OpenCode. */ @@ -37,7 +38,7 @@ export function registerEventTools( const context = createRequestContext({ signal: extra?.signal, timeout: durationMs ?? 3000 }); let streamError: unknown; try { - for await (const evt of client.subscribeSSE(scope === "global" ? "/global/event" : "/event", { + for await (const evt of backendEvents(client, scope ?? "project", { signal: context.signal, deadline: context.deadline, directory, })) { events.push(evt); @@ -46,9 +47,9 @@ export function registerEventTools( } catch (error) { // Only the polling window expiring normally is a successful empty // observation. Caller cancellation and connection errors stay visible. - if (!(context.signal.aborted && context.signal.reason?.name === "TimeoutError" && !extra?.signal?.aborted)) { - streamError = error; - } + const timeout = error instanceof Error && error.name === "TimeoutError"; + const localDeadline = context.signal.aborted && context.signal.reason?.name === "TimeoutError" || timeout && Date.now() >= context.deadline; + if (!localDeadline || extra?.signal?.aborted) streamError = error; } finally { context.dispose(); } diff --git a/src/tools/file.ts b/src/tools/file.ts index 004fb12..9275ade 100644 --- a/src/tools/file.ts +++ b/src/tools/file.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; @@ -16,7 +17,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { readOnly, async ({ pattern, directory }) => { try { - const raw = await client.get("/find", { pattern }, directory); + const raw = await operate(client, "files.findText", { query: { pattern }, directory: directory }); const results = Array.isArray(raw) ? raw as Array> : []; if (results.length === 0) { return toolResult(`No matches found for pattern: ${pattern}`, false, { data: [] }); @@ -69,7 +70,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { if (type) q.type = type; if (searchDirectory) q.directory = searchDirectory; if (limit !== undefined) q.limit = String(limit); - const files = (await client.get("/find/file", q, directory)) as string[]; + const files = (await operate(client, "files.find", { query: q, directory: directory })) as string[]; if (!files || files.length === 0) { return toolResult(`No files found matching: ${query}`, false, { data: [] }); } @@ -90,7 +91,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { readOnly, async ({ query, directory }) => { try { - const raw = await client.get("/find/symbol", { query }, directory); + const raw = await operate(client, "files.findSymbol", { query: { query }, directory: directory }); const symbols = Array.isArray(raw) ? raw as Array> : []; if (symbols.length === 0) { return toolResult(`No symbols found matching: ${query}`, false, { data: [] }); @@ -128,7 +129,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { async ({ path, directory }) => { try { const q: Record = { path: path || "." }; - const nodes = (await client.get("/file", q, directory)) as Array>; + const nodes = (await operate(client, "files.list", { query: q, directory: directory })) as Array>; if (!nodes || nodes.length === 0) { return toolResult("Empty directory."); } @@ -153,7 +154,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { readOnly, async ({ path, directory }) => { try { - const result = (await client.get("/file/content", { path }, directory)) as Record; + const result = (await operate(client, "files.read", { query: { path }, directory: directory })) as Record; if (typeof result.content === "string") { return toolResult(`File: ${path}\n\n${result.content}`, false, { data: result }); } @@ -173,7 +174,7 @@ export function registerFileTools(server: McpServer, client: OpenCodeClient) { readOnly, async ({ directory }) => { try { - const files = (await client.get("/file/status", undefined, directory)) as Array>; + const files = (await operate(client, "files.status", { directory: directory })) as Array>; if (!files || files.length === 0) { return toolResult("No tracked file changes."); } diff --git a/src/tools/global.ts b/src/tools/global.ts index 0d8eaf8..52ada03 100644 --- a/src/tools/global.ts +++ b/src/tools/global.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolResult, toolError, directoryParam, readOnly } from "../helpers.js"; @@ -12,7 +13,7 @@ export function registerGlobalTools(server: McpServer, client: OpenCodeClient) { readOnly, async ({ directory }) => { try { - const raw = await client.get("/global/health", undefined, directory) as Record; + const raw = await operate(client, "lifecycle.health", { directory: directory }) as Record; const status = raw.healthy ? "healthy" : "unhealthy"; const version = raw.version ?? "unknown"; return toolResult(`Status: ${status}\nVersion: ${version}`); diff --git a/src/tools/input.ts b/src/tools/input.ts index 9e81f90..d6fa0b4 100644 --- a/src/tools/input.ts +++ b/src/tools/input.ts @@ -1,32 +1,34 @@ +import { operate } from "../backends/adapter.js"; import { inputRequired } from "@modelcontextprotocol/server"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { JobService } from "../jobs.js"; -import { directoryParam, normalizeDirectory, toolJson, toolResult } from "../helpers.js"; -import { decodeInputResponses, inputAnswerSchema, inputRequests, supportsForm } from "../task-input.js"; +import { directoryParam, normalizeDirectory, toolJson, toolResult, toolError } from "../helpers.js"; +import { decodeInputResponses, inputAnswerSchema, inputRequests, supportsForm, canUseNativeForms } from "../task-input.js"; -const errorResult = (error: unknown) => toolResult(error instanceof Error ? error.message : String(error), true); +const errorResult = toolError; export function registerInputTools(server: McpServer, client: OpenCodeClient, jobs: JobService) { server.tool("opencode_question_list", "List pending OpenCode questions, optionally filtered to a session.", { sessionId: z.string().optional(), directory: directoryParam, }, async ({ sessionId, directory }) => { try { - const questions = await client.get("/question", undefined, normalizeDirectory(directory)); + const questions = await operate(client, "forms.list", { directory: normalizeDirectory(directory) }); return toolJson(sessionId ? questions.filter(q => q.sessionID === sessionId) : questions); } catch (error) { return errorResult(error); } }); - server.tool("opencode_question_reply", "Answer an OpenCode question request. Supply one array of selected labels or free text per question.", { - requestId: z.string().min(1), answers: z.array(z.array(z.string())), directory: directoryParam, - }, async ({ requestId, answers, directory }) => { - try { return toolJson(await client.post(`/question/${encodeURIComponent(requestId)}/reply`, { answers }, { directory: normalizeDirectory(directory) })); } + server.tool("opencode_question_reply", "Answer an OpenCode question request. V1 accepts answers arrays. V2 accepts typed field-keyed values matching the returned form; include sessionId to identify the session.", { + requestId: z.string().min(1), sessionId: z.string().optional(), answers: z.array(z.array(z.string())).optional(), + values: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), directory: directoryParam, + }, async ({ requestId, sessionId, answers, values, directory }) => { + try { return toolJson(await operate(client, "forms.reply", { sessionId, requestId: requestId, body: { ...(answers ? { answers } : {}), ...(values ? { values } : {}) }, ...({ directory: normalizeDirectory(directory) }) })); } catch (error) { return errorResult(error); } }); server.tool("opencode_question_reject", "Reject a pending OpenCode question explicitly.", { - requestId: z.string().min(1), directory: directoryParam, - }, async ({ requestId, directory }) => { - try { return toolJson(await client.post(`/question/${encodeURIComponent(requestId)}/reject`, undefined, { directory: normalizeDirectory(directory) })); } + requestId: z.string().min(1), sessionId: z.string().optional(), directory: directoryParam, + }, async ({ requestId, sessionId, directory }) => { + try { return toolJson(await operate(client, "forms.reject", { sessionId, requestId: requestId, ...({ directory: normalizeDirectory(directory) }) })); } catch (error) { return errorResult(error); } }); for (const action of ["get", "cancel"] as const) { @@ -54,8 +56,8 @@ export function registerInputTools(server: McpServer, client: OpenCodeClient, jo if (input?.length) job = await jobs.update(jobId, input, options); if (job.status === "input_required" && job.inputs?.length) { const envelope = extra?.mcpReq?.envelope as Record | undefined; - if (supportsForm(envelope?.["io.modelcontextprotocol/clientCapabilities"])) return inputRequired({ inputRequests: inputRequests(job.inputs) }); - return toolResult("OpenCode needs input. Call opencode_job_input with explicit responses for the pending IDs.", false, { ...job }); + if (supportsForm(envelope?.["io.modelcontextprotocol/clientCapabilities"]) && canUseNativeForms(job.inputs)) return inputRequired({ inputRequests: inputRequests(job.inputs) }); + return toolResult("OpenCode needs input. Call opencode_job_input with explicit responses for the pending IDs. V2 forms require field-keyed typed values matching the returned fields; always approval requires scope=project, rejection requires scope=session.", false, { ...job }); } return toolResult(job.text ?? `Job ${jobId}: ${job.status}`, job.status === "failed", { ...job }); } catch (error) { return errorResult(error); } diff --git a/src/tools/message.ts b/src/tools/message.ts index d15e517..b6019f6 100644 --- a/src/tools/message.ts +++ b/src/tools/message.ts @@ -1,3 +1,4 @@ +import { operate, selectModel } from "../backends/adapter.js"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; @@ -26,17 +27,15 @@ export function registerMessageTools( .number() .optional() .describe("Maximum number of messages to return"), + cursor: z.string().optional().describe("V2 continuation cursor"), directory: directoryParam, }, - async ({ sessionId, limit, directory }) => { + async ({ sessionId, limit, cursor, directory }) => { try { const query: Record = {}; if (limit !== undefined) query.limit = String(limit); - const messages = await client.get( - `/session/${sessionId}/message`, - query, - directory, - ); + if (cursor) query.cursor = cursor; + const messages = await operate(client, "messages.list", { sessionId: sessionId, query: query, directory: directory }); return toolResult(formatMessageList(messages as unknown[]), false, { data: messages }); } catch (e) { return toolError(e); @@ -54,11 +53,7 @@ export function registerMessageTools( }, async ({ sessionId, messageId, directory }) => { try { - const msg = await client.get( - `/session/${sessionId}/message/${messageId}`, - undefined, - directory, - ); + const msg = await operate(client, "messages.get", { sessionId: sessionId, messageId: messageId, directory: directory }); return toolResult(formatMessageResponse(msg), false, { data: msg }); } catch (e) { return toolError(e); @@ -108,18 +103,14 @@ export function registerMessageTools( const body: Record = { parts: [{ type: "text", text }], }; - const model = applyModelDefaults(providerID, modelID); + const model = selectModel(client, providerID, modelID); if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; + if (variant || client.getBackendIdentity?.().kind === "v2" && variant !== undefined) body.variant = variant; + if (agent || client.getBackendIdentity?.().kind === "v2" && agent !== undefined) body.agent = agent; if (noReply !== undefined) body.noReply = noReply; if (system) body.system = system; if (format) body.format = format; - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory }, - ); + const response = await operate(client, "messages.send", { sessionId: sessionId, body: body, ...({ directory }) }); const analysis = analyzeMessageResponse(response); const formatted = formatMessageResponse(response); @@ -165,12 +156,12 @@ export function registerMessageTools( messageID: messageId, parts: [{ type: "text", text }], }; - const model = applyModelDefaults(providerID, modelID); + const model = selectModel(client, providerID, modelID); if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; + if (variant || client.getBackendIdentity?.().kind === "v2" && variant !== undefined) body.variant = variant; + if (agent || client.getBackendIdentity?.().kind === "v2" && agent !== undefined) body.agent = agent; if (format) body.format = format; - await client.post(`/session/${sessionId}/prompt_async`, body, { directory }); + await operate(client, "messages.enqueue", { sessionId: sessionId, body: body, ...({ directory }) }); return toolResult( `Message sent asynchronously. Use opencode_wait({sessionId: "${sessionId}", messageId: "${messageId}"}) to observe this exact turn.`, false, @@ -215,16 +206,12 @@ export function registerMessageTools( command, arguments: args ?? "", }; - if (agent) body.agent = agent; - const cmdModel = applyModelDefaults(providerID, modelID); + if (agent || client.getBackendIdentity?.().kind === "v2" && agent !== undefined) body.agent = agent; + const cmdModel = selectModel(client, providerID, modelID); if (cmdModel) body.model = `${cmdModel.providerID}/${cmdModel.modelID}`; - if (variant) body.variant = variant; - const result = await client.post( - `/session/${sessionId}/command`, - body, - { directory }, - ); - return toolResult(formatMessageResponse(result), false, { data: result }); + if (variant || client.getBackendIdentity?.().kind === "v2" && variant !== undefined) body.variant = variant; + const result = await operate(client, "messages.command", { sessionId: sessionId, body: body, ...({ directory }) }); + return toolResult(formatMessageResponse(result), (result as { isError?: boolean })?.isError === true, { data: result }); } catch (e) { return toolError(e); } @@ -245,14 +232,10 @@ export function registerMessageTools( async ({ sessionId, command, agent, providerID, modelID, directory }) => { try { const body: Record = { command, agent }; - const shellModel = applyModelDefaults(providerID, modelID); + const shellModel = selectModel(client, providerID, modelID); if (shellModel) body.model = shellModel; - const result = await client.post( - `/session/${sessionId}/shell`, - body, - { directory }, - ); - return toolResult(formatMessageResponse(result), false, { data: result }); + const result = await operate(client, "messages.shell", { sessionId: sessionId, body: body, ...({ directory }) }); + return toolResult(formatMessageResponse(result), (result as { isError?: boolean })?.isError === true, { data: result }); } catch (e) { return toolError(e); } diff --git a/src/tools/misc.ts b/src/tools/misc.ts index 557b759..6c8f798 100644 --- a/src/tools/misc.ts +++ b/src/tools/misc.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; @@ -14,7 +15,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/path", undefined, directory); + const raw = await operate(client, "files.paths", { directory: directory }); const p = raw as Record; if (p && typeof p === "object" && !Array.isArray(p)) { const labels: Record = { @@ -49,7 +50,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/vcs", undefined, directory); + const raw = await operate(client, "files.vcs", { directory: directory }); const v = raw as Record; if (!v || typeof v !== "object") { return toolResult("No VCS info available."); @@ -86,7 +87,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { destructive, async ({ directory }) => { try { - await client.post("/instance/dispose", undefined, { directory }); + await operate(client, "lifecycle.dispose", { ...({ directory }) }); return toolResult("Instance disposed."); } catch (e) { return toolError(e); @@ -104,7 +105,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const agents = (await client.get("/agent", undefined, directory)) as Array>; + const agents = (await operate(client, "configuration.agents", { directory: directory })) as Array>; if (!agents || agents.length === 0) { return toolResult("No agents found."); } @@ -131,7 +132,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/command", undefined, directory); + const raw = await operate(client, "configuration.commands", { directory: directory }); const commands = Array.isArray(raw) ? raw as Array> : []; if (commands.length === 0) { return toolResult("No commands available."); @@ -160,7 +161,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/lsp", undefined, directory); + const raw = await operate(client, "lifecycle.lsp", { directory: directory }); const servers = Array.isArray(raw) ? raw as Array> : []; if (servers.length === 0) { return toolResult("No LSP servers running."); @@ -188,7 +189,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/formatter", undefined, directory); + const raw = await operate(client, "lifecycle.formatter", { directory: directory }); const formatters = Array.isArray(raw) ? raw as Array> : []; if (formatters.length === 0) { return toolResult("No formatters configured."); @@ -227,7 +228,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/mcp", undefined, directory); + const raw = await operate(client, "configuration.mcp", { directory: directory }); // MCP status may be an object keyed by server name or an array if (raw && typeof raw === "object" && !Array.isArray(raw)) { const entries = Object.entries(raw as Record); @@ -272,7 +273,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ name, config, directory }) => { try { - return toolJson(await client.post("/mcp", { name, config }, { directory })); + return toolJson(await operate(client, "configuration.mcpAdd", { body: { name, config }, ...({ directory }) })); } catch (e) { return toolError(e); } @@ -289,7 +290,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - const raw = await client.get("/experimental/tool/ids", undefined, directory); + const raw = await operate(client, "lifecycle.toolIds", { directory: directory }); const ids = Array.isArray(raw) ? raw as string[] : []; if (ids.length === 0) { return toolResult("No tools available."); @@ -311,7 +312,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { }, async ({ provider, model, directory }) => { try { - const raw = await client.get("/experimental/tool", { provider, model }, directory); + const raw = await operate(client, "lifecycle.tools", { query: { provider, model }, directory: directory }); const tools = Array.isArray(raw) ? raw as Array> : []; if (tools.length === 0) { return toolResult("No tools available for this provider/model."); @@ -353,7 +354,7 @@ export function registerMiscTools(server: McpServer, client: OpenCodeClient) { try { const body: Record = { service, level, message }; if (extra) body.extra = extra; - await client.post("/log", body, { directory }); + await operate(client, "lifecycle.log", { body: body, ...({ directory }) }); return toolResult(`Log entry written [${level}] ${service}: ${message}`); } catch (e) { return toolError(e); diff --git a/src/tools/project.ts b/src/tools/project.ts index 035ebde..b26761b 100644 --- a/src/tools/project.ts +++ b/src/tools/project.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; import { toolJson, toolError, toolResult, directoryParam } from "../helpers.js"; @@ -95,7 +96,7 @@ export function registerProjectTools( }, async ({ directory }) => { try { - const raw = await client.get("/project", undefined, directory); + const raw = await operate(client, "projects.list", { directory: directory }); const projects = Array.isArray(raw) ? raw as Array> : []; if (projects.length === 0) { return toolResult("No projects found."); @@ -183,7 +184,7 @@ export function registerProjectTools( // ── Register with OpenCode server (best-effort) ───────────── try { - await client.get("/project/current", undefined, realPath); + await operate(client, "projects.current", { directory: realPath }); } catch (e) { console.error( `opencode_project_init: project ping failed for ${realPath}: ${e instanceof Error ? e.message : String(e)}`, @@ -207,7 +208,7 @@ export function registerProjectTools( }, async ({ directory }) => { try { - const raw = await client.get("/project/current", undefined, directory); + const raw = await operate(client, "projects.current", { directory: directory }); const p = raw as Record; if (p && typeof p === "object") { return toolResult(formatProject(p)); diff --git a/src/tools/provider.ts b/src/tools/provider.ts index 10f5ad3..0c35033 100644 --- a/src/tools/provider.ts +++ b/src/tools/provider.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; import { OpenCodeClient } from "../client.js"; @@ -15,7 +16,7 @@ export function registerProviderTools( }, async ({ directory }) => { try { - const raw = await client.get("/provider", undefined, directory); + const raw = await operate(client, "providers.list", { directory: directory }); const providers = ( raw && typeof raw === "object" && "all" in (raw as Record) ? (raw as Record).all @@ -88,7 +89,7 @@ export function registerProviderTools( }, async ({ providerId, limit, directory }) => { try { - const raw = await client.get("/provider", undefined, directory); + const raw = await operate(client, "providers.list", { directory: directory }); const providers = ( raw && typeof raw === "object" && "all" in (raw as Record) ? (raw as Record).all @@ -157,7 +158,7 @@ export function registerProviderTools( }, async ({ directory }) => { try { - const raw = await client.get("/provider/auth", undefined, directory); + const raw = await operate(client, "providers.authMethods", { directory: directory }); if (raw && typeof raw === "object" && !Array.isArray(raw)) { const entries = Object.entries(raw as Record); if (entries.length === 0) { @@ -190,12 +191,14 @@ export function registerProviderTools( { providerId: z.string().describe("Provider ID to authorize"), method: z.number().int().nonnegative().default(0).describe("Auth method index from opencode_provider_auth_methods (default 0)"), + integrationId: z.string().optional(), methodId: z.string().optional(), + values: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.array(z.string())])).optional(), inputs: z.record(z.string(), z.string()).optional().describe("Additional inputs requested by the selected authentication method"), }, - async ({ providerId, method, inputs }) => { + async ({ providerId, method, inputs, integrationId, methodId, values }) => { try { return toolJson( - await client.post(`/provider/${encodeURIComponent(providerId)}/oauth/authorize`, { method: method ?? 0, ...(inputs ? { inputs } : {}) }), + await operate(client, "providers.authorize", { providerId: providerId, body: { method: method ?? 0, ...(inputs ? { inputs } : {}), ...(integrationId ? { integrationId } : {}), ...(methodId ? { methodId } : {}), ...(values ? { values } : {}) } }), ); } catch (e) { return toolError(e); @@ -209,16 +212,15 @@ export function registerProviderTools( { providerId: z.string().describe("Provider ID"), callbackData: z.object({ - method: z.number().int().nonnegative().describe("The method index used for authorization"), + attemptId: z.string().optional().describe("V2 exact attemptId from authorization"), + integrationId: z.string().optional(), + method: z.number().int().nonnegative().optional().describe("V1 method index used for authorization; V2 uses the exact attemptId"), code: z.string().optional().describe("Authorization code for methods that request one"), }).describe("OAuth callback data"), }, async ({ providerId, callbackData }) => { try { - await client.post( - `/provider/${encodeURIComponent(providerId)}/oauth/callback`, - callbackData, - ); + await operate(client, "providers.callback", { providerId: providerId, body: callbackData }); return toolResult("OAuth callback processed."); } catch (e) { return toolError(e); @@ -236,7 +238,7 @@ export function registerProviderTools( }, async ({ providerId, type, key }) => { try { - await client.put(`/auth/${providerId}`, { type, key }); + await operate(client, "providers.setAuth", { providerId: providerId, body: { type, key } }); return toolResult(`Auth credentials set for ${providerId}.`); } catch (e) { return toolError(e); diff --git a/src/tools/session.ts b/src/tools/session.ts index 2c69d21..8dcf8a6 100644 --- a/src/tools/session.ts +++ b/src/tools/session.ts @@ -1,6 +1,8 @@ +import { BackendCapabilityError } from "../backends/contracts.js"; +import { operate, selectModel } from "../backends/adapter.js"; import { z } from "zod"; import { McpServer } from "../mcp-server.js"; -import { OpenCodeClient } from "../client.js"; +import { OpenCodeClient, OpenCodeError } from "../client.js"; import { toolError, formatSessionList, formatDiffResponse, resolveSessionStatus, toolResult, directoryParam, destructive, readOnly } from "../helpers.js"; /** Format a single session object into a compact human-readable summary. */ @@ -45,12 +47,13 @@ export function registerSessionTools( "opencode_session_list", "List all sessions", { + limit: z.number().int().positive().optional(), cursor: z.string().optional().describe("V2 continuation cursor"), directory: directoryParam, }, readOnly, - async ({ directory }) => { + async ({ directory, limit, cursor }) => { try { - const sessions = (await client.get("/session", undefined, directory)) as Array>; + const sessions = (await operate(client, "sessions.list", { directory, ...(limit !== undefined || cursor ? { query: { ...(limit !== undefined ? { limit: String(limit) } : {}), ...(cursor ? { cursor } : {}) } } : {}) })) as Array>; return toolResult(formatSessionList(sessions), false, { data: sessions }); } catch (e) { return toolError(e); @@ -64,14 +67,20 @@ export function registerSessionTools( { parentID: z.string().optional().describe("Parent session ID"), title: z.string().optional().describe("Session title"), + providerID: z.string().optional(), modelID: z.string().optional(), variant: z.string().optional(), agent: z.string().optional(), directory: directoryParam, }, - async ({ parentID, title, directory }) => { + async ({ parentID, title, providerID, modelID, variant, agent, directory }) => { try { - const body: Record = {}; - if (parentID) body.parentID = parentID; + const body: Record = {}; + if (client.getBackendIdentity?.().kind === "v2") { + const model = selectModel(client, providerID, modelID, true); + if (model) body.model = model; + if (variant !== undefined) body.variant = variant; if (agent !== undefined) body.agent = agent; + } else if (providerID || modelID || variant || agent) throw new BackendCapabilityError("session_create.selection", "V1 selects model and agent when prompting; these creation fields apply to V2.", "v1"); + if (parentID || client.getBackendIdentity?.().kind === "v2" && parentID !== undefined) body.parentID = parentID; if (title) body.title = title; - const session = await client.post("/session", body, { directory }) as Record; + const session = await operate(client, "sessions.create", { body: body, ...({ directory }) }) as Record; if (typeof session?.id !== "string") throw new Error("OpenCode returned a session without an ID"); return toolResult(`Session created.\n\n${formatSession(session)}`, false, { sessionId: session.id, session }); } catch (e) { @@ -90,7 +99,7 @@ export function registerSessionTools( readOnly, async ({ id, directory }) => { try { - const session = await client.get(`/session/${id}`, undefined, directory); + const session = await operate(client, "sessions.get", { sessionId: id, directory: directory }); return toolResult(formatSession(session), false, { data: session }); } catch (e) { return toolError(e); @@ -108,7 +117,7 @@ export function registerSessionTools( destructive, async ({ id, directory }) => { try { - await client.delete(`/session/${id}`, undefined, directory); + await operate(client, "sessions.remove", { sessionId: id, directory: directory }); return toolResult(`Session ${id} deleted.`); } catch (e) { return toolError(e); @@ -128,7 +137,7 @@ export function registerSessionTools( try { const body: Record = {}; if (title !== undefined) body.title = title; - const updated = await client.patch(`/session/${id}`, body, directory); + const updated = await operate(client, "sessions.update", { sessionId: id, body: body, directory: directory }); return toolResult(formatSession(updated)); } catch (e) { return toolError(e); @@ -146,7 +155,7 @@ export function registerSessionTools( readOnly, async ({ id, directory }) => { try { - const children = (await client.get(`/session/${id}/children`, undefined, directory)) as unknown[]; + const children = (await operate(client, "sessions.children", { sessionId: id, directory: directory })) as unknown[]; if (!children || !Array.isArray(children) || children.length === 0) { return toolResult("No child sessions found."); } @@ -166,7 +175,7 @@ export function registerSessionTools( readOnly, async ({ directory }) => { try { - const raw = await client.get("/session/status", undefined, directory); + const raw = await operate(client, "sessions.status", { directory: directory }); const statuses = raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Record : {}; @@ -192,7 +201,7 @@ export function registerSessionTools( readOnly, async ({ id, directory }) => { try { - const raw = await client.get(`/session/${id}/todo`, undefined, directory); + const raw = await operate(client, "sessions.todo", { sessionId: id, directory: directory }); const todos = Array.isArray(raw) ? raw as Array> : []; if (todos.length === 0) { return toolResult("No todos for this session."); @@ -225,7 +234,7 @@ export function registerSessionTools( }, async ({ id, messageID, providerID, modelID, variant, directory }) => { try { - await client.post(`/session/${id}/init`, { messageID, providerID, modelID, variant }, { directory }); + await operate(client, "sessions.init", { sessionId: id, body: { messageID, providerID, modelID, variant }, ...({ directory }) }); return toolResult("AGENTS.md initialization started."); } catch (e) { return toolError(e); @@ -242,8 +251,8 @@ export function registerSessionTools( }, async ({ id, directory }) => { try { - await client.post(`/session/${id}/abort`, undefined, { directory }); - return toolResult(`Session ${id} aborted.`); + const result = await operate(client, "sessions.abort", { sessionId: id, ...({ directory }) }); + return toolResult(client.getBackendIdentity?.().kind === "v2" ? `Interrupt requested for session ${id}.` : `Session ${id} aborted.`, false, { data: result }); } catch (e) { return toolError(e); } @@ -261,9 +270,9 @@ export function registerSessionTools( async ({ id, messageID, directory }) => { try { const body: Record = {}; - if (messageID) body.messageID = messageID; - const forked = await client.post(`/session/${id}/fork`, body, { directory }); - return toolResult(`Session forked.\n\n${formatSession(forked)}`); + if (messageID || client.getBackendIdentity?.().kind === "v2" && messageID !== undefined) body.messageID = messageID; + const forked = await operate(client, "sessions.fork", { sessionId: id, body: body, ...({ directory }) }); + return toolResult(`Session forked.${client.getBackendIdentity?.().kind === "v2" && messageID ? ` Boundary: before ${messageID} (excluded).` : ""}\n\n${formatSession(forked)}`, false, { data: forked }); } catch (e) { return toolError(e); } @@ -279,7 +288,7 @@ export function registerSessionTools( }, async ({ id, directory }) => { try { - const result = await client.post(`/session/${id}/share`, undefined, { directory }); + const result = await operate(client, "sessions.share", { sessionId: id, ...({ directory }) }); const r = result as Record; // API may return share URL in different locations const shareUrl = r.shareUrl ?? (r.share as Record | undefined)?.url ?? null; @@ -300,7 +309,7 @@ export function registerSessionTools( }, async ({ id, directory }) => { try { - await client.delete(`/session/${id}/share`, undefined, directory); + await operate(client, "sessions.unshare", { sessionId: id, directory: directory }); return toolResult(`Session ${id} unshared.`); } catch (e) { return toolError(e); @@ -314,14 +323,17 @@ export function registerSessionTools( { id: z.string().describe("Session ID"), messageID: z.string().optional().describe("Message ID (optional)"), + from: z.string().optional().describe("V2 first message in an explicit diff range"), + to: z.string().optional().describe("V2 last message in an explicit diff range"), directory: directoryParam, }, - async ({ id, messageID, directory }) => { + async ({ id, messageID, from, to, directory }) => { try { const query: Record = {}; if (messageID) query.messageID = messageID; - const diffs = await client.get(`/session/${id}/diff`, query, directory); - return toolResult(formatDiffResponse(diffs as unknown[])); + if (from) query.from = from; if (to) query.to = to; + const diffs = await operate(client, "sessions.diff", { sessionId: id, query: query, directory: directory }); + return toolResult(formatDiffResponse(diffs as unknown[]), false, { data: diffs }); } catch (e) { return toolError(e); } @@ -340,7 +352,7 @@ export function registerSessionTools( }, async ({ id, providerID, modelID, variant, directory }) => { try { - await client.post(`/session/${id}/summarize`, { providerID, modelID, variant }, { directory }); + await operate(client, "sessions.compact", { sessionId: id, body: { providerID, modelID, variant }, ...({ directory }) }); return toolResult("Session summarization started."); } catch (e) { return toolError(e); @@ -360,9 +372,9 @@ export function registerSessionTools( async ({ id, messageID, partID, directory }) => { try { const body: Record = { messageID }; - if (partID) body.partID = partID; - await client.post(`/session/${id}/revert`, body, { directory }); - return toolResult(`Message ${messageID} reverted.`); + if (partID || client.getBackendIdentity?.().kind === "v2" && partID !== undefined) body.partID = partID; + const result = await operate(client, "sessions.revert", { sessionId: id, body: body, ...({ directory }) }); + return toolResult(client.getBackendIdentity?.().kind === "v2" ? `Revert staged at message ${messageID}; reversible and not committed. Use session_unrevert to clear it.` : `Message ${messageID} reverted.`, false, { data: result }); } catch (e) { return toolError(e); } @@ -378,7 +390,7 @@ export function registerSessionTools( }, async ({ id, directory }) => { try { - await client.post(`/session/${id}/unrevert`, undefined, { directory }); + await operate(client, "sessions.unrevert", { sessionId: id, ...({ directory }) }); return toolResult("All reverted messages restored."); } catch (e) { return toolError(e); @@ -396,7 +408,7 @@ export function registerSessionTools( readOnly, async ({ directory }) => { try { - const requests = (await client.get("/permission", undefined, directory)) as Array>; + const requests = (await operate(client, "permissions.list", { directory: directory })) as Array>; if (!requests || !Array.isArray(requests) || requests.length === 0) { return toolResult("No pending permission requests."); } @@ -414,13 +426,14 @@ export function registerSessionTools( // Show what "always" would approve const always = Array.isArray(r.always) ? (r.always as string[]).join(", ") : ""; if (always) line += `\n Always would approve: ${always}`; + if (r.backend === "v2") line += "\n Always requires scope=project and saves approvals for this project. Reject requires scope=session and rejects ALL pending permissions in the session."; return line; }); return toolResult( `## Pending Permission Requests (${requests.length})\n\n` + lines.join("\n\n") + - `\n\nRespond with: \`opencode_session_permission({id: "SESSION_ID", permissionID: "PERM_ID", reply: "once"|"always"|"reject"})\`` + `\n\nRespond with: \`opencode_session_permission({id: "SESSION_ID", permissionID: "PERM_ID", reply: "once"|"always"|"reject"})\``, false, { data: requests } ); } catch (e) { return toolError(e); @@ -431,23 +444,25 @@ export function registerSessionTools( // ─── Permission: respond ────────────────────────────────────────────── server.tool( "opencode_session_permission", - "Respond to a permission request in a session. Use `opencode_permission_list` to see pending requests. Reply values: 'once' (approve this request only), 'always' (approve this + future matching requests for this session), 'reject' (deny the request).", + "Respond to a permission request in a session. Use `opencode_permission_list` to see pending requests. Reply values: 'once' (approve this request only), 'always' (V1 session behavior; V2 project-wide saved approval requiring scope=project), 'reject' (V2 denies ALL pending session requests requiring scope=session).", { id: z.string().describe("Session ID"), permissionID: z.string().describe("Permission request ID"), reply: z.enum(["once", "always", "reject"]).describe("Response to the permission request: 'once' to approve once, 'always' to auto-approve matching future requests, 'reject' to deny"), + scope: z.enum(["project", "session"]).optional().describe("V2 explicit acknowledgment: always applies to the project; reject denies all pending session requests"), directory: directoryParam, }, - async ({ id, permissionID, reply, directory }) => { + async ({ id, permissionID, reply, scope, directory }) => { try { // Try the new API first (POST /permission/{requestID}/reply) try { - await client.post(`/permission/${permissionID}/reply`, { reply }, { directory }); - return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).`); - } catch { + await operate(client, "permissions.reply", { sessionId: id, requestId: permissionID, body: { reply, ...(scope ? { scope } : {}) }, ...({ directory }) }); + return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).${client.getBackendIdentity?.().kind === "v2" ? reply === "always" ? " Saved approval applies to the project." : reply === "reject" ? " All pending permissions in this session were rejected." : " Applies to this request." : ""}`); + } catch (error) { + if (client.getBackendIdentity?.().kind === "v2" || !(error instanceof OpenCodeError) || ![404, 405].includes(error.status)) throw error; // Fall back to the deprecated session-scoped endpoint - await client.post(`/session/${id}/permissions/${permissionID}`, { response: reply }, { directory }); - return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).`); + await operate(client, "permissions.legacyReply", { sessionId: id, requestId: permissionID, body: { response: reply }, ...({ directory }) }); + return toolResult(`Permission ${reply === "reject" ? "rejected" : "approved"} (${reply}).${client.getBackendIdentity?.().kind === "v2" ? reply === "always" ? " Saved approval applies to the project." : reply === "reject" ? " All pending permissions in this session were rejected." : " Applies to this request." : ""}`); } } catch (e) { return toolError(e); @@ -466,7 +481,7 @@ export function registerSessionTools( readOnly, async ({ query, directory }) => { try { - const sessions = (await client.get("/session", undefined, directory)) as Array>; + const sessions = (await operate(client, "sessions.list", { directory: directory })) as Array>; if (!sessions || sessions.length === 0) { return toolResult("No sessions found.", false, { data: [] }); } diff --git a/src/tools/tui.ts b/src/tools/tui.ts index f06a925..774ca09 100644 --- a/src/tools/tui.ts +++ b/src/tools/tui.ts @@ -1,3 +1,4 @@ +import { operate } from "../backends/adapter.js"; /** * TUI control tools — drive the OpenCode TUI remotely. * Useful for IDE integrations and automation. @@ -18,7 +19,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ text, directory }) => { try { - await client.post("/tui/append-prompt", { text }, { directory }); + await operate(client, "tui.append-prompt", { body: { text }, ...({ directory }) }); return toolResult("Text appended to prompt."); } catch (e) { return toolError(e); @@ -34,7 +35,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - await client.post("/tui/submit-prompt", undefined, { directory }); + await operate(client, "tui.submit-prompt", { ...({ directory }) }); return toolResult("Prompt submitted."); } catch (e) { return toolError(e); @@ -50,7 +51,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - await client.post("/tui/clear-prompt", undefined, { directory }); + await operate(client, "tui.clear-prompt", { ...({ directory }) }); return toolResult("Prompt cleared."); } catch (e) { return toolError(e); @@ -67,7 +68,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ command, directory }) => { try { - await client.post("/tui/execute-command", { command }, { directory }); + await operate(client, "tui.execute-command", { body: { command }, ...({ directory }) }); return toolResult(`Command '${command}' executed.`); } catch (e) { return toolError(e); @@ -92,7 +93,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { const body: Record = { message }; if (title) body.title = title; if (variant) body.variant = variant; - await client.post("/tui/show-toast", body, { directory }); + await operate(client, "tui.show-toast", { body: body, ...({ directory }) }); return toolResult("Toast shown."); } catch (e) { return toolError(e); @@ -108,7 +109,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - await client.post("/tui/open-help", undefined, { directory }); + await operate(client, "tui.open-help", { ...({ directory }) }); return toolResult("Help dialog opened."); } catch (e) { return toolError(e); @@ -124,7 +125,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - await client.post("/tui/open-sessions", undefined, { directory }); + await operate(client, "tui.open-sessions", { ...({ directory }) }); return toolResult("Session selector opened."); } catch (e) { return toolError(e); @@ -140,7 +141,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - await client.post("/tui/open-models", undefined, { directory }); + await operate(client, "tui.open-models", { ...({ directory }) }); return toolResult("Model selector opened."); } catch (e) { return toolError(e); @@ -156,7 +157,7 @@ export function registerTuiTools(server: McpServer, client: OpenCodeClient) { }, async ({ directory }) => { try { - await client.post("/tui/open-themes", undefined, { directory }); + await operate(client, "tui.open-themes", { ...({ directory }) }); return toolResult("Theme selector opened."); } catch (e) { return toolError(e); diff --git a/src/tools/workflow.ts b/src/tools/workflow.ts index dfa2a0c..f53cb6a 100644 --- a/src/tools/workflow.ts +++ b/src/tools/workflow.ts @@ -1,3 +1,6 @@ +import { capabilities } from "../backends/capabilities.js"; +import { assertPromptOptions } from "../backends/v2-guards.js"; +import { operate, selectModel } from "../backends/adapter.js"; /** * High-level workflow tools — composite operations that make it easy * for an LLM to accomplish common tasks in a single call. @@ -26,6 +29,11 @@ import { outputFormatParam, } from "../helpers.js"; +function jobFailure(error: unknown, snapshot: Partial) { + const failure = toolError(error); + return { ...failure, structuredContent: { ...snapshot, status: "failed", ...failure.structuredContent } }; +} + function jobResult(snapshot: JobSnapshot, note = "", extra: Record = {}) { const lines = [snapshot.directory ? `Directory: ${snapshot.directory}` : "", snapshot.sessionId ? `Session: ${snapshot.sessionId}` : "", snapshot.jobId ? `Job: ${snapshot.jobId}` : "", `Status: ${snapshot.status}`].filter(Boolean); @@ -81,11 +89,14 @@ export function registerWorkflowTools( async ({ directory }) => { try { const sections: string[] = []; + const identity = client.getBackendIdentity?.(); + const supportedCapabilities = capabilities.filter(capability => !identity || identity.kind === "v1" || capability.v2 === "supported").map(capability => capability.name); + if (identity) sections.push(identity.resolved === false ? "## Connection\nBackend unresolved; restart after the configured endpoint is available." : `## Connection\nBackend: ${identity.kind} ${identity.version ?? "unknown"}\nSource: ${identity.connectionSource}\nProcess ownership: ${identity.processOwnership}\nWork survives MCP disconnect: ${identity.survivesDisconnect ? "yes, while the backend stays alive" : "no, MCP owns the child"}`); // 1. Health check let healthy = false; try { - const health = (await client.get("/global/health", undefined, directory)) as Record; + const health = (await operate(client, "lifecycle.health", { directory: directory })) as Record; healthy = true; sections.push( `## Server\nStatus: healthy\nVersion: ${health.version ?? "unknown"}`, @@ -97,12 +108,12 @@ export function registerWorkflowTools( } if (!healthy) { - return toolResult(sections.join("\n\n")); + return toolResult(sections.join("\n\n"), false, { backend: identity?.resolved === false ? { resolved: false } : identity, supportedCapabilities }); } // 2. Providers — categorize by readiness try { - const raw = await client.get("/provider", undefined, directory); + const raw = await operate(client, "providers.list", { directory: directory }); const providers = ( raw && typeof raw === "object" && "all" in (raw as Record) ? (raw as Record).all @@ -113,7 +124,7 @@ export function registerWorkflowTools( // Fetch auth methods for richer guidance let authMethods: Record | null = null; try { - authMethods = (await client.get("/provider/auth", undefined, directory)) as Record; + authMethods = (await operate(client, "providers.authMethods", { directory: directory })) as Record; } catch { /* non-critical */ } // Helper to count models @@ -211,7 +222,7 @@ export function registerWorkflowTools( // 3. Project info (if directory given or from default) try { - const project = (await client.get("/project/current", undefined, directory)) as Record; + const project = (await operate(client, "projects.current", { directory: directory })) as Record; const worktree = (project.worktree ?? "unknown") as string; // Derive a readable name: prefer project.name, then last dir component from worktree, then id const name = project.name @@ -257,7 +268,7 @@ export function registerWorkflowTools( } sections.push(`## Next Steps\n${tips.join("\n")}`); - return toolResult(sections.join("\n\n")); + return toolResult(sections.join("\n\n"), false, { backend: identity?.resolved === false ? { resolved: false } : identity, supportedCapabilities }); } catch (e) { return toolError(e); } @@ -297,10 +308,12 @@ export function registerWorkflowTools( async ({ prompt, title, providerID, modelID, variant, agent, system, format, directory }, extra) => { let sessionId: string | undefined; try { + if (client.getBackendIdentity?.().kind === "v2") assertPromptOptions({ system, format }); // 1. Create session - const session = (await client.post("/session", { + const session = (await operate(client, "sessions.create", { body: { title: title ?? prompt.slice(0, 80), - }, { directory, ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; + ...(client.getBackendIdentity?.().kind === "v2" ? { model: selectModel(client, providerID, modelID, true), variant, agent } : {}), + }, ...({ directory, ...(extra?.signal ? { signal: extra.signal } : {}) }) })) as Record; sessionId = session.id as string; if (!sessionId) throw new Error("OpenCode returned a session without an ID"); @@ -310,16 +323,12 @@ export function registerWorkflowTools( }; const model = applyModelDefaults(providerID, modelID); if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; + if (variant || client.getBackendIdentity?.().kind === "v2" && variant !== undefined) body.variant = variant; + if (agent || client.getBackendIdentity?.().kind === "v2" && agent !== undefined) body.agent = agent; if (system) body.system = system; if (format) body.format = format; - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, - ); + const response = await operate(client, "messages.send", { sessionId: sessionId, body: body, ...({ directory, ...(extra?.signal ? { signal: extra.signal } : {}) }) }); // 3. Analyze for auth / empty response issues const analysis = analyzeMessageResponse(response); @@ -335,7 +344,7 @@ export function registerWorkflowTools( return toolResult(parts.join("\n\n"), analysis.hasError, { sessionId, directory, data: response }); } catch (e) { const result = toolError(e); - return { ...result, content: [{ type: "text" as const, text: `${sessionId ? `Session: ${sessionId}\n` : ""}${result.content[0].text}` }], structuredContent: { sessionId, directory } }; + return { ...result, content: [{ type: "text" as const, text: `${sessionId ? `Session: ${sessionId}\n` : ""}${result.content[0].text}` }], structuredContent: { ...result.structuredContent, sessionId, directory } }; } }, ); @@ -359,17 +368,13 @@ export function registerWorkflowTools( const body: Record = { parts: [{ type: "text", text: prompt }], }; - const model = applyModelDefaults(providerID, modelID); + const model = selectModel(client, providerID, modelID); if (model) body.model = model; - if (variant) body.variant = variant; - if (agent) body.agent = agent; + if (variant || client.getBackendIdentity?.().kind === "v2" && variant !== undefined) body.variant = variant; + if (agent || client.getBackendIdentity?.().kind === "v2" && agent !== undefined) body.agent = agent; if (format) body.format = format; - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, - ); + const response = await operate(client, "messages.send", { sessionId: sessionId, body: body, ...({ directory, ...(extra?.signal ? { signal: extra.signal } : {}) }) }); const analysis = analyzeMessageResponse(response); const formatted = formatMessageResponse(response); @@ -389,7 +394,7 @@ export function registerWorkflowTools( ); } catch (e) { const result = toolError(e); - return { ...result, content: [{ type: "text" as const, text: `Session: ${sessionId}\n${result.content[0].text}` }], structuredContent: { sessionId, directory } }; + return { ...result, content: [{ type: "text" as const, text: `Session: ${sessionId}\n${result.content[0].text}` }], structuredContent: { ...result.structuredContent, sessionId, directory } }; } }, ); @@ -404,18 +409,16 @@ export function registerWorkflowTools( .number() .optional() .describe("Max messages to return (default: all)"), + cursor: z.string().optional().describe("V2 continuation cursor"), directory: directoryParam, }, readOnly, - async ({ sessionId, limit, directory }) => { + async ({ sessionId, limit, cursor, directory }) => { try { const query: Record = {}; if (limit !== undefined) query.limit = String(limit); - const messages = await client.get( - `/session/${sessionId}/message`, - query, - directory, - ); + if (cursor) query.cursor = cursor; + const messages = await operate(client, "messages.list", { sessionId: sessionId, query: query, directory: directory }); const formatted = formatMessageList( messages as unknown[], ); @@ -437,8 +440,8 @@ export function registerWorkflowTools( async ({ directory }) => { try { const [sessions, statuses] = await Promise.all([ - client.get("/session", undefined, directory) as Promise>>, - client.get("/session/status", undefined, directory) as Promise>, + operate(client, "sessions.list", { directory: directory }) as Promise>>, + operate(client, "sessions.status", { directory: directory }) as Promise>, ]); if (!sessions || sessions.length === 0) { @@ -478,11 +481,11 @@ export function registerWorkflowTools( directory = normalizeDirectory(directory) as typeof directory; const [project, path, vcs, config, agents] = await Promise.all([ - client.get("/project/current", undefined, directory).catch(() => null), - client.get("/path", undefined, directory).catch(() => null), - client.get("/vcs", undefined, directory).catch(() => null), - client.get("/config", undefined, directory).catch(() => null), - client.get("/agent", undefined, directory).catch(() => null), + operate(client, "projects.current", { directory: directory }).catch(() => null), + operate(client, "files.paths", { directory: directory }).catch(() => null), + operate(client, "files.vcs", { directory: directory }).catch(() => null), + operate(client, "configuration.get", { directory: directory }).catch(() => null), + operate(client, "configuration.agents", { directory: directory }).catch(() => null), ]); const sections: string[] = []; @@ -587,16 +590,19 @@ export function registerWorkflowTools( .string() .optional() .describe("Specific message ID to get diff for"), + from: z.string().optional().describe("V2 first message in an explicit diff range"), + to: z.string().optional().describe("V2 last message in an explicit diff range"), directory: directoryParam, }, readOnly, - async ({ sessionId, messageID, directory }) => { + async ({ sessionId, messageID, from, to, directory }) => { try { const query: Record = {}; if (messageID) query.messageID = messageID; - const diffs = await client.get(`/session/${sessionId}/diff`, query, directory); + if (from) query.from = from; if (to) query.to = to; + const diffs = await operate(client, "sessions.diff", { sessionId: sessionId, query: query, directory: directory }); const { formatDiffResponse } = await import("../helpers.js"); - return toolResult(formatDiffResponse(diffs as unknown[])); + return toolResult(formatDiffResponse(diffs as unknown[]), false, { data: diffs }); } catch (e) { return toolError(e); } @@ -618,7 +624,7 @@ export function registerWorkflowTools( let sessionId: string | null = null; try { if (!modelID) { - const raw = await client.get("/provider", undefined, directory) as Record; + const raw = await operate(client, "providers.list", { directory: directory }) as Record; const providers = Array.isArray(raw) ? raw : Array.isArray(raw?.all) ? raw.all : []; const provider = providers.find((p: Record) => p.id === providerId); const models = provider?.models; @@ -629,10 +635,12 @@ export function registerWorkflowTools( modelID = preferred && choices.includes(preferred) ? preferred : choices[0]; if (!modelID) throw new Error(`No model discovered for provider "${providerId}". Specify modelID from opencode_provider_models.`); } + if (client.getBackendIdentity?.().kind === "v2") assertPromptOptions({ format }); // 1. Create a temporary test session - const session = (await client.post("/session", { + const session = (await operate(client, "sessions.create", { body: { title: `[test] ${providerId}`, - }, { directory, ...(extra?.signal ? { signal: extra.signal } : {}) })) as Record; + ...(client.getBackendIdentity?.().kind === "v2" ? { model: { providerID: providerId, modelID }, variant } : {}), + }, ...({ directory, ...(extra?.signal ? { signal: extra.signal } : {}) }) })) as Record; sessionId = session.id as string; // 2. Send a trivial prompt @@ -640,14 +648,10 @@ export function registerWorkflowTools( parts: [{ type: "text", text: "Say hello in one word." }], }; body.model = { providerID: providerId, modelID }; - if (variant) body.variant = variant; + if (variant || client.getBackendIdentity?.().kind === "v2" && variant !== undefined) body.variant = variant; if (format) body.format = format; - const response = await client.post( - `/session/${sessionId}/message`, - body, - { directory, ...(extra?.signal ? { signal: extra.signal } : {}) }, - ); + const response = await operate(client, "messages.send", { sessionId: sessionId, body: body, ...({ directory, ...(extra?.signal ? { signal: extra.signal } : {}) }) }); // 3. Analyze the response const analysis = analyzeMessageResponse(response); @@ -655,7 +659,7 @@ export function registerWorkflowTools( // 4. Cleanup — delete test session try { - await client.delete(`/session/${sessionId}`, undefined, directory); + await operate(client, "sessions.remove", { sessionId: sessionId, directory: directory }); } catch { /* best-effort cleanup */ } if (analysis.hasError || analysis.isEmpty) { @@ -677,7 +681,7 @@ export function registerWorkflowTools( // Cleanup on error if (sessionId) { try { - await client.delete(`/session/${sessionId}`, undefined, directory); + await operate(client, "sessions.remove", { sessionId: sessionId, directory: directory }); } catch { /* best-effort cleanup */ } } return { ...toolError(e), structuredContent: { sessionId, directory, providerId, modelID } }; @@ -696,13 +700,13 @@ export function registerWorkflowTools( const context = createRequestContext({ signal: extra?.signal, timeout: seconds > 0 ? seconds * 1000 : 120000 }); try { snapshot = await service.start({ prompt, sessionId, title, directory, - model: applyModelDefaults(providerID, modelID), variant, agent, format }, context); + model: selectModel(client, providerID, modelID, !sessionId), variant, agent, format }, context); if (["failed", "unknown", "cancelled", "input_required"].includes(snapshot.status)) return jobResult(snapshot); const remaining = seconds === 0 ? 0 : Math.max(0, (context.deadline - Date.now()) / 1000); return await waitForSnapshot(options => service.get(snapshot.jobId!, options), snapshot, remaining, 1000, extra); } catch (error) { if (context.signal.aborted) return jobResult(snapshot, "Observation ended before dispatch could be confirmed. Check the session before retrying.", { timedOut: !extra?.signal?.aborted, observationCancelled: extra?.signal?.aborted === true }); - return jobResult({ ...snapshot, status: "failed", error: error instanceof Error ? error.message : String(error) }); + return jobFailure(error, snapshot); } finally { context.dispose(); } @@ -716,11 +720,11 @@ export function registerWorkflowTools( async ({ prompt, sessionId, title, providerID, modelID, variant, agent, format, directory }, extra) => { try { const snapshot = await service.start({ prompt, sessionId, title, directory, - model: applyModelDefaults(providerID, modelID), variant, agent, format }, { signal: extra?.signal }); + model: selectModel(client, providerID, modelID, !sessionId), variant, agent, format }, { signal: extra?.signal }); return jobResult(snapshot, snapshot.status === "accepted" ? `Task dispatched. Use opencode_check({jobId: "${snapshot.jobId}"}) or opencode_wait({jobId: "${snapshot.jobId}"}) to monitor it.` : ""); } catch (error) { - return jobResult({ sessionId: sessionId ?? "", directory, status: "failed", error: error instanceof Error ? error.message : String(error) }); + return jobFailure(error, { sessionId: sessionId ?? "", directory }); } }, ); @@ -746,7 +750,7 @@ export function registerWorkflowTools( const sid = snapshot.sessionId; if (!sid) return jobResult(snapshot, "Session creation could not be confirmed. Inspect this job before retrying."); const project = snapshot.directory ?? directory; - const todos = await client.get(`/session/${sid}/todo`, undefined, project, options).catch(() => null) as Array> | null; + const todos = await operate(client, "sessions.todo", { sessionId: sid, directory: project, ...(options) }).catch(() => null) as Array> | null; const session = snapshot.session as { title?: string; summary?: { files?: number } } | undefined; const lines: string[] = []; if (session?.title) lines.push(`Title: ${session.title}`); @@ -784,10 +788,10 @@ export function registerWorkflowTools( directory = normalizeDirectory(directory) as typeof directory; const [health, providerRaw, sessions, vcs] = await Promise.all([ - client.get("/global/health", undefined, directory).catch(() => null), - client.get("/provider", undefined, directory).catch(() => null), - client.get("/session", undefined, directory).catch(() => null), - client.get("/vcs", undefined, directory).catch(() => null), + operate(client, "lifecycle.health", { directory: directory }).catch(() => null), + operate(client, "providers.list", { directory: directory }).catch(() => null), + operate(client, "sessions.list", { directory: directory }).catch(() => null), + operate(client, "files.vcs", { directory: directory }).catch(() => null), ]); const lines: string[] = []; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..b2c9d7e --- /dev/null +++ b/src/version.ts @@ -0,0 +1,3 @@ +import { readFileSync } from "node:fs"; +/** One release identity for CLI, diagnostics and MCP initialization. */ +export const packageVersion: string = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; diff --git a/tests/backend-detection.test.ts b/tests/backend-detection.test.ts new file mode 100644 index 0000000..e99a053 --- /dev/null +++ b/tests/backend-detection.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { BackendDetectionError, DEFAULT_BASE_URL, detectBackend, readBackendConfig } from "../src/backend-detection.js"; + +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +const info = { version: "2.0.6", pid: 42, urls: ["http://127.0.0.1:4100"], paths: { tmp: "/tmp" } }; +const config = (values: NodeJS.ProcessEnv = {}) => readBackendConfig(values); +const absent = () => Promise.resolve(undefined); + +describe("backend configuration", () => { + it.each([{ OPENCODE_BACKEND: "v3" }, { OPENCODE_TOOL_PROFILE: "invalid" }, { OPENCODE_AUTO_SERVE: "1" }, { OPENCODE_DEFAULT_MODEL: "one" }, + { OPENCODE_BASE_URL: "https://user:secret@example.test" }, { OPENCODE_BASE_URL: "https://example.test?token=secret" }, { OPENCODE_BASE_URL: "file:///tmp" }, { OPENCODE_BASE_URL: "invalid" }])("rejects invalid config without echoing values %j", values => { + expect(() => config(values)).toThrow(BackendDetectionError); + try { config(values); } catch (error) { expect(String(error)).not.toContain("secret"); } + }); + it("keeps explicit URL authoritative and preserves prefix", () => { + expect(config({ OPENCODE_BASE_URL: "http://host.test/prefix/" }).baseUrl).toBe("http://host.test/prefix"); + expect(config()).toMatchObject({ backend: "auto", autoServe: false, profile: "full", baseUrl: undefined }); + }); +}); + +describe("backend detection", () => { + it("identifies V1 at the explicit URL without local discovery or executable", async () => { + const fetch = vi.fn(async () => response({ healthy: true, version: "1.18.31" })); + const discover = vi.fn(absent), registration = vi.fn(absent), start = vi.fn(); + const result = await detectBackend(config({ OPENCODE_BASE_URL: "https://remote.test/prefix" }), {}, { fetch, discover, registration, start }); + expect(result.identity).toEqual({ kind: "v1", version: "1.18.31", connectionSource: "explicit", processOwnership: "external", survivesDisconnect: true }); + expect(fetch.mock.calls[0][0]).toBe("https://remote.test/prefix/global/health"); + expect(discover).not.toHaveBeenCalled(); expect(registration).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); + }); + it("recognizes V2 server.info after a rejected V1 health contract", async () => { + const fetch = vi.fn(async (url: any) => response(String(url).endsWith("/api/info") ? info : { error: "not found" }, String(url).endsWith("/api/info") ? 200 : 404)); + const result = await detectBackend(config({ OPENCODE_BASE_URL: "http://remote.test" }), {}, { fetch }); + expect(result.identity.kind).toBe("v2"); expect(fetch).toHaveBeenCalledTimes(2); + }); + it.each(["v1", "v2"] as const)("forced %s validates only that contract", async backend => { + const fetch = vi.fn(async () => response(backend === "v1" ? info : { healthy: true, version: "1.18.31" })); + const start = vi.fn(); + await expect(detectBackend(config({ OPENCODE_BACKEND: backend, OPENCODE_BASE_URL: "http://host.test", OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch, start })).rejects.toMatchObject({ code: "incompatible" }); + expect(fetch).toHaveBeenCalledTimes(1); expect(start).not.toHaveBeenCalled(); + }); + it.each([401, 403])("stops on HTTP %i without alternate probe, discovery, or startup", async status => { + const fetch = vi.fn(async () => response({ secret: "secret-sentinel" }, status)); + const discover = vi.fn(absent), registration = vi.fn(absent), start = vi.fn(); + await expect(detectBackend(config({ OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch, discover, registration, start })).rejects.toMatchObject({ code: "authentication", status }); + expect(fetch).toHaveBeenCalledTimes(1); expect(discover).not.toHaveBeenCalled(); expect(registration).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); + }); + it("never discovers another endpoint after an explicit endpoint fails", async () => { + const fetch = vi.fn(async () => { throw new Error("offline"); }), discover = vi.fn(absent), registration = vi.fn(absent); + await expect(detectBackend(config({ OPENCODE_BASE_URL: "http://host.test" }), {}, { fetch, discover, registration })).rejects.toMatchObject({ code: "unavailable" }); + expect(discover).not.toHaveBeenCalled(); expect(registration).not.toHaveBeenCalled(); + }); + it("preflights a service registration and selects via read-only SDK discovery", async () => { + const fetch = vi.fn(async (url: any) => { if (String(url).startsWith(DEFAULT_BASE_URL)) throw new Error("offline"); return response(info); }); + const discover = vi.fn(async () => ({ url: "http://127.0.0.1:4100", auth: { type: "basic" as const, username: "opencode", password: "fixture-only" } })); + const registration = vi.fn(async () => ({ url: "http://127.0.0.1:4100", pid: 42, version: "2.0.6", password: "fixture-only" })); + const start = vi.fn(); + const result = await detectBackend(config({ OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch, discover, registration, start }); + expect(result.identity).toMatchObject({ kind: "v2", connectionSource: "discovered", processOwnership: "shared", survivesDisconnect: true }); + expect(discover).toHaveBeenCalledOnce(); expect(start).not.toHaveBeenCalled(); + expect(fetch.mock.calls.filter(call => String(call[0]).includes(":4100"))).toHaveLength(2); + }); + it.each([401, 403])("registration HTTP %i terminates before opaque SDK discovery or startup", async status => { + const fetch = vi.fn(async (url: any) => { if (String(url).startsWith(DEFAULT_BASE_URL)) throw new Error("offline"); return response({}, status); }); + const discover = vi.fn(absent), start = vi.fn(); + await expect(detectBackend(config({ OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch, discover, start, registration: async () => ({ url: "http://127.0.0.1:4100", pid: 42 }) })).rejects.toMatchObject({ code: "authentication" }); + expect(discover).not.toHaveBeenCalled(); expect(start).not.toHaveBeenCalled(); + }); + it("does not replace an unresponsive registered service", async () => { + const start = vi.fn(), discover = vi.fn(absent); + await expect(detectBackend(config({ OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch: vi.fn(async () => { throw new Error("offline"); }), start, discover, registration: async () => ({ url: "http://127.0.0.1:4100", pid: 42 }) })).rejects.toMatchObject({ code: "incompatible" }); + expect(start).not.toHaveBeenCalled(); expect(discover).not.toHaveBeenCalled(); + }); + it("starts an owned V1 child only with opt-in, absent registration, and allowed startup", async () => { + let running = false; + const fetch = vi.fn(async () => { if (!running) throw new Error("offline"); return response({ healthy: true, version: "1.18.31" }); }); + const start = vi.fn(async () => { running = true; return { running: true, managedByUs: true, url: DEFAULT_BASE_URL, version: "1.18.31" }; }); + const result = await detectBackend(config({ OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch, start, registration: absent }); + expect(result.identity).toMatchObject({ kind: "v1", connectionSource: "owned-child", processOwnership: "owned", survivesDisconnect: false }); + expect(start).toHaveBeenCalledOnce(); + expect(start.mock.calls[0][0]).toMatchObject({ timeoutMs: expect.any(Number) }); + }); + it.each([{}, { OPENCODE_AUTO_SERVE: "true", OPENCODE_BACKEND: "v2" }])("never starts without permitted V1 startup %j", async env => { + const start = vi.fn(); + await expect(detectBackend(config(env), { allowStartup: false }, { fetch: vi.fn(async () => { throw new Error(); }), start, registration: absent })).rejects.toBeInstanceOf(BackendDetectionError); + expect(start).not.toHaveBeenCalled(); + }); + it("bounds a hung discovery inside the total deadline", async () => { + const fetch = vi.fn(async (url: any) => { if (String(url).startsWith(DEFAULT_BASE_URL)) throw new Error("offline"); return response(info); }); + const start = vi.fn(); + const before = Date.now(); + await expect(detectBackend(config(), { timeout: 40 }, { fetch, start, discover: () => new Promise(() => {}), registration: async () => ({ url: "http://127.0.0.1:4100", pid: 42 }) })).rejects.toMatchObject({ code: "timeout" }); + expect(Date.now() - before).toBeLessThan(500); expect(start).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/capability-catalog.test.ts b/tests/capability-catalog.test.ts new file mode 100644 index 0000000..8dc6de9 --- /dev/null +++ b/tests/capability-catalog.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer } from "../src/app.js"; +import { McpServer } from "../src/mcp-server.js"; +import { capabilities } from "../src/backends/capabilities.js"; +import { OpenCodeClient } from "../src/client.js"; +import type { JobService } from "../src/jobs.js"; + +afterEach(() => vi.restoreAllMocks()); +const jobs = new Proxy({}, { get: () => () => { throw new Error("Unexpected job operation while inspecting catalog"); } }) as JobService; +const client = (kind: "v1" | "v2") => new OpenCodeClient({ baseUrl: "http://fixture.invalid", backend: kind }); + +describe("public backend capability inventory", () => { + it("covers every actual tool/resource/template/prompt registration exactly once", () => { + const actual: Array<{ name: string; kind: string }> = []; + const resource = vi.spyOn(McpServer.prototype, "resource"); + const prompt = vi.spyOn(McpServer.prototype, "prompt"); + const server = createServer(client("v1"), jobs, "full"); + actual.push(...server.catalog.map(tool => ({ name: tool.name, kind: "tool" }))); + actual.push(...resource.mock.calls.map(([name, uri]) => ({ name, kind: typeof uri === "string" ? "resource" : "template" }))); + actual.push(...prompt.mock.calls.map(([name]) => ({ name, kind: "prompt" }))); + const keys = (items: typeof actual) => items.map(item => `${item.kind}:${item.name}`).sort(); + expect(keys(capabilities)).toEqual(keys(actual)); + expect(new Set(keys(capabilities)).size).toBe(capabilities.length); + expect(actual.filter(item => item.kind === "tool")).toHaveLength(87); + expect(actual.filter(item => item.kind === "resource")).toHaveLength(10); + expect(actual.filter(item => item.kind === "template")).toHaveLength(4); + expect(actual.filter(item => item.kind === "prompt")).toHaveLength(6); + }); + it.each(["full", "essential"] as const)("preserves %s tool names, schemas, and annotations across backends", profile => { + const legacy = createServer(client("v1"), jobs, profile).catalog; + const modern = createServer(client("v2"), jobs, profile).catalog; + expect(modern).toEqual(legacy); + if (profile === "essential") expect(legacy).toHaveLength(25); + }); + it("distinguishes the upstream command blocker from the planned V1-only exceptions", () => { + const tools = capabilities.filter(item => item.kind === "tool"); + expect(tools.filter(item => item.v2 === "supported")).toHaveLength(64); + expect(tools.filter(item => item.v2 === "unsupported")).toHaveLength(22); + expect(tools.filter(item => item.v2 === "blocked").map(item => item.name)).toEqual(["opencode_command_execute"]); + }); + it.each(capabilities.filter(item => item.kind === "tool" && item.v2 !== "supported"))("rejects $name on V2 before transport or handler mutation", async capability => { + const handlers = new Map(); + vi.spyOn(McpServer.prototype, "registerTool").mockImplementation(((name: string, _configuration: unknown, handler: Function) => { handlers.set(name, handler); return {}; }) as any); + const transport = client("v2"); + const get = vi.spyOn(transport, "get"), post = vi.spyOn(transport, "post"), patch = vi.spyOn(transport, "patch"), put = vi.spyOn(transport, "put"), remove = vi.spyOn(transport, "delete"); + createServer(transport, jobs, "full"); + const result = await handlers.get(capability.name)!({}, { mcpReq: { signal: new AbortController().signal } }); + expect(result.isError).toBe(true); + expect(result.structuredContent.error).toMatchObject({ code: "UNSUPPORTED_CAPABILITY", capability: capability.name, backend: "v2" }); + for (const method of [get, post, patch, put, remove]) expect(method).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/compat-harness.test.ts b/tests/compat-harness.test.ts new file mode 100644 index 0000000..e5de3bb --- /dev/null +++ b/tests/compat-harness.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { compatibilityErrorSummary, ownedServerPassword, targets, waitForBackend } from "../scripts/test-compat.mjs"; + +afterEach(() => vi.unstubAllGlobals()); +describe("isolated compatibility authentication", () => { + it("reads only the documented V2 password line from owned stdout", () => { + expect(ownedServerPassword("server listening http://127.0.0.1:1234\nserver password fixture-only\n")).toBe("fixture-only"); + expect(ownedServerPassword("unrelated line with fixture-only")).toBeUndefined(); + }); + it.each([401, 403])("forwards configured Basic authentication and terminates on HTTP %i", async status => { + const fetch = vi.fn(async () => new Response("{}", { status })); vi.stubGlobal("fetch", fetch); + const processInfo = { child: { exitCode: null, signalCode: null }, stdout: "secret-sentinel", stderr: "secret-sentinel" }; + await expect(waitForBackend(processInfo, "http://fixture.test", targets.v2, { Authorization: "Basic fixture" }, 30)).rejects.toMatchObject({ code: "BACKEND_AUTHENTICATION" }); + expect(fetch).toHaveBeenCalledTimes(1); expect(fetch.mock.calls[0][1]).toMatchObject({ headers: { Authorization: "Basic fixture" } }); + }); + it("does not make an unauthenticated V2 probe before the password line arrives", async () => { + const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); + const processInfo = { child: { exitCode: null, signalCode: null }, stdout: "", stderr: "secret-sentinel" }; + await expect(waitForBackend(processInfo, "http://fixture.test", targets.v2, () => undefined, 10)).rejects.toMatchObject({ code: "BACKEND_TIMEOUT" }); + expect(fetch).not.toHaveBeenCalled(); + }); + it("never includes process output or arbitrary error messages in diagnostics", async () => { + const processInfo = { child: { exitCode: 1, signalCode: null }, stdout: "secret-sentinel", stderr: "secret-sentinel" }; + try { await waitForBackend(processInfo, "http://fixture.test", targets.v2, {}, 10); throw new Error("expected failure"); } + catch (error) { expect(String(error) + compatibilityErrorSummary(error)).not.toContain("secret-sentinel"); } + expect(compatibilityErrorSummary(new Error("secret-sentinel"))).not.toContain("secret-sentinel"); + expect(compatibilityErrorSummary({ code: "ERR_ASSERTION", message: "secret-sentinel" })).not.toContain("secret-sentinel"); + }); +}); + +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { connect } from "node:net"; +import { executableCommand } from "../scripts/test-compat.mjs"; +import { childProcess, stopProcess, run } from "../scripts/test-package.mjs"; + +describe("isolated executable process ownership", () => { + it("invokes JavaScript shebang wrappers through the current Node executable", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-launcher-")); + try { + const wrapper = join(root, "opencode"); await writeFile(wrapper, '#!/usr/bin/env node\nconsole.log("launcher-ok")\n'); + const [command, ...args] = await executableCommand(wrapper); + expect(command).toBe(process.execPath); expect(args).toEqual([wrapper]); + expect((await run(command, args)).trim()).toBe("launcher-ok"); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it("cleans up a native server child launched by an owned wrapper", async () => { + const server = 'const net=require("node:net");const s=net.createServer();s.listen(0,"127.0.0.1",()=>console.log(s.address().port));'; + const wrapper = `const {spawn}=require("node:child_process");const child=spawn(process.execPath,["-e",${JSON.stringify(server)}],{stdio:["ignore","pipe","ignore"]});child.stdout.pipe(process.stdout);setInterval(()=>{},1000);`; + const owned = childProcess(process.execPath, ["-e", wrapper], { ownedProcessGroup: true }); + try { + const deadline = Date.now() + 5000; + while (!owned.stdout.trim() && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 20)); + const port = Number(owned.stdout.trim()); expect(port).toBeGreaterThan(0); + await stopProcess(owned); + const closed = await new Promise(resolve => { + const socket = connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { socket.destroy(); resolve(false); }); + socket.once("error", error => { socket.destroy(); resolve((error as NodeJS.ErrnoException).code === "ECONNREFUSED"); }); + }); + expect(closed).toBe(true); + } finally { await stopProcess(owned); } + }, 15_000); +}); + +import { packageOptions } from "../scripts/test-package.mjs"; +describe("preserved archive selection", () => { + it("keeps default packing and accepts one absolute tgz candidate", () => { + expect(packageOptions([])).toEqual({}); + const archive = join(tmpdir(), "candidate package.tgz"); + expect(packageOptions(["--archive", archive])).toEqual({ archive }); + }); + it.each([["--archive"], ["--archive", "relative.tgz"], ["--archive", "https://example.test/archive.tgz"], ["--archive", join(tmpdir(), "package.json")], ["--unknown"], ["--archive", join(tmpdir(), "a.tgz"), "extra"]].map(args => ({ args })))("rejects invalid archive arguments $args", ({ args }) => { + expect(() => packageOptions(args)).toThrow(); + }); +}); + +import { compatibilityOptions, assertDoctorResult, configuredProviderEvidence } from "../scripts/test-compat.mjs"; +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; +import { isolatedEnvironment, npmCommand, packedInstall } from "../scripts/test-package.mjs"; + +describe("retained compatibility archives", () => { + it("accepts archive selection without enabling inference", () => { + const archive = join(tmpdir(), "retained candidate.tgz"); + expect(compatibilityOptions(["--backend", "v2", "--archive", archive])).toMatchObject({ backend: "v2", archive, inference: false, credentialEnv: [] }); + }); + it.each([["--archive"], ["--archive", "relative.tgz"], ["--archive", "https://example.test/candidate.tgz"], ["--archive", join(tmpdir(), "candidate.json")], ["--archive", join(tmpdir(), "a.tgz"), "--archive", join(tmpdir(), "b.tgz")]].map(args => ({ args })))("rejects invalid or duplicate archive arguments $args", ({ args }) => { + expect(() => compatibilityOptions(args)).toThrow(); + }); + it("installs supplied bytes without packing the working tree or changing the original", async () => { + const root = await mkdtemp(join(tmpdir(), "opencode-retained-archive-")); + try { + const env = await isolatedEnvironment(root), producer = join(root, "producer"), consumer = join(root, "test"); + await mkdir(producer); await mkdir(consumer); + await writeFile(join(producer, "package.json"), JSON.stringify({ name: "opencode-mcp", version: "99.0.0-fixture", bin: { "opencode-mcp": "index.js" } })); + await writeFile(join(producer, "index.js"), '#!/usr/bin/env node\nconsole.log("retained-fixture")\n'); + const [npm, ...prefix] = await npmCommand(); + const [packed] = JSON.parse(await run(npm, [...prefix, "pack", "--ignore-scripts", "--json"], { env, cwd: producer })); + const archive = join(producer, packed.filename), bytes = await readFile(archive); + const installed = await packedInstall(consumer, env, archive); + expect(installed.manifest.version).toBe("99.0.0-fixture"); + expect(installed.integrity).toBe("sha512-" + createHash("sha512").update(bytes).digest("base64")); + expect(await readFile(archive)).toEqual(bytes); expect(await readFile(installed.archive)).toEqual(bytes); + expect((await run(process.execPath, [installed.executable], { env })).trim()).toBe("retained-fixture"); + } finally { await rm(root, { recursive: true, force: true }); } + }, 30_000); +}); + +function diagnostic(configured: boolean) { + return { schemaVersion: 1, ready: configured, checks: ["node", "configuration", "backend", "authentication", "supported_version", "project_access", "lifecycle"].map(id => ({ id, status: "pass" })).concat([ + { id: "provider_configuration", status: configured ? "pass" : "fail", message: configured ? "Provider configured" : "No configured provider was found." } as any, + ]), backend: { version: "2.0.6", integrationTargetVersion: "2.0.6", matchesIntegrationTarget: true, processOwnership: "external", connectionSource: "explicit", survivesDisconnect: true } }; +} +describe("native doctor contract assertions", () => { + it.each([false, true])("accepts accurate readiness when configured=%s", configured => { + expect(assertDoctorResult({ stdout: JSON.stringify(diagnostic(configured)), stderr: "", code: configured ? 0 : 1 }, targets.v2)).toEqual({ ready: configured, providerConfigured: configured }); + }); + it("rejects provider read failures, mismatched readiness, and credential output", () => { + const report = diagnostic(false); report.checks.at(-1)!.message = "Provider configuration could not be read."; + expect(() => assertDoctorResult({ stdout: JSON.stringify(report), stderr: "", code: 1 }, targets.v2)).toThrow(); + expect(() => assertDoctorResult({ stdout: JSON.stringify(diagnostic(false)), stderr: "", code: 0 }, targets.v2)).toThrow(); + expect(() => assertDoctorResult({ stdout: JSON.stringify(diagnostic(false)), stderr: "private-fixture", code: 1 }, targets.v2, ["private-fixture"])).toThrow(); + }); + it("derives configuration from native connected providers and matching integration connections", () => { + expect(configuredProviderEvidence(targets.v1, new Map([["/provider", { connected: [] }]]))).toBe(false); + expect(configuredProviderEvidence(targets.v1, new Map([["/provider", { connected: ["free-provider"] }]]))).toBe(true); + const response = new Map([["/api/provider", { data: [{ id: "p", integrationID: "i" }] }], ["/api/integration", { data: [{ id: "other", connections: [{}] }] }]]); + expect(configuredProviderEvidence(targets.v2, response)).toBe(false); + response.set("/api/integration", { data: [{ id: "i", connections: [{}] }] }); + expect(configuredProviderEvidence(targets.v2, response)).toBe(true); + }); +}); + +import { safeFailureDetails } from "../scripts/test-compat.mjs"; +describe("safe native failure classification", () => { + it("classifies nested transport failures and process status without emitting messages", () => { + expect(safeFailureDetails({ name: "TypeError", message: "secret-sentinel", cause: { code: "UND_ERR_SOCKET", message: "secret-sentinel" } }, { child: { exitCode: null, signalCode: null } })).toEqual({ name: "TypeError", code: "UND_ERR_SOCKET", backendExitCode: null, backendSignal: null }); + }); + it("allows only known names, codes, signals and numeric exit status", () => { + expect(safeFailureDetails({ name: "secret-sentinel", code: "secret-sentinel" }, { child: { exitCode: "secret-sentinel", signalCode: "secret-sentinel" } })).toEqual({ backendExitCode: null, backendSignal: null }); + expect(safeFailureDetails({ name: "Error", code: "ESRCH" }, { child: { exitCode: 1, signalCode: "SIGTERM" } })).toEqual({ name: "Error", code: "ESRCH", backendExitCode: 1, backendSignal: "SIGTERM" }); + }); +}); diff --git a/tests/doctor.test.ts b/tests/doctor.test.ts new file mode 100644 index 0000000..72d7d01 --- /dev/null +++ b/tests/doctor.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { runDoctor, formatDoctor } from "../src/doctor.js"; +import { parseCliArgs } from "../src/cli.js"; +import { BackendDetectionError, type DetectedBackend } from "../src/backend-detection.js"; +import type { OpenCodeClient } from "../src/client.js"; + +const connection: DetectedBackend = { baseUrl: "http://fixture.test", identity: { kind: "v1", version: "1.18.31", connectionSource: "explicit", processOwnership: "external", survivesDisconnect: true } }; +const check = (report: Awaited>, id: string) => report.checks.find(check => check.id === id)!; +function dependencies(handler?: (path: string) => Promise) { + const get = vi.fn(handler ?? (async (path: string) => path === "/provider" ? { all: [{ id: "fixture", source: "env" }] } : { id: "project", worktree: "/remote/project" })); + return { get, detect: vi.fn(async () => connection), client: () => ({ get, getBackendIdentity: () => connection.identity } as unknown as OpenCodeClient) }; +} + +describe("CLI arguments", () => { + it("preserves no-argument stdio and supports doctor/check", () => { + expect(parseCliArgs([])).toEqual({ command: "stdio" }); + expect(parseCliArgs(["doctor", "--directory", "C:\\repo", "--json"])).toEqual({ command: "doctor", directory: "C:\\repo", json: true }); + expect(parseCliArgs(["doctor", "--directory", "\\\\server\\share\\repo"])).toEqual({ command: "doctor", directory: "\\\\server\\share\\repo", json: false }); + expect(parseCliArgs(["--check"])).toEqual({ command: "doctor", json: false }); + expect(parseCliArgs(["--version"])).toEqual({ command: "version" }); + }); + it.each([["--json"], ["doctor", "--directory"], ["doctor", "--json", "--json"], ["doctor", "--bogus"], ["doctor", "--directory", "--json"], ["doctor", "--directory", "relative/path"], ["--help", "doctor"]])("rejects invalid arguments %j", args => expect(() => parseCliArgs(args)).toThrow()); +}); + +describe("read-only doctor", () => { + it("reports readiness using configured credentials without invoking inference", async () => { + const deps = dependencies(); + const report = await runDoctor({ ...deps, env: {}, directory: "/remote/project" }); + expect(report.ready).toBe(true); expect(report.schemaVersion).toBe(1); + expect(report.backend).toMatchObject({ kind: "v1", integrationTargetVersion: "1.18.31", matchesIntegrationTarget: true }); + expect(deps.detect).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ allowStartup: false, signal: expect.any(AbortSignal) }), undefined); + expect(deps.get.mock.calls.map(call => call[0])).toEqual(["/provider", "/project/current"]); + expect(check(report, "provider_configuration").message).toContain("no inference"); + expect(formatDoctor(report)).toContain("local executable is not required"); + }); + it("does not require a default project when --directory is omitted", async () => { + const deps = dependencies(); + const report = await runDoctor({ ...deps, env: {} }); + expect(report.ready).toBe(true); expect(deps.get).toHaveBeenCalledTimes(1); + expect(check(report, "project_access").message).toContain("not requested"); + }); + it("distinguishes a recognized version from the integration target without claiming live evidence", async () => { + const deps = dependencies(); + const report = await runDoctor({ ...deps, env: {}, detect: async () => ({ ...connection, identity: { ...connection.identity, version: "1.99.0" } }) }); + expect(report.ready).toBe(true); expect(report.backend?.matchesIntegrationTarget).toBe(false); + expect(check(report, "supported_version").status).toBe("warn"); + expect(formatDoctor(report)).not.toContain("integration-tested"); + }); + it("fails readiness without configured providers", async () => { + const deps = dependencies(async () => ({ all: [{ id: "fixture" }] })); + const report = await runDoctor({ ...deps, env: {} }); + expect(report.ready).toBe(false); expect(check(report, "provider_configuration").status).toBe("fail"); + }); + it.each([{ OPENCODE_BASE_URL: "http://username:secret-sentinel@host.test" }, { OPENCODE_BASE_URL: "https://host.test/?token=secret-sentinel" }])("redacts invalid URL configuration from text and JSON", async env => { + const deps = dependencies(); + const report = await runDoctor({ ...deps, env }); + expect(report.ready).toBe(false); expect(deps.detect).not.toHaveBeenCalled(); + expect(JSON.stringify(report) + formatDoctor(report)).not.toMatch(/secret-sentinel|username|host\.test/); + }); + it("never emits provider bodies or upstream error messages", async () => { + const deps = dependencies(async () => { throw new Error("secret-sentinel upstream credential"); }); + const report = await runDoctor({ ...deps, env: { OPENCODE_SERVER_PASSWORD: "secret-sentinel" }, directory: "/secret-sentinel" }); + expect(JSON.stringify(report) + formatDoctor(report)).not.toContain("secret-sentinel"); + }); + it("omits hostile server version text", async () => { + const deps = dependencies(); + const report = await runDoctor({ ...deps, env: {}, detect: async () => ({ ...connection, identity: { ...connection.identity, version: "secret-sentinel\n" } }) }); + expect(JSON.stringify(report) + formatDoctor(report)).not.toContain("secret-sentinel"); + expect(report.ready).toBe(false); + }); + it.each([{ status: 401 }, { status: 403 }, { _tag: "UnauthorizedError" }, { cause: { status: 403 } }])("stops all reads after authentication failure %j", async error => { + const deps = dependencies(async () => { throw { ...error, secret: "secret-sentinel" }; }); + const report = await runDoctor({ ...deps, env: {}, directory: "/remote/project" }); + expect(report.ready).toBe(false); expect(deps.get).toHaveBeenCalledTimes(1); + expect(check(report, "authentication").status).toBe("fail"); + expect(JSON.stringify(report)).not.toContain("secret-sentinel"); + }); + it("stops after a detection authentication error without initializing a client", async () => { + const client = vi.fn(); + const report = await runDoctor({ env: {}, client, detect: async () => { throw new BackendDetectionError("authentication", "secret-sentinel", 401); } }); + expect(report.ready).toBe(false); expect(client).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain("secret-sentinel"); + }); + it("bounds the combined detection/provider/project work with one deadline", async () => { + const deps = dependencies(() => new Promise(() => {})); + const before = Date.now(); + const report = await runDoctor({ ...deps, env: {}, timeoutMs: 35, directory: "/remote/project" }); + expect(Date.now() - before).toBeLessThan(500); expect(report.ready).toBe(false); + expect(deps.get).toHaveBeenCalledTimes(1); + }); + it("checks Node requirement without starting detection", async () => { + const deps = dependencies(); + const report = await runDoctor({ ...deps, env: {}, nodeVersion: "20.1.0" }); + expect(report.ready).toBe(false); expect(deps.detect).not.toHaveBeenCalled(); + }); +}); + +describe("doctor CLI process", () => { + let http: Server | undefined; + afterEach(async () => { if (http) { http.closeAllConnections(); await new Promise(resolve => http!.close(() => resolve())); http = undefined; } }); + async function run(args: string[], env: NodeJS.ProcessEnv = {}) { + return new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(new URL("../dist/index.js", import.meta.url)), ...args], { + env: { ...process.env, OPENCODE_BACKEND: "auto", OPENCODE_AUTO_SERVE: "false", OPENCODE_DEFAULT_PROVIDER: "", OPENCODE_DEFAULT_MODEL: "", ...env }, stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = "", stderr = ""; + child.stdout.on("data", value => { stdout += value; }); child.stderr.on("data", value => { stderr += value; }); + child.once("error", reject); child.once("exit", code => resolve({ code, stdout, stderr })); + }); + } + it("help/version work without backend readiness and invalid arguments exit2", async () => { + expect((await run(["--help"])).stdout).toContain("Usage:"); + const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); + expect((await run(["--version"])).stdout.trim()).toBe(pkg.version); + expect((await run(["doctor", "--invalid"])).code).toBe(2); + expect((await run(["doctor", "--directory", "relative/path"])).code).toBe(2); + }); + it.each([true, false])("emits parseable JSON and exit status; provider configured=%s", async configured => { + const requests: string[] = []; + http = createServer((req, res) => { + requests.push(`${req.method} ${req.url}`); res.setHeader("content-type", "application/json"); + if (req.url === "/global/health") res.end(JSON.stringify({ healthy: true, version: "1.18.31" })); + else if (req.url?.startsWith("/provider")) res.end(JSON.stringify({ all: configured ? [{ id: "fixture", source: "env", options: { apiKey: "secret-sentinel" } }] : [] })); + else res.end(JSON.stringify({ id: "project", worktree: "/remote/project" })); + }); + await new Promise(resolve => http!.listen(0, "127.0.0.1", resolve)); + const result = await run(["--check", "--json", "--directory", "/remote/project"], { OPENCODE_BASE_URL: `http://127.0.0.1:${(http.address() as { port: number }).port}`, OPENCODE_AUTO_SERVE: "true" }); + expect(result.code).toBe(configured ? 0 : 1); expect(JSON.parse(result.stdout).ready).toBe(configured); + expect(result.stdout + result.stderr).not.toContain("secret-sentinel"); + expect(requests).toHaveLength(3); expect(requests.every(value => value.startsWith("GET "))).toBe(true); + }); + it.each([401, 403])("stops V2 SDK reads after provider HTTP %i and redacts the body", async status => { + const requests: string[] = []; + http = createServer((req, res) => { + requests.push(req.url ?? ""); res.setHeader("content-type", "application/json"); + if (req.url === "/global/health") { res.statusCode = 404; res.end("{}"); } + else if (req.url === "/api/info") res.end(JSON.stringify({ version: "2.0.6", pid: 42, urls: [], paths: { tmp: "/tmp" } })); + else { res.statusCode = status; res.end(JSON.stringify({ _tag: "UnauthorizedError", message: "secret-sentinel" })); } + }); + await new Promise(resolve => http!.listen(0, "127.0.0.1", resolve)); + const result = await run(["doctor", "--json", "--directory", "/remote/project"], { OPENCODE_BASE_URL: `http://127.0.0.1:${(http.address() as { port: number }).port}` }); + expect(result.code).toBe(1); expect(JSON.parse(result.stdout).checks.find((entry: any) => entry.id === "authentication").status).toBe("fail"); + expect(requests).toHaveLength(3); expect(requests[2]).toMatch(/provider/); + expect(result.stdout + result.stderr).not.toContain("secret-sentinel"); + }); +}); diff --git a/tests/server-manager.test.ts b/tests/server-manager.test.ts index 98dc1c6..4a93452 100644 --- a/tests/server-manager.test.ts +++ b/tests/server-manager.test.ts @@ -31,6 +31,7 @@ import { ensureServer, registerShutdownHandlers, resetShutdownRegisteredForTests, + ServerAuthenticationError, } from "../src/server-manager.js"; // ─── Helpers ───────────────────────────────────────────────────────────── @@ -231,17 +232,35 @@ describe("startServer", () => { ); }); - it("returns version=undefined when post-start health check fails", async () => { + it("closes only its owned child when post-start health check fails", async () => { + const close = vi.fn(); createOpencodeServerMock.mockResolvedValueOnce({ url: "http://127.0.0.1:4096", - close: vi.fn(), + close, }); mockFetchDown(); // health check after start fails - const result = await startServer("http://127.0.0.1:4096", 5000); + await expect(startServer("http://127.0.0.1:4096", 5000)).rejects.toThrow("healthy V1 contract"); + expect(close).toHaveBeenCalledOnce(); + stopServer(); + expect(close).toHaveBeenCalledOnce(); + }); - expect(result.url).toBe("http://127.0.0.1:4096"); - expect(result.version).toBeUndefined(); + it("uses configured authentication for the owned child without unauthenticated fallback", async () => { + createOpencodeServerMock.mockResolvedValueOnce({ url: "http://127.0.0.1:4096", close: vi.fn() }); + mockFetchHealthy(); + await startServer("http://127.0.0.1:4096", 5000, "fixture-user", "fixture-password"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(`Basic ${Buffer.from("fixture-user:fixture-password").toString("base64")}`); + }); + + it.each([401, 403])("closes the owned child after post-start HTTP %i", async status => { + const close = vi.fn(); + createOpencodeServerMock.mockResolvedValueOnce({ url: "http://127.0.0.1:4096", close }); + fetchMock.mockResolvedValueOnce({ ok: false, status }); + await expect(startServer("http://127.0.0.1:4096", 5000, "fixture", "fixture")).rejects.toBeInstanceOf(ServerAuthenticationError); + expect(close).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledTimes(1); + stopServer(); expect(close).toHaveBeenCalledOnce(); }); }); @@ -325,6 +344,12 @@ describe("shutdown behavior", () => { // ─── ensureServer ──────────────────────────────────────────────────────── describe("ensureServer", () => { + it.each([401, 403])("never starts after authentication HTTP %i", async status => { + fetchMock.mockResolvedValueOnce({ ok: false, status }); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe: true })).rejects.toBeInstanceOf(ServerAuthenticationError); + expect(createOpencodeServerMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); it("returns immediately when server is already running", async () => { mockFetchHealthy("1.14.46"); diff --git a/tests/stdio-integration.test.ts b/tests/stdio-integration.test.ts index 1a1d5a6..250ff67 100644 --- a/tests/stdio-integration.test.ts +++ b/tests/stdio-integration.test.ts @@ -1,3 +1,4 @@ +import { packageVersion } from "../src/version.js"; import { afterEach, describe, expect, it } from "vitest"; import { spawn, type ChildProcess } from "node:child_process"; import { createServer, type Server } from "node:http"; @@ -34,7 +35,7 @@ describe("MCP process lifecycle", () => { await new Promise((resolve, reject) => { child!.stderr!.on("data", (chunk) => { log += chunk; - if (log.includes("opencode-mcp v3.0.0 started (")) resolve(); + if (log.includes(`opencode-mcp v${packageVersion} started (`)) resolve(); }); child!.once("error", reject); child!.once("exit", () => reject(new Error(`Premature exit: ${log}`))); diff --git a/tests/task-transport.test.ts b/tests/task-transport.test.ts index a1348bc..7dcd974 100644 --- a/tests/task-transport.test.ts +++ b/tests/task-transport.test.ts @@ -16,7 +16,7 @@ const snapshot = (status: JobSnapshot["status"]): JobSnapshot => ({ async function setup() { const sent: any[] = []; const inner = { start: vi.fn(async () => {}), close: vi.fn(async () => {}), send: vi.fn(async (message: unknown) => { sent.push(message); }) } as unknown as Transport; - const jobs = { start: vi.fn(async () => snapshot("accepted")), get: vi.fn(async () => snapshot("running")), + const jobs = { selectModel: (providerID?:string,modelID?:string) => providerID && modelID ? {providerID,modelID} : undefined, start: vi.fn(async () => snapshot("accepted")), get: vi.fn(async () => snapshot("running")), update: vi.fn(async () => snapshot("running")), cancel: vi.fn(async () => snapshot("cancelled")) }; const transport = new TaskTransport(inner, jobs as unknown as JobService); const forwarded = vi.fn(); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 80b0ba2..2a38eab 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -1661,7 +1661,7 @@ describe("Tool handlers", () => { post: vi.fn().mockImplementation((path: string) => { callCount++; if (path.startsWith("/permission/")) { - return Promise.reject(new Error("404 not found")); + return Promise.reject(new OpenCodeError("404 not found", 404, "POST", path, "not found")); } return Promise.resolve({}); }), diff --git a/tests/transport-reliability.test.ts b/tests/transport-reliability.test.ts index 61497ce..8a26db0 100644 --- a/tests/transport-reliability.test.ts +++ b/tests/transport-reliability.test.ts @@ -192,6 +192,26 @@ describe("event polling results", () => { expect(result.structuredContent.partial).toBe(false); }); + it("recognizes an inner timeout at the shared deadline before the outer timer fires", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1000); + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + vi.spyOn(client, "subscribeSSE").mockImplementation(async function* (_path, options) { + now.mockReturnValue(options!.deadline!); + throw new DOMException("Inner deadline expired", "TimeoutError"); + }); + const result = await eventHandler(client)({ durationMs: 200 }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent.partial).toBe(false); + }); + + it("does not swallow an early upstream TimeoutError", async () => { + const client = new OpenCodeClient({ baseUrl: "http://localhost:4096" }); + vi.spyOn(client, "subscribeSSE").mockImplementation(async function* () { + throw new DOMException("Upstream operation timed out", "TimeoutError"); + }); + expect((await eventHandler(client)({ durationMs: 2000 })).isError).toBe(true); + }); + it("keeps caller cancellation visible", async () => { const controller = new AbortController(); controller.abort(); diff --git a/tests/v2-adapter.test.ts b/tests/v2-adapter.test.ts new file mode 100644 index 0000000..91f0336 --- /dev/null +++ b/tests/v2-adapter.test.ts @@ -0,0 +1,294 @@ +import { registerSessionTools } from "../src/tools/session.js"; +import type { McpServer } from "../src/mcp-server.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { OpenCodeClient, OpenCodeError, OpenCodeSubmissionError } from "../src/client.js"; +import { backend, events, operate, selectModel } from "../src/backends/adapter.js"; +import { setModelDefaults, safeStringify, toolResult } from "../src/helpers.js"; +import { BackendCapabilityError } from "../src/backends/contracts.js"; +import { validateFormValues, nativeFormSchema } from "../src/backends/v2-forms.js"; +import { translateMcpConfig } from "../src/backends/v2-mcp.js"; +import { decodeInputResponses } from "../src/task-input.js"; + +const cleanups: Array<() => Promise> = []; +afterEach(async () => { setModelDefaults(); await Promise.all(cleanups.splice(0).map(cleanup => cleanup())); }); +const defaultLocation = {directory:"/server/project",project:{id:"prj",directory:"/server/project",canonical:"/server/project"}}; +const session = (overrides: Record = {}) => ({ id: "ses_a", title: "Example", location: {directory:"/server/project"}, projectID:"prj_a", model:{providerID:"p",id:"m",variant:"fast"}, agent:"build", time:{created:1,updated:2}, cost:0,tokens:{}, ...overrides }); +type Call = {method:string;url:URL;body:any}; +async function fixture(handler: (call: Call, res: ServerResponse) => unknown, prefix = "") { + const calls:Call[] = []; + const server = createServer(async (req:IncomingMessage,res:ServerResponse) => { + const chunks:Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const text=Buffer.concat(chunks).toString();const call={method:req.method!,url:new URL(req.url!,"http://fixture"),body:text ? JSON.parse(text) : undefined};calls.push(call); + res.setHeader("content-type","application/json"); + try { const value=await handler(call,res); if(!res.writableEnded && !res.destroyed) res.end(JSON.stringify(value ?? {})); } + catch { res.statusCode=500;res.end('{}'); } + }); + await new Promise(resolve=>server.listen(0,"127.0.0.1",resolve)); + cleanups.push(()=>new Promise(resolve=>{server.closeAllConnections();server.close(()=>resolve());})); + const address=server.address() as {port:number}; + return {calls,client:new OpenCodeClient({baseUrl:`http://127.0.0.1:${address.port}${prefix}`,backend:"v2"})}; +} + +describe("V2 actual SDK HTTP contracts",()=>{ + it.each(["/bridge","/api"])("preserves authoritative base URL prefix %s",async prefix=>{ + const {client,calls}=await fixture(()=>({version:"2.0.6",pid:1,urls:[],paths:{tmp:"/tmp"}}),prefix); + await expect(operate(client,"lifecycle.health")).resolves.toMatchObject({healthy:true,backend:"v2"}); + expect(calls[0].url.pathname).toBe(prefix+"/api/info"); + }); + it("preserves remote Windows paths and creates model/agent before prompting",async()=>{ + const {client,calls}=await fixture(({body})=>({data:session({location:body.location,model:body.model,agent:body.agent})})); + const result=await operate(client,"sessions.create",{directory:"\\\\server\\share\\project",body:{model:{providerID:"p",modelID:"m"},variant:"fast",agent:"plan"}}); + expect(calls).toHaveLength(1);expect(calls[0].body).toEqual({model:{providerID:"p",id:"m",variant:"fast"},agent:"plan",location:{directory:"\\\\server\\share\\project"}}); + expect(result.directory).toBe("\\\\server\\share\\project"); + }); + it.each([400,401,403])("keeps explicit write rejection HTTP %i definite",async status=>{ + const {client,calls}=await fixture((_,res)=>{res.statusCode=status;return {message:"rejected"};}); + await expect(operate(client,"sessions.create",{body:{title:"test"}})).rejects.toMatchObject({name:"OpenCodeError",status}); + expect(calls).toHaveLength(1); + }); + it("keeps a lost write response ambiguous without replay",async()=>{ + const {client,calls}=await fixture((_,res)=>res.destroy()); + await expect(operate(client,"sessions.create",{body:{title:"test"}})).rejects.toBeInstanceOf(OpenCodeSubmissionError); + expect(calls).toHaveLength(1); + }); + it("rejects pre-cancelled writes without dispatch",async()=>{ + const {client,calls}=await fixture(()=>session());const controller=new AbortController();controller.abort(); + await expect(operate(client,"sessions.create",{signal:controller.signal,body:{title:"test"}})).rejects.toMatchObject({name:"AbortError"});expect(calls).toHaveLength(0); + }); + it.each([{format:{type:"json_schema",schema:{}}},{system:"override"},{noReply:true}])("rejects unsupported prompt guarantee before any HTTP request: %j",async guard=>{ + const {client,calls}=await fixture(()=>session()); + await expect(operate(client,"messages.enqueue",{sessionId:"ses_a",body:{parts:[{type:"text",text:"test"}],...guard}})).rejects.toBeInstanceOf(BackendCapabilityError);expect(calls).toHaveLength(0); + }); + it("rejects parent creation and variant without model before writes",async()=>{ + const {client,calls}=await fixture(()=>session()); + await expect(operate(client,"sessions.create",{body:{parentID:"ses_a"}})).rejects.toBeInstanceOf(BackendCapabilityError); + await expect(operate(client,"sessions.create",{body:{variant:"fast"}})).rejects.toBeInstanceOf(BackendCapabilityError);expect(calls).toHaveLength(0); + }); + it("preserves existing session selection and queues the exact prompt identity",async()=>{ + const {client,calls}=await fixture(({method,body})=>method==="GET"?{data:session()}:{data:{id:body.id,sessionID:"ses_a",type:"user",delivery:body.delivery}}); + const receipt=await operate(client,"messages.enqueue",{sessionId:"ses_a",body:{messageID:"msg_assigned",parts:[{type:"text",text:"hello"}],model:{providerID:"p",modelID:"m"},variant:"fast",agent:"build"}}); + expect(receipt?.id).toBe("msg_assigned");expect(calls[1].body).toEqual({id:"msg_assigned",text:"hello",delivery:"queue"}); + await expect(operate(client,"messages.enqueue",{sessionId:"ses_a",body:{model:{providerID:"other",modelID:"m"}}})).rejects.toBeInstanceOf(BackendCapabilityError); + expect(calls.filter(call=>call.method!=="GET")).toHaveLength(1); + }); + it("rejects mismatched receipt as unknown and never retries",async()=>{ + const {client,calls}=await fixture(({method})=>method==="GET"?{data:session()}:{data:{id:"msg_wrong",sessionID:"ses_a"}}); + await expect(operate(client,"messages.enqueue",{sessionId:"ses_a",body:{messageID:"msg_expected",parts:[{type:"text",text:"hello"}]}})).rejects.toBeInstanceOf(OpenCodeSubmissionError); + expect(calls.filter(call=>call.method==="POST")).toHaveLength(1); + }); + it("does not apply defaults to existing V2 sessions or accept half a model selection",async()=>{ + const {client}=await fixture(()=>session());setModelDefaults("default","default-model"); + expect(selectModel(client)).toBeUndefined();expect(selectModel(client,undefined,undefined,true)).toEqual({providerID:"default",modelID:"default-model"}); + expect(()=>selectModel(client,"explicit")).toThrow("both providerID and modelID"); + }); + it("returns explicit pagination while preserving a caller limit",async()=>{ + const {client,calls}=await fixture(({url})=>url.pathname==="/api/location"?defaultLocation:({data:[session({id:url.searchParams.get("cursor")?"ses_b":"ses_a"})],cursor:{next:url.searchParams.get("cursor")?null:"next-page"}})); + const result=await operate(client,"sessions.list",{query:{limit:"1"}}); + expect(result).toHaveLength(1);expect(calls.filter(call=>call.url.pathname==="/api/session")).toHaveLength(1); + expect(toolResult("sessions",false,{data:result}).structuredContent).toMatchObject({complete:false,cursor:"next-page"}); + expect(JSON.parse(safeStringify(result))).toMatchObject({complete:false,cursor:"next-page"}); + const continued=await operate(client,"sessions.list",{query:{limit:"1",cursor:"next-page"}});expect(continued[0].id).toBe("ses_b"); + }); + it.each(["0","-1","1.5"])("rejects invalid pagination limit %s",async limit=>{ + const {client,calls}=await fixture(({url})=>url.pathname==="/api/location"?defaultLocation:({data:[],cursor:{}}));await expect(operate(client,"sessions.list",{query:{limit}})).rejects.toThrow("positive integer");expect(calls.every(call=>call.url.pathname==="/api/location")).toBe(true); + }); + it("reads every page when no limit is requested",async()=>{ + const {client,calls}=await fixture(({url})=>url.pathname==="/api/location"?defaultLocation:({data:[session({id:url.searchParams.has("cursor")?"ses_b":"ses_a"})],cursor:{next:url.searchParams.has("cursor")?null:"next"}})); + expect(await operate(client,"sessions.list")).toHaveLength(2);expect(calls.filter(call=>call.url.pathname==="/api/session")).toHaveLength(2); + }); + it("refuses wrong session directory before read or mutation",async()=>{ + const {client,calls}=await fixture(({url})=>url.pathname==="/api/location"?{directory:"/elsewhere",project:{id:"prj",directory:"/elsewhere",canonical:"/elsewhere"}}:{data:session()}); + await expect(operate(client,"sessions.remove",{sessionId:"ses_a",directory:"/elsewhere"})).rejects.toThrow("does not match");expect(calls.every(call=>call.method==="GET")).toBe(true); + await expect(operate(client,"messages.list",{sessionId:"ses_a",directory:"/elsewhere"})).rejects.toThrow("does not match"); + }); + it("requires an explicit diff range and never commits a staged revert",async()=>{ + const {client,calls}=await fixture(({method})=>method==="GET"?{data:session()}:{data:{messageID:"msg_a"}}); + await expect(operate(client,"sessions.diff",{sessionId:"ses_a"})).rejects.toThrow("explicit from and to"); + await expect(operate(client,"sessions.revert",{sessionId:"ses_a",body:{messageID:"msg_a",partID:"part_a"}})).rejects.toBeInstanceOf(BackendCapabilityError); + expect(calls).toHaveLength(0); + await expect(operate(client,"sessions.revert",{sessionId:"ses_a",body:{messageID:"msg_a"}})).resolves.toMatchObject({committed:false,reversible:true}); + expect(calls.filter(call=>call.method==="POST").map(call=>call.url.pathname)).toEqual(["/api/session/ses_a/revert/stage"]); + }); + it("requires explicit project/session permission scope before mutation",async()=>{ + const {client,calls}=await fixture(({method},res)=>{if(method==="GET")return {data:session()};res.statusCode=204;}); + await expect(operate(client,"permissions.reply",{sessionId:"ses_a",requestId:"perm_a",body:{reply:"always"}})).rejects.toThrow("scope: project"); + await expect(operate(client,"permissions.reply",{sessionId:"ses_a",requestId:"perm_a",body:{reply:"reject"}})).rejects.toThrow("scope: session");expect(calls).toHaveLength(0); + await operate(client,"permissions.reply",{sessionId:"ses_a",requestId:"perm_a",body:{reply:"always",scope:"project"}});expect(calls.at(-1)?.body).toEqual({decision:"always"}); + }); + it("blocks uncorrelatable commands before submission",async()=>{ + const {client,calls}=await fixture(()=>({}));await expect(operate(client,"messages.command",{sessionId:"ses_a",body:{command:"test"}})).rejects.toThrow("no command was submitted");expect(calls).toHaveLength(0); + }); + it("requires exact OAuth attempt identity before global auth writes",async()=>{ + const {client,calls}=await fixture(()=>({}));await expect(operate(client,"providers.callback",{providerId:"p",body:{method:0}})).rejects.toThrow("attemptId");expect(calls).toHaveLength(0); + }); +}); + +describe("V2 forms and configuration contracts",()=>{ + const fields:any=[{key:"count",type:"integer",required:true,minimum:1},{key:"approve",type:"boolean",required:true},{key:"choices",type:"multiselect",options:[{value:"a",label:"A"}],maxItems:1}]; + it("validates typed native/manual values against the same schema",()=>{ + expect(validateFormValues(fields,{count:2,approve:false,choices:["a"]})).toEqual({count:2,approve:false,choices:["a"]}); + expect(()=>validateFormValues(fields,{count:"2",approve:true})).toThrow();expect(()=>validateFormValues(fields,{count:2,approve:true,choices:["other"]})).toThrow(); + expect(()=>validateFormValues(fields,{count:2,approve:true,unknown:"x"})).toThrow(); + }); + it("uses manual fallback for forms MCP cannot represent faithfully",()=>{ + expect(nativeFormSchema([{key:"x",type:"string",pattern:"^[a-z]+$"}])).toBeUndefined(); + expect(nativeFormSchema([{key:"x",type:"multiselect",custom:true,options:[]}])).toBeUndefined(); + expect(nativeFormSchema([{key:"x",type:"external",url:"https://example.com"}])).toBeUndefined(); + }); + it("never interprets declining permission elicitation as a session rejection",()=>{ + expect(decodeInputResponses([{id:"p",kind:"permission",backend:"v2",sessionID:"ses_a"}],{"permission:p":{action:"decline"}})).toEqual([]); + expect(decodeInputResponses([{id:"f",kind:"question",backend:"v2",sessionID:"ses_a",fields}],{"question:f":{action:"decline"}})).toEqual([{id:"f",kind:"question",reject:true}]); + expect(()=>decodeInputResponses([{id:"p",kind:"permission",backend:"v2",sessionID:"ses_a"}],{"permission:p":{action:"accept",content:{decision:"always",scope:"session"}}})).toThrow(); + }); + it("translates supported MCP fields and rejects conflicts or unsupported fields",()=>{ + expect(translateMcpConfig({type:"remote",url:"https://example.com",enabled:true,timeout:1000,oauth:{clientId:"client"}})).toMatchObject({disabled:false,timeout:{catalog:1000,execution:1000},oauth:{client_id:"client"}}); + expect(()=>translateMcpConfig({type:"local",command:["test"],enabled:true,disabled:true})).toThrow("Conflicting"); + expect(()=>translateMcpConfig({type:"local",command:["test"],unknown:true})).toThrow("Unsupported"); + }); +}); + +describe("V2 normalized reads and scoped mutations over HTTP",()=>{ + it("joins provider models and integrations without treating activation as a credential",async()=>{ + const {client,calls}=await fixture(({url})=>({location:{directory:"/server/project"},data: + url.pathname==="/api/provider"?[{id:"p",name:"Configured",integrationID:"i",activation:"enabled"},{id:"q",name:"Unconfigured",activation:"enabled"}]: + url.pathname==="/api/model"?[{id:"m",name:"Model",providerID:"p",enabled:true,status:"active",limit:{context:100,output:10}}]: + [{id:"i",name:"Integration",connections:[{id:"credential"}],methods:[]}]})); + const result=await operate(client,"providers.configured");expect(result.providers.map(provider=>provider.id)).toEqual(["p"]);expect(result.providers[0].models.m.name).toBe("Model"); + expect(calls.map(call=>call.url.pathname)).toEqual(["/api/provider","/api/model","/api/integration"]); + }); + it("does not make further provider reads after authentication failure",async()=>{ + const {client,calls}=await fixture((_,res)=>{res.statusCode=401;return {};});await expect(operate(client,"providers.list")).rejects.toMatchObject({status:401});expect(calls).toHaveLength(1); + }); + it("preserves ordered configuration sources without inventing an effective merge",async()=>{ + const sources=[{type:"document",path:"global.json",info:{model:"p/a"}},{type:"document",path:"project.json",info:{model:"p/b"}}]; + const {client}=await fixture(()=>sources);await expect(operate(client,"configuration.get")).resolves.toEqual({backend:"v2",sources,effectiveConfiguration:false}); + }); + it("normalizes text and binary file reads without resolving paths locally",async()=>{ + const {client,calls}=await fixture(({url},res)=>{res.setHeader("content-type","application/octet-stream");res.end(url.pathname.endsWith("/binary")?Buffer.from([0xff,0xfe]):Buffer.from("hello"));}); + await expect(operate(client,"files.read",{query:{path:"file.txt"},directory:"C:\\remote\\project"})).resolves.toMatchObject({type:"text",content:"hello"}); + await expect(operate(client,"files.read",{query:{path:"binary"},directory:"C:\\remote\\project"})).resolves.toMatchObject({type:"binary",encoding:"base64",content:"//4="}); + expect(calls[0].url.searchParams.get("location[directory]")).toBe("C:\\remote\\project"); + }); + it("marks bounded file searches without inventing continuation",async()=>{ + const {client}=await fixture(()=>({location:{directory:"/project"},data:[{path:"one",type:"file"},{path:"two",type:"file"}]})); + const results=await operate(client,"files.find",{query:{query:"o",limit:"2"}});expect(toolResult("files",false,{data:results}).structuredContent).toMatchObject({complete:false,continuationAvailable:false,bounded:true}); + }); + it("reports model and agent conflicts without changing settings",async()=>{ + const {client,calls}=await fixture(()=>({data:session()})); + await expect(operate(client,"messages.enqueue",{sessionId:"ses_a",body:{agent:"plan"}})).rejects.toThrow("preserve their agent"); + await expect(operate(client,"messages.enqueue",{sessionId:"ses_a",body:{variant:"slow"}})).rejects.toThrow("preserve their model variant");expect(calls.every(call=>call.method==="GET")).toBe(true); + }); + it("preserves explicit fork-before boundary",async()=>{ + const {client,calls}=await fixture(({url,method})=>({data:method==="POST"?session({id:"ses_fork"}):url.pathname.endsWith("/message/msg_boundary")?{id:"msg_boundary",type:"user",text:"boundary",time:{created:1}}:session()})); + const result=await operate(client,"sessions.fork",{sessionId:"ses_a",body:{messageID:"msg_boundary"}}); + expect(result.forkBoundary).toEqual({type:"before",messageId:"msg_boundary"});expect(calls.at(-1)?.body).toEqual({before:"msg_boundary"}); + }); + it("rejects cross-location diff ranges and discloses turn attribution",async()=>{ + let crossed=true; + const {client,calls}=await fixture(({url})=>url.pathname.endsWith("/message")?{data:[{id:"msg_a",type:"user",text:"A",time:{created:1}},...(crossed?[{id:"msg_move",type:"location-switched",time:{created:2}}]:[]),{id:"msg_b",type:"user",text:"B",time:{created:3}}],cursor:{}}:url.pathname.endsWith("/diff")?{data:[{file:"a",before:"",after:"x",additions:1,deletions:0}]}:{data:session()}); + await expect(operate(client,"sessions.diff",{sessionId:"ses_a",query:{from:"msg_a",to:"msg_b"}})).rejects.toThrow("cross-location");expect(calls.some(call=>call.url.pathname.endsWith("/diff"))).toBe(false); + crossed=false;const diff=await operate(client,"sessions.diff",{sessionId:"ses_a",query:{from:"msg_a",to:"msg_b"}});expect(toolResult("diff",false,{data:diff}).structuredContent).toMatchObject({attribution:"turn",range:{from:"msg_a",to:"msg_b"}}); + }); + it("validates field-keyed form values before replying",async()=>{ + const form={id:"form_a",sessionID:"ses_a",title:"Count",fields:[{key:"count",type:"integer",required:true,minimum:1}],state:{status:"pending"}}; + const {client,calls}=await fixture(({url,method},res)=>{if(method==="POST"){res.statusCode=204;return;}return {data:url.pathname.endsWith("/form/form_a")?form:session()};}); + await expect(operate(client,"forms.reply",{sessionId:"ses_a",requestId:"form_a",body:{values:{count:"3"}}})).rejects.toThrow("Invalid value");expect(calls.every(call=>call.method==="GET")).toBe(true); + await operate(client,"forms.reply",{sessionId:"ses_a",requestId:"form_a",body:{values:{count:3}}});expect(calls.at(-1)?.body).toEqual({answer:{count:3}}); + }); + it("carries the exact OAuth attempt and rejects ambiguous integration mappings",async()=>{ + let mapped=true; + const {client,calls}=await fixture(({url,method},res)=>{ + if(url.pathname==="/api/provider")return {data:[{id:"p",name:"Provider",integrationID:mapped?"integration_a":undefined}]}; + if(url.pathname==="/api/integration")return {data:[{id:"integration_a",name:"Integration",connections:[],methods:[{id:"oauth_a",type:"oauth",label:"Sign in"}]}]}; + if(url.pathname.endsWith("/oauth"))return {data:{attemptID:"attempt_exact",url:"https://example.com",instructions:"Enter code",mode:"code",time:{created:1,expires:2}}}; + if(method==="POST"){res.statusCode=204;return;} + }); + const authorization=await operate(client,"providers.authorize",{providerId:"p",body:{methodId:"oauth_a"}}) as any; + expect(authorization.attemptId).toBe("attempt_exact"); + await operate(client,"providers.callback",{providerId:"p",body:{attemptId:"attempt_exact",code:"code"}}); + expect(calls.at(-1)?.url.pathname).toContain("attempt_exact"); + mapped=false;const prior=calls.filter(call=>call.method==="POST").length;await expect(operate(client,"providers.authorize",{providerId:"p",body:{method:0}})).rejects.toThrow("unambiguous");expect(calls.filter(call=>call.method==="POST")).toHaveLength(prior); + }); + it("observes shell completion and reports nonzero exit instead of submission success",async()=>{ + let assigned="",reads=0; + const {client,calls}=await fixture(({url,method,body},res)=>{ + if(method==="POST"){assigned=body.id;res.statusCode=204;return;} + if(url.pathname.includes("/message/")) { if(reads++===0){res.statusCode=404;return{};}return{data:{id:assigned,type:"shell",command:"false",shellID:"sh_a",status:"exited",exit:1,time:{created:1,completed:2},output:{output:"failed",cursor:1,size:6,truncated:false}}}; } + return{data:session()}; + }); + const result=await operate(client,"messages.shell",{sessionId:"ses_a",body:{command:"false",agent:"build"}});expect(result.isError).toBe(true);expect(result.info.id).toBe(assigned);expect(result.parts[0].text).toContain("failed");expect(calls.filter(call=>call.method==="POST")).toHaveLength(1); + }); + it("filters project events by actual server location and releases the stream",async()=>{ + const {client}=await fixture(({url},res)=>{ + if(url.pathname==="/api/location")return{directory:"/canonical/project",project:{id:"prj",directory:"/canonical/project",canonical:"/canonical/project"}}; + res.setHeader("content-type","text/event-stream");res.end([{type:"server.connected"},{type:"session.created",location:{directory:"/other"},data:{}},{type:"session.updated",location:{directory:"/canonical/project"},data:{}}].map(event=>`data: ${JSON.stringify(event)}\n\n`).join("")); + }); + const received=[];for await(const event of events(client,"project",{directory:"/server/alias",timeout:1000})){received.push(event);break;} + expect(received.map(event=>event.event)).toEqual(["session.updated"]); + }); +}); + +describe("V2 completion and location boundary regressions",()=>{ + it("treats a failed observation after successful mutation as unknown with recovery identity",async()=>{ + let patched=false; + const {client}=await fixture(({method},res)=>{if(method==="PATCH"){patched=true;res.statusCode=204;return;}if(patched){res.statusCode=503;return{};}return{data:session()};}); + await expect(operate(client,"sessions.update",{sessionId:"ses_a",body:{title:"new title"}})).rejects.toMatchObject({name:"OpenCodeSubmissionError",sessionId:"ses_a"}); + }); + it("retains assigned shell identity when post-submission observation fails",async()=>{ + let assigned=""; + const {client}=await fixture(({method,url,body},res)=>{if(method==="POST"){assigned=body.id;res.statusCode=204;return;}if(url.pathname.includes("/message/")){res.statusCode=503;return{};}return{data:session()};}); + await expect(operate(client,"messages.shell",{sessionId:"ses_a",body:{command:"pwd",agent:"build"}})).rejects.toMatchObject({name:"OpenCodeSubmissionError",sessionId:"ses_a",messageId:expect.stringMatching(/^msg_/)});expect(assigned).toMatch(/^msg_/); + }); + it("keeps default project sessions and statuses scoped to the resolved location",async()=>{ + const {client,calls}=await fixture(({url})=>{ + if(url.pathname==="/api/location")return defaultLocation; + if(url.pathname==="/api/session")return{data:[session()],cursor:{}}; + if(url.pathname==="/api/session/active")return{data:{ses_a:{type:"running"},ses_other:{type:"running"}}}; + return{data:session({id:url.pathname.endsWith("ses_other")?"ses_other":"ses_a",location:{directory:url.pathname.endsWith("ses_other")?"/other/project":"/server/project"}})}; + }); + expect(await operate(client,"sessions.list")).toHaveLength(1);expect(calls.find(call=>call.url.pathname==="/api/session")?.url.searchParams.get("directory")).toBe("/server/project"); + expect(await operate(client,"sessions.status")).toEqual({ses_a:{type:"running"}}); + }); + it("uses compaction queue delivery and preserves the existing selection",async()=>{ + const {client,calls}=await fixture(({method,body})=>method==="GET"?{data:session()}:{data:{id:"inbox_compaction",sessionID:"ses_a",type:"compaction",delivery:body.delivery}}); + await operate(client,"sessions.compact",{sessionId:"ses_a",body:{providerID:"p",modelID:"m",variant:"fast"}});expect(calls.at(-1)?.body).toEqual({delivery:"queue"}); + await expect(operate(client,"sessions.compact",{sessionId:"ses_a",body:{providerID:"other",modelID:"m"}})).rejects.toBeInstanceOf(BackendCapabilityError);expect(calls.filter(call=>call.method==="POST")).toHaveLength(1); + }); + it("maps normalized tool content without inventing message parentage",async()=>{ + const {client}=await fixture(()=>({data:[{id:"msg_a",type:"user",text:"work",time:{created:1}},{id:"msg_b",type:"assistant",agent:"build",model:{providerID:"p",id:"m"},time:{created:2,completed:3},content:[{type:"tool",id:"call_a",name:"read",state:{status:"completed",input:{file:"x"},content:[{type:"text",text:"contents"}]},time:{created:2}}]}],cursor:{}})); + const messages=await operate(client,"messages.list",{sessionId:"ses_a"});expect(messages[1].info.parentID).toBeUndefined();expect(messages[1].parts[0]).toMatchObject({type:"tool",tool:"read",callID:"call_a"});expect(messages[1].raw?.backend).toBe("v2"); + }); +}); + +describe("V1 mutation fallback characterization",()=>{ + it.each([401,403,500,"lost"] as const)("does not replay permission responses after %s",async status=>{ + const {client:connection,calls}=await fixture((_,res)=>{if(status==="lost"){res.destroy();return;}res.statusCode=status;return {error:"rejected"};}); + const client=new OpenCodeClient({baseUrl:connection.getBaseUrl(),backend:"v1"}); + const handlers=new Map();registerSessionTools({tool:(...args:unknown[])=>handlers.set(String(args[0]),args.at(-1) as Function)} as unknown as McpServer,client); + const result=await handlers.get("opencode_session_permission")!({id:"ses_a",permissionID:"perm_a",reply:"once"}); + expect(result.isError).toBe(true);expect(calls).toHaveLength(1);expect(calls[0].url.pathname).toBe("/permission/perm_a/reply"); + }); +}); + +describe("public V2 preflight arguments",()=>{ + it.each([ + ["opencode_session_create",{parentID:""}], + ["opencode_session_revert",{id:"ses_a",messageID:"msg_a",partID:""}], + ["opencode_session_create",{agent:""}], + ["opencode_session_create",{variant:""}], + ["opencode_session_create",{providerID:"",modelID:""}], + ] as const)("does not discard unsupported empty arguments in %s",async(name,args)=>{ + const {client,calls}=await fixture(()=>({data:session()}));const handlers=new Map(); + registerSessionTools({tool:(...values:unknown[])=>handlers.set(String(values[0]),values.at(-1) as Function)} as unknown as McpServer,client); + const result=await handlers.get(name)!(args);expect(result.isError).toBe(true);expect(calls).toHaveLength(0); + }); +}); + +describe("V2 upstream implicit mutation safeguards",()=>{ + it.each(["messages.send","messages.enqueue","sessions.compact"] as const)("refuses %s while a reversible revert is staged",async operation=>{ + const {client,calls}=await fixture(()=>({data:session({revert:{messageID:"msg_a"}})})); + await expect(operate(client,operation,{sessionId:"ses_a",body:{}})).rejects.toMatchObject({code:"UNSUPPORTED_CAPABILITY",capability:"session.staged_revert"}); + expect(calls.map(call=>call.method)).toEqual(["GET"]); + }); +}); diff --git a/tests/v2-auth.test.ts b/tests/v2-auth.test.ts new file mode 100644 index 0000000..2520fe6 --- /dev/null +++ b/tests/v2-auth.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createServer, type ServerResponse } from "node:http"; +import { z } from "zod"; +import { OpenCodeClient } from "../src/client.js"; +import { operate } from "../src/backends/adapter.js"; +import { BackendCapabilityError } from "../src/backends/contracts.js"; +import { registerProviderTools } from "../src/tools/provider.js"; +import type { McpServer } from "../src/mcp-server.js"; + +// These tests use the installed V2 SDK transport against an isolated HTTP server. +// They do not authenticate to a real provider or claim a completed OAuth login. +type Call = { method: string; url: URL; body: Record | undefined }; +const cleanups: Array<() => Promise> = []; +afterEach(async () => { await Promise.all(cleanups.splice(0).map(close => close())); }); +const provider = { id: "provider", name: "Provider", integrationID: "integration", activation: "enabled" }; +const oauthMethod = { id: "oauth-primary", type: "oauth", label: "Sign in" }; +const integration = { id: "integration", name: "Integration", connections: [], methods: [{ type: "key", label: "API key" }, oauthMethod] }; +const authorization = { attemptID: "attempt-returned", url: "https://example.invalid/authorize", instructions: "Enter the authorization code", mode: "code", time: { created: 1, expires: 2 } }; + +async function fixture(options: { + providers?: unknown[]; + integrations?: unknown[]; + intercept?: (call: Call, res: ServerResponse) => boolean; +} = {}) { + const calls: Call[] = []; + const server = createServer(async (req, res) => { + const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const raw = Buffer.concat(chunks).toString(); + const call: Call = { method: req.method!, url: new URL(req.url!, "http://fixture.invalid"), body: raw ? JSON.parse(raw) : undefined }; + calls.push(call); res.setHeader("content-type", "application/json"); + if (options.intercept?.(call, res)) return; + if (call.method === "GET" && call.url.pathname === "/api/provider") res.end(JSON.stringify({ data: options.providers ?? [provider] })); + else if (call.method === "GET" && call.url.pathname === "/api/integration") res.end(JSON.stringify({ data: options.integrations ?? [integration] })); + else if (call.method === "POST" && call.url.pathname === "/api/integration/integration/connect/oauth") res.end(JSON.stringify({ data: authorization })); + else if (call.method === "POST") { res.statusCode = 204; res.end(); } + else { res.statusCode = 404; res.end("{}"); } + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); })); + const port = (server.address() as { port: number }).port; + const client = new OpenCodeClient({ baseUrl: `http://127.0.0.1:${port}`, backend: "v2" }); + const entries = new Map(); + registerProviderTools({ tool(name: string, _description: string, shape: z.ZodRawShape, ...rest: unknown[]) { + entries.set(name, { shape, handler: rest.at(-1) as Function }); + } } as unknown as McpServer, client); + const tool = (name: string, input: unknown) => { const entry = entries.get(name)!; return entry.handler(z.object(entry.shape).parse(input)); }; + return { client, calls, tool, entries, writes: () => calls.filter(call => call.method !== "GET") }; +} + +const authWrites = ["authorize", "callback", "setAuth"] as const; +function write(client: OpenCodeClient, operation: typeof authWrites[number], integrationId?: string) { + const common = { providerId: "provider", directory: "/must-not-scope-global-auth" }; + if (operation === "authorize") return operate(client, "providers.authorize", { ...common, body: { methodId: "oauth-primary", integrationId } }); + if (operation === "callback") return operate(client, "providers.callback", { ...common, body: { attemptId: "attempt-returned", integrationId, code: "fixture-code" } }); + return operate(client, "providers.setAuth", { ...common, body: { type: "api", key: "fixture-key", integrationId } }); +} + +describe("V2 global provider authentication through the actual SDK HTTP transport", () => { + it.each(authWrites)("keeps %s discovery and mutation global even with a directory argument", async operation => { + const { client, calls, writes } = await fixture(); + await write(client, operation, "integration"); + expect(calls.slice(0, 2).map(call => call.url.pathname)).toEqual(["/api/provider", "/api/integration"]); + expect(writes()).toHaveLength(1); + expect(calls.every(call => call.url.search === "")).toBe(true); + expect(calls.every(call => !call.body || !("location" in call.body) && !("directory" in call.body))).toBe(true); + const expected = operation === "authorize" ? ["/api/integration/integration/connect/oauth", { methodID: "oauth-primary" }] + : operation === "callback" ? ["/api/integration/integration/connect/oauth/attempt-returned/complete", { code: "fixture-code" }] + : ["/api/integration/integration/connect/key", { key: "fixture-key" }]; + expect([writes()[0].url.pathname, writes()[0].body]).toEqual(expected); + }); + + it("exposes method IDs, integration IDs and typed forms through the public discovery tool", async () => { + const form = [{ key: "tenant", type: "string", required: true }]; + const { tool, entries } = await fixture({ integrations: [{ ...integration, methods: [{ ...oauthMethod, form }] }] }); + const result = await tool("opencode_provider_auth_methods", {}); + expect(result.structuredContent.data.provider).toEqual([{ ...oauthMethod, form, integrationID: "integration" }]); + for (const name of ["opencode_provider_oauth_authorize", "opencode_provider_oauth_callback", "opencode_auth_set"]) { + expect(entries.get(name)!.shape).not.toHaveProperty("directory"); + } + }); + + it.each(authWrites)("rejects duplicate provider mappings before %s writes", async operation => { + const { client, writes } = await fixture({ providers: [provider, { ...provider, integrationID: "other-integration" }] }); + await expect(write(client, operation)).rejects.toThrow("unambiguous"); + expect(writes()).toHaveLength(0); + }); + + it.each(authWrites)("rejects duplicate integration mappings before %s writes", async operation => { + const { client, writes } = await fixture({ integrations: [integration, { ...integration, name: "Conflicting integration" }] }); + await expect(write(client, operation)).rejects.toThrow(/unambiguous|ambiguous/i); + expect(writes()).toHaveLength(0); + }); + + it.each(authWrites)("rejects an explicit integration that contradicts the provider before %s writes", async operation => { + const { client, calls, writes } = await fixture(); + await expect(write(client, operation, "other-integration")).rejects.toThrow("does not match"); + expect(calls).toHaveLength(1); expect(writes()).toHaveLength(0); + }); + + it.each([ + { label: "missing provider", providers: [], integrations: [integration] }, + { label: "missing provider integration ID", providers: [{ ...provider, integrationID: undefined }], integrations: [integration] }, + { label: "missing integration", providers: [provider], integrations: [] }, + ])("rejects $label instead of inferring an integration", async options => { + const { client, writes } = await fixture(options); + await expect(write(client, "authorize")).rejects.toThrow(); expect(writes()).toHaveLength(0); + }); + + it.each([401, 403])("stops discovery on authentication rejection %i without an auth write", async status => { + const { client, calls, writes } = await fixture({ intercept: (_call, res) => { res.statusCode = status; res.end("{}"); return true; } }); + await expect(write(client, "authorize")).rejects.toMatchObject({ status }); + expect(calls).toHaveLength(1); expect(writes()).toHaveLength(0); + }); +}); + +describe("V2 OAuth method, typed answer and attempt contracts", () => { + const form = [ + { key: "count", type: "integer", required: true, minimum: 1 }, + { key: "enabled", type: "boolean", required: true }, + { key: "region", type: "string", required: true, options: [{ value: "eu", label: "Europe" }] }, + { key: "scopes", type: "multiselect", required: true, options: [{ value: "read", label: "Read" }] }, + ]; + const withForm = { ...integration, methods: [{ type: "key" }, { ...oauthMethod, form }] }; + + it("selects the explicit OAuth method ID and returns the exact attempt ID with typed values unchanged", async () => { + const { tool, writes } = await fixture({ integrations: [withForm] }); + const values = { count: 2, enabled: false, region: "eu", scopes: ["read"] }; + const result = await tool("opencode_provider_oauth_authorize", { providerId: "provider", integrationId: "integration", method: 0, methodId: "oauth-primary", values }); + expect(result.isError).not.toBe(true); + expect(result.structuredContent.data).toMatchObject({ ...authorization, attemptId: "attempt-returned", integrationId: "integration", methodId: "oauth-primary" }); + expect(writes()[0].body).toEqual({ methodID: "oauth-primary", answer: values }); + }); + + it.each([ + { count: "2", enabled: false, region: "eu", scopes: ["read"] }, + { count: 2.5, enabled: false, region: "eu", scopes: ["read"] }, + { count: 2, enabled: "false", region: "eu", scopes: ["read"] }, + { count: 2, enabled: false, region: "other", scopes: ["read"] }, + { count: 2, enabled: false, region: "eu", scopes: ["write"] }, + { count: 2, enabled: false, region: "eu", scopes: ["read"], unknown: "value" }, + ])("rejects invalid typed authorization values before submission: %j", async values => { + const { client, writes } = await fixture({ integrations: [withForm] }); + await expect(operate(client, "providers.authorize", { providerId: "provider", body: { methodId: "oauth-primary", values } })).rejects.toThrow(/form field/); + expect(writes()).toHaveLength(0); + }); + + it("does not infer missing required answers from form defaults", async () => { + const { client, writes } = await fixture({ integrations: [{ ...integration, methods: [{ ...oauthMethod, form: [{ key: "consent", type: "boolean", required: true, default: true }] }] }] }); + await expect(operate(client, "providers.authorize", { providerId: "provider", body: { methodId: "oauth-primary" } })).rejects.toThrow("Missing required field"); + expect(writes()).toHaveLength(0); + }); + + it("accepts the published OAuth method index when no method ID is supplied", async () => { + const { client, writes } = await fixture(); + await operate(client, "providers.authorize", { providerId: "provider", body: { method: 1 } }); + expect(writes()[0].body).toEqual({ methodID: "oauth-primary" }); + }); + + it.each([{ method: 0 }, { method: 5 }, { methodId: "missing" }])("rejects a key or unknown OAuth method without submitting: %j", async body => { + const { client, writes } = await fixture(); + await expect(operate(client, "providers.authorize", { providerId: "provider", body })).rejects.toThrow("Select an OAuth method"); + expect(writes()).toHaveLength(0); + }); + + it("requires callback attempt identity before any discovery or mutation", async () => { + const { tool, calls } = await fixture(); + const result = await tool("opencode_provider_oauth_callback", { providerId: "provider", callbackData: { method: 1, code: "fixture-code" } }); + expect(result.isError).toBe(true); expect(JSON.stringify(result)).toContain("attemptId"); expect(calls).toHaveLength(0); + }); + + it("uses exactly the returned attempt in the public authorize/callback workflow", async () => { + const { tool, writes } = await fixture(); + const started = await tool("opencode_provider_oauth_authorize", { providerId: "provider", methodId: "oauth-primary" }); + const { attemptId, integrationId } = started.structuredContent.data; + const completed = await tool("opencode_provider_oauth_callback", { providerId: "provider", callbackData: { attemptId, integrationId, method: 99, code: "fixture-code" } }); + expect(completed.isError).not.toBe(true); + expect(writes().map(call => call.url.pathname)).toEqual(["/api/integration/integration/connect/oauth", "/api/integration/integration/connect/oauth/attempt-returned/complete"]); + expect(writes()[1].body).toEqual({ code: "fixture-code" }); + }); + + it("does not fall back to a method index or retry when the exact attempt is rejected", async () => { + const { client, writes } = await fixture({ intercept: (call, res) => { + if (!call.url.pathname.endsWith("/complete")) return false; + res.statusCode = 404; res.end(JSON.stringify({ _tag: "IntegrationAttemptNotFoundError", integrationID: "integration", attemptID: "attempt-missing", message: "Unknown attempt" })); return true; + } }); + await expect(operate(client, "providers.callback", { providerId: "provider", body: { attemptId: "attempt-missing", method: 1 } })).rejects.toMatchObject({ status: 404 }); + expect(writes()).toHaveLength(1); expect(writes()[0].url.pathname).toBe("/api/integration/integration/connect/oauth/attempt-missing/complete"); + }); + + it("rejects non-key integrations and non-API auth types without writing", async () => { + const { client, calls, writes } = await fixture({ integrations: [{ ...integration, methods: [oauthMethod] }] }); + await expect(operate(client, "providers.setAuth", { providerId: "provider", body: { type: "oauth", key: "fixture-key" } })).rejects.toBeInstanceOf(BackendCapabilityError); + expect(calls).toHaveLength(0); + await expect(operate(client, "providers.setAuth", { providerId: "provider", body: { type: "api", key: "fixture-key" } })).rejects.toThrow("key authentication method"); + expect(writes()).toHaveLength(0); + }); +}); diff --git a/tests/v2-jobs-http.test.ts b/tests/v2-jobs-http.test.ts new file mode 100644 index 0000000..484872a --- /dev/null +++ b/tests/v2-jobs-http.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createServer, type ServerResponse } from "node:http"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { OpenCodeClient } from "../src/client.js"; +import { JobService } from "../src/jobs.js"; +import { operate } from "../src/backends/adapter.js"; + +// Protocol fixture, not a claim of live native inference. The real published +// SDK parses these HTTP envelopes and SSE frames; no SDK/adapter methods mock. +let root: string; +const cleanups: Array<() => Promise> = []; +beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "opencode-v2-http-jobs-")); }); +afterEach(async () => { await Promise.all(cleanups.splice(0).map(cleanup => cleanup())); await rm(root, { recursive: true, force: true }); }); +async function fixture() { + const sessionID = "ses_http_fixture"; + const session = { id: sessionID, title: "HTTP fixture", projectID: "prj_fixture", location: { directory: "/remote/project" }, + model: { providerID: "fixture", id: "model" }, agent: "build", time: { created: 1, updated: 1 } }; + const calls: Array<{ method: string; path: string; query: URLSearchParams; body: any }> = []; + const events: any[] = []; + const inbox = new Map(); + const messages = new Map(); + let loseReceipt = false; + const event = (type: string, data: any = {}) => events.push({ id: `evt_${events.length + 1}`, type: `session.${type}`, created: 1, + durable: { aggregateID: sessionID, seq: events.length + 1, version: 1 }, data: { sessionID, ...data } }); + const json = (res: ServerResponse, data: unknown) => { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ data })); }; + const server = createServer(async (req, res) => { + if (req.headers.authorization !== `Basic ${Buffer.from("fixture:local-only").toString("base64")}`) { res.writeHead(401); res.end(); return; } + const url = new URL(req.url!, "http://fixture"); const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.from(chunk)); + const text = Buffer.concat(chunks).toString(); const body = text ? JSON.parse(text) : undefined; + calls.push({ method: req.method!, path: url.pathname, query: url.searchParams, body }); + if (req.method === "POST" && url.pathname === "/api/session") return json(res, session); + if (req.method === "GET" && url.pathname === `/api/session/${sessionID}`) return json(res, session); + if (url.pathname === `/api/session/${sessionID}/prompt` && req.method === "POST") { + const receipt = { id: body.id, sessionID, time: { created: 1 }, type: "user", delivery: body.delivery, payload: { text: body.text } }; + inbox.set(body.id, receipt); event("inbox.enqueued", { inboxID: body.id, item: { type: "user", delivery: body.delivery, payload: { text: body.text } } }); + if (loseReceipt) { res.destroy(); return; } + return json(res, receipt); + } + if (url.pathname === `/api/experimental/session/${sessionID}/log`) { + const after = Number(url.searchParams.get("after") ?? 0); + res.setHeader("content-type", "text/event-stream"); + const replay = [...events.filter(value => value.durable.seq > after), { type: "log.synced", aggregateID: sessionID, seq: events.length }]; + // CRLF and split writes exercise the real SDK's framing, not a direct iterator. + const frames = replay.map(value => `data: ${JSON.stringify(value)}\r\n\r\n`).join(""); + res.write(frames.slice(0, 37)); res.end(frames.slice(37)); return; + } + if (url.pathname === `/api/session/${sessionID}/inbox` && req.method === "GET") return json(res, [...inbox.values()]); + if ([`/api/session/${sessionID}/form`, `/api/session/${sessionID}/permission`].includes(url.pathname)) return json(res, []); + if (url.pathname.startsWith(`/api/session/${sessionID}/message/`)) { + const id = decodeURIComponent(url.pathname.split("/").at(-1)!); + if (!messages.has(id)) { res.statusCode = 404; return json(res, { message: "Projection absent" }); } + return json(res, messages.get(id)); + } + res.statusCode = 404; json(res, { message: "Unexpected fixture route" }); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + cleanups.push(() => new Promise(resolve => { server.closeAllConnections(); server.close(() => resolve()); })); + const baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const client = () => new OpenCodeClient({ baseUrl, backend: "v2", username: "fixture", password: "local-only" }); + return { client, sessionID, calls, events, loseReceipt: () => { loseReceipt = true; }, + deliver(id: string) { + const receipt = inbox.get(id); expect(receipt).toBeDefined(); inbox.delete(id); + event("execution.started"); event("inbox.delivered", { inboxID: id }); + messages.set(id, { id, type: "user", text: receipt.payload.text, time: { created: 2 } }); + event("step.started", { assistantMessageID: "msg_assistant_http" }); + messages.set("msg_assistant_http", { id: "msg_assistant_http", type: "assistant", content: [{ type: "text", text: "Partial response" }], time: { created: 3 } }); + }, + finish() { + messages.set("msg_assistant_http", { id: "msg_assistant_http", type: "assistant", content: [{ type: "text", text: "Completed response" }], time: { created: 3, completed: 4 }, finish: "stop" }); + event("step.ended", { assistantMessageID: "msg_assistant_http", finish: "stop", cost: 0, tokens: {} }); event("execution.succeeded"); + }, + }; +} +async function record(jobId: string) { + const namespace = join(root, (await readdir(root))[0]); + return JSON.parse(await readFile(join(namespace, "v2", `${jobId}.json`), "utf8")); +} + +describe("V2 jobs through the actual SDK HTTP transport", () => { + it("persists the admission receipt and preserves assigned message identity through user projection and restart", async () => { + const f = await fixture(); const firstClient = f.client(); const first = new JobService(firstClient, { storeRoot: root }); + const job = await first.start({ prompt: "A fixture task", title: "HTTP fixture" }); + expect(job.status).toBe("accepted"); + const prompt = f.calls.find(call => call.path.endsWith("/prompt"))!; + expect(prompt.body).toEqual({ id: job.messageId, text: "A fixture task", delivery: "queue" }); + expect(await record(job.jobId!)).toMatchObject({ receipt: { id: job.messageId, sessionID: f.sessionID } }); + // The queued entry is not a projected user message yet. This is not job failure. + await expect(operate(firstClient, "messages.get", { sessionId: f.sessionID, messageId: job.messageId })).rejects.toMatchObject({ status: 404 }); + expect((await first.get(job.jobId!)).status).toBe("accepted"); + f.deliver(job.messageId!); + const user = await operate(firstClient, "messages.get", { sessionId: f.sessionID, messageId: job.messageId }); + expect(user.info).toMatchObject({ id: job.messageId, role: "user" }); + expect((await first.get(job.jobId!)).status).toBe("running"); + const checkpoint = await record(job.jobId!); expect(checkpoint.observation.delivered).toBe(true); expect(checkpoint.observation.userProjectionValidated).toBe(true); + f.finish(); + const restarted = new JobService(f.client(), { storeRoot: root }); const result = await restarted.get(job.jobId!); + expect(result).toMatchObject({ backend: "v2", status: "completed", result: { info: { id: "msg_assistant_http", parentID: job.messageId } } }); + expect(f.calls.filter(call => call.path.endsWith("/log")).at(-1)!.query.get("after")).toBe(String(checkpoint.observation.cursor)); + expect(f.calls.filter(call => call.method === "POST" && call.path.endsWith("/prompt"))).toHaveLength(1); + }); + it("recovers a server-admitted prompt after socket loss without resending the mutation", async () => { + const f = await fixture(); f.loseReceipt(); const first = new JobService(f.client(), { storeRoot: root }); + const job = await first.start({ prompt: "Lost receipt fixture", sessionId: f.sessionID }); + expect(job.status).toBe("unknown"); expect((await record(job.jobId!)).receipt).toBeUndefined(); + const restarted = new JobService(f.client(), { storeRoot: root }); expect((await restarted.get(job.jobId!)).status).toBe("accepted"); + f.deliver(job.messageId!); f.finish(); + const result = await new JobService(f.client(), { storeRoot: root }).get(job.jobId!); + expect(result).toMatchObject({ status: "completed", messageId: job.messageId }); + expect(f.calls.filter(call => call.method === "POST")).toHaveLength(1); + }); +}); diff --git a/tests/v2-jobs.test.ts b/tests/v2-jobs.test.ts new file mode 100644 index 0000000..a26a4b2 --- /dev/null +++ b/tests/v2-jobs.test.ts @@ -0,0 +1,247 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { JobService, observeSession } from "../src/jobs.js"; +import { OpenCodeClient, OpenCodeError } from "../src/client.js"; +import { backend } from "../src/backends/adapter.js"; +import { BackendCapabilityError } from "../src/backends/contracts.js"; +import { initialV2Observation, observeV2Session } from "../src/backends/v2-observation.js"; +import { withRequestOptions } from "../src/async.js"; + +let root: string; +beforeEach(async () => { root = await mkdtemp(join(tmpdir(), "opencode-v2-jobs-")); }); +afterEach(async () => { vi.restoreAllMocks(); await rm(root, { recursive: true, force: true }); }); +const identity = { kind: "v2", version: "2.0.6", connectionSource: "explicit", processOwnership: "external", survivesDisconnect: true }; +function fixture() { + const events: any[] = []; + const messages: Record = {}; + let inbox: any[] = [], forms: any[] = [], permissions: any[] = []; + let kind = "v2"; + const client = { getBaseUrl: () => "http://fixture:4096", getBackendIdentity: () => ({ ...identity, kind }), connectionHeaders: {} } as unknown as OpenCodeClient; + const api: any = { + session: { + get: vi.fn(async () => ({ id: "session", title: "Fixture", location: { directory: "/remote/project" }, time: { created: 1, updated: 1 }, projectID: "project" })), + log: vi.fn(({ after }: any) => (async function* () { for (const event of events) if (event.durable.seq > after) yield event; yield { type: "log.synced", aggregateID: "session", seq: events.at(-1)?.durable.seq ?? 0 }; })()), + inbox: { list: vi.fn(async () => inbox), cancel: vi.fn(async () => ({})) }, + message: { get: vi.fn(async ({ messageID }: any) => { if (!messages[messageID]) throw new OpenCodeError("Not projected", 404, "GET", "/message", ""); return messages[messageID]; }) }, + form: { list: vi.fn(async () => forms) }, + interrupt: vi.fn(async () => ({ interrupted: true })), + }, + permission: { list: vi.fn(async () => permissions) }, + message: { list: vi.fn(async () => ({ data: [], cursor: {} })) }, + }; + const adapter = backend(client); + Object.defineProperty(adapter, "api", { value: api }); + const execute = vi.spyOn(adapter, "execute").mockImplementation(async (operation, args) => { + const body = args.body as any; + if (operation === "sessions.create") return { id: "session" }; + if (operation === "messages.enqueue") { inbox.push({ id: body.messageID }); return { id: body.messageID, sessionID: "session" }; } + if (operation === "permissions.reply") { permissions = []; return {}; } + if (operation === "forms.reply" || operation === "forms.reject") { forms = []; return {}; } + throw new Error(`Unexpected ${operation}`); + }); + const event = (type: string, data: any = {}) => { events.push({ type: `session.${type}`, durable: { aggregateID: "session", seq: events.length + 1, version: 1 }, data: { sessionID: "session", ...data } }); }; + const enqueue = (id: string, delivery = "queue", type = "user") => event("inbox.enqueued", { inboxID: id, item: { type, delivery } }); + const deliver = (id: string) => { event("inbox.delivered", { inboxID: id }); inbox = inbox.filter(item => item.id !== id); messages[id] = { id, type: "user", text: "Fixture prompt", time: { created: 1 } }; }; + const step = (id: string, finish = "stop") => { + event("step.started", { assistantMessageID: id }); + messages[id] = { id, type: "assistant", time: { created: 2, completed: 3 }, content: [{ type: "text", text: `Result ${id}` }], finish }; + event("step.ended", { assistantMessageID: id, finish }); + }; + return { client, api, execute, events, event, enqueue, deliver, step, messages, + kind: (value: string) => { kind = value; }, inbox: (value: any[]) => { inbox = value; }, + forms: (value: any[]) => { forms = value; }, permissions: (value: any[]) => { permissions = value; } }; +} +async function records() { const namespace = join(root, (await readdir(root))[0]); const path = join(namespace, "v2"); return Promise.all((await readdir(path)).map(async name => JSON.parse(await readFile(join(path, name), "utf8")))); } +async function running(f: ReturnType, service: JobService) { + const job = await service.start({ prompt: "private task", sessionId: "session" }); + f.enqueue(job.messageId!); f.event("execution.started"); f.deliver(job.messageId!); + return job; +} + +describe("V2 durable observation", () => { + it("does not attribute a prior turn's inputs or completion to a queued job", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("earlier"); f.step("earlier-result"); f.event("execution.succeeded"); f.inbox([{ id: "target" }]); + f.forms([{ id: "other", sessionID: "session" }]); + const observed = await observeV2Session(f.api, "session", "target"); + expect(observed.snapshot).toMatchObject({ backend: "v2", status: "accepted", inputs: [] }); + expect(f.api.session.message.get).not.toHaveBeenCalled(); + expect(f.api.session.form.list).not.toHaveBeenCalled(); + }); + it("completes two queued turns drained within one execution independently", async () => { + const f = fixture(); f.enqueue("first"); f.enqueue("second"); f.event("execution.started"); f.deliver("first"); f.step("answer1"); f.deliver("second"); f.step("answer2"); f.event("execution.succeeded"); + const first = await observeV2Session(f.api, "session", "first"); + const second = await observeV2Session(f.api, "session", "second"); + expect(first.snapshot).toMatchObject({ status: "completed", result: { info: { id: "answer1", parentID: "first" } } }); + expect(second.snapshot).toMatchObject({ status: "completed", result: { info: { id: "answer2", parentID: "second" } } }); + }); + it.each(["tool-calls", "unknown", "error"])("does not treat %s as a final queued turn boundary", async finish => { + const f = fixture(); f.enqueue("target"); f.enqueue("next"); f.event("execution.started"); f.deliver("target"); f.step("partial", finish); f.deliver("next"); f.step("other"); f.event("execution.succeeded"); + expect((await observeV2Session(f.api, "session", "target")).snapshot.status).toBe("unknown"); + }); + it("does not attribute a steered turn even after a final step", async () => { + const f = fixture(); f.enqueue("target"); f.enqueue("steer", "steer"); f.event("execution.started"); f.deliver("target"); f.step("first"); f.deliver("steer"); f.step("other"); f.event("execution.succeeded"); + const observed = await observeV2Session(f.api, "session", "target"); + expect(observed.snapshot.status).toBe("unknown"); expect(observed.state.ownershipLost).toBe(true); + expect(observed.state.assistantIds).toEqual(["first"]); + }); + it.each([{ id: "wrong", type: "user" }, { id: "target", type: "assistant" }])("refuses mismatched delivered user projection %j", async projection => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("target"); f.step("answer"); f.event("execution.succeeded"); + f.messages.target = projection; + const result = await observeV2Session(f.api, "session", "target"); + expect(result.snapshot.status).toBe("unknown"); expect(result.state.userProjectionValidated).not.toBe(true); + expect(result.snapshot.result).toBeUndefined(); expect(result.snapshot.inputs).toEqual([]); + }); + it("recovers a delayed user projection before claiming terminal output", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("target"); f.step("answer"); f.event("execution.succeeded"); + const projected = f.messages.target; delete f.messages.target; + const before = await observeV2Session(f.api, "session", "target"); + expect(before.snapshot.status).toBe("unknown"); expect(before.state.userProjectionValidated).not.toBe(true); + f.messages.target = projected; + const after = await observeV2Session(f.api, "session", "target", {}, before.state); + expect(after.snapshot.status).toBe("completed"); expect(after.state.userProjectionValidated).toBe(true); + }); + it("withholds form controls until delivered user projection validates", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("target"); delete f.messages.target; + f.forms([{ id: "form", sessionID: "session" }]); + const before = await observeV2Session(f.api, "session", "target"); + expect(before.snapshot).toMatchObject({ status: "unknown", inputs: [] }); expect(f.api.session.form.list).not.toHaveBeenCalled(); + f.messages.target = { id: "target", type: "user", text: "Fixture prompt", time: { created: 1 } }; + expect((await observeV2Session(f.api, "session", "target", {}, before.state)).snapshot.status).toBe("input_required"); + }); + it("preserves delivery and assistant correlation across restart", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("target"); f.step("answer"); + const before = await observeV2Session(f.api, "session", "target"); + f.event("execution.succeeded"); + const after = await observeV2Session(f.api, "session", "target", {}, JSON.parse(JSON.stringify(before.state))); + expect(after.snapshot.status).toBe("completed"); + expect(f.api.session.log.mock.calls.at(-1)[0].after).toBe(before.state.cursor); + }); + it("recovers shutdown interruption without replaying admission", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("target"); f.event("execution.interrupted", { reason: "shutdown" }); + const before = await observeV2Session(f.api, "session", "target"); expect(before.snapshot.status).toBe("unknown"); + f.event("execution.started"); f.step("answer"); f.event("execution.succeeded"); + expect((await observeV2Session(f.api, "session", "target", {}, before.state)).snapshot.status).toBe("completed"); + }); + it("retains turn ownership across explicit compaction control delivery", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.deliver("target"); f.step("partial", "tool-calls"); f.enqueue("compact", "queue", "compaction"); f.deliver("compact"); f.step("final"); f.event("execution.succeeded"); + expect((await observeV2Session(f.api, "session", "target")).snapshot).toMatchObject({ status: "completed", result: { info: { id: "final" } } }); + }); + it.each([NaN, -1, 0.5])("rejects malformed sync sequence %s", async seq => { + const f = fixture(); f.api.session.log.mockImplementation(() => (async function* () { yield { type: "log.synced", aggregateID: "session", seq }; })()); + const result = await observeV2Session(f.api, "session", "target"); expect(result.snapshot.status).toBe("unknown"); expect(result.state.cursor).toBe(0); expect(result.state.synced).toBe(false); + }); + it("accepts native filtered sequence gaps and advances the validated sync cursor", async () => { + const f = fixture(); f.enqueue("target"); f.event("execution.started"); f.events[1].durable.seq = 3; + const observed = await observeV2Session(f.api, "session", "target"); expect(observed.state.cursor).toBe(3); expect(observed.state.synced).toBe(true); + }); +}); + +describe("V2 durable job controls", () => { + it("persists assigned message identity before dispatch, excludes prompt text, and rejects unsupported format before any writes", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); + const original = f.execute.getMockImplementation()!; + f.execute.mockImplementation(async (operation, args) => { if (operation === "messages.enqueue") { const [record] = await records(); expect(record).toMatchObject({ version: 2, messageId: (args.body as any).messageID, sessionId: "session" }); expect(JSON.stringify(record)).not.toContain("private task"); } return original(operation, args); }); + const job = await service.start({ prompt: "private task" }); expect(job.status).toBe("accepted"); + expect((await records())[0].receipt).toEqual({ id: job.messageId, sessionID: "session" }); + const calls = f.execute.mock.calls.length; + await expect(service.start({ prompt: "x", format: { type: "json_schema", schema: {} } })).rejects.toThrow(); + expect(f.execute).toHaveBeenCalledTimes(calls); expect(await records()).toHaveLength(1); + }); + it("recovers ambiguous admission from durable replay without resubmission", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); + f.execute.mockImplementation(async (operation, args) => { if (operation === "messages.enqueue") { f.enqueue((args.body as any).messageID); throw new Error("lost receipt"); } return { id: "session" }; }); + const job = await service.start({ prompt: "x", sessionId: "session" }); expect(job.status).toBe("unknown"); + f.event("execution.started"); f.deliver(job.messageId!); f.step("answer"); f.event("execution.succeeded"); + const restarted = new JobService(f.client, { storeRoot: root }); expect((await restarted.get(job.jobId!)).status).toBe("completed"); expect(f.execute).toHaveBeenCalledTimes(1); + }); + it("does not accept a mismatched admission receipt", async () => { + const f = fixture(); f.execute.mockResolvedValue({ id: "wrong", sessionID: "session" }); + expect((await new JobService(f.client, { storeRoot: root }).start({ prompt: "x", sessionId: "session" })).status).toBe("unknown"); + }); + it("retains actionable capability errors", async () => { + const f = fixture(); f.execute.mockRejectedValue(new BackendCapabilityError("model.selection", "Use session selection", "v2")); + const job = await new JobService(f.client, { storeRoot: root }).start({ prompt: "x", sessionId: "session" }); + expect(job).toMatchObject({ status: "failed", error: { code: "UNSUPPORTED_CAPABILITY", backend: "v2" } }); + }); + it("cancels only its queued inbox entry while another turn is active", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await service.start({ prompt: "x", sessionId: "session" }); + f.enqueue(job.messageId!); f.event("execution.started"); f.deliver("unrelated"); + expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); expect(f.api.session.interrupt).not.toHaveBeenCalled(); + expect(f.api.session.inbox.cancel).toHaveBeenCalledWith({ sessionID: "session", inboxID: job.messageId }, expect.anything()); + }); + it("refuses active cancellation until user projection ownership validates", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + delete f.messages[job.messageId!]; + await expect(service.cancel(job.jobId!)).rejects.toThrow("ownership is not established"); + expect(f.api.session.interrupt).not.toHaveBeenCalled(); expect((await records())[0].cancelRequested).toBeUndefined(); + }); + it("refuses other queued work before recording intent and permits later cancellation", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + f.inbox([{ id: "other" }]); await expect(service.cancel(job.jobId!)).rejects.toThrow("Other queued work"); + expect((await records())[0].cancelRequested).toBeUndefined(); expect(f.api.session.interrupt).not.toHaveBeenCalled(); + f.inbox([]); expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); + }); + it("bounds a hung cancellation and never replays its ambiguous mutation", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + f.api.session.interrupt.mockImplementation(() => new Promise(() => {})); + expect((await withRequestOptions({ timeout: 30 }, () => service.cancel(job.jobId!))).status).toBe("unknown"); + expect((await service.cancel(job.jobId!)).status).toBe("unknown"); expect(f.api.session.interrupt).toHaveBeenCalledTimes(1); + }); + it("allows retry after a definite nested SDK cancellation rejection", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + f.api.session.interrupt.mockRejectedValueOnce({ cause: { cause: new OpenCodeError("Rejected", 409, "POST", "/interrupt", "") } }); + expect((await service.cancel(job.jobId!)).status).toBe("unknown"); expect((await records())[0].cancelRequested).toBe(false); + expect((await service.cancel(job.jobId!)).status).toBe("cancelled"); + }); + it("allows corrected permission scope after a local rejection without poisoning receipts", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + f.permissions([{ id: "permission", sessionID: "session", action: "shell", resources: [], save: [] }]); + await expect(service.update(job.jobId!, [{ id: "permission", kind: "permission", reply: "reject" }])).rejects.toThrow("scope: session"); + expect((await records())[0].responses).toEqual({}); + await service.update(job.jobId!, [{ id: "permission", kind: "permission", reply: "reject", scope: "session" }]); + expect((await records())[0].responses["permission:permission"].state).toBe("sent"); + }); + it("validates typed forms before receipt persistence and deduplicates the same response across services", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + const form = { id: "form", sessionID: "session", fields: [{ key: "count", type: "integer", required: true, minimum: 1 }, { key: "approve", type: "boolean", required: true }] }; + f.forms([form]); + await expect(service.update(job.jobId!, [{ id: "form", kind: "question", values: { count: "two", approve: true } }])).rejects.toThrow("Invalid value"); + expect((await records())[0].responses).toEqual({}); + const response = { id: "form", kind: "question" as const, values: { count: 2, approve: false } }; + const another = new JobService(f.client, { storeRoot: root }); + await Promise.all([service.update(job.jobId!, [response]), another.update(job.jobId!, [response])]); + expect(f.execute.mock.calls.filter(([operation]) => operation === "forms.reply")).toHaveLength(1); + }); + it("permits correction after a known adapter preflight error and blocks uncertain input replay", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); + f.permissions([{ id: "permission", sessionID: "session", action: "shell", resources: [], save: [] }]); + const original = f.execute.getMockImplementation()!; + let failure: unknown = new BackendCapabilityError("permission.scope", "Explicit scope required"); + f.execute.mockImplementation(async (operation, args) => { if (operation === "permissions.reply") throw failure; return original(operation, args); }); + const response = { id: "permission", kind: "permission" as const, reply: "once" as const }; + await expect(service.update(job.jobId!, [response])).rejects.toThrow("Explicit scope"); + expect((await records())[0].responses).toEqual({}); + failure = new Error("Connection lost after write"); + await expect(service.update(job.jobId!, [response])).rejects.toThrow(); + expect((await records())[0].responses["permission:permission"].state).toBe("unknown"); + const calls = f.execute.mock.calls.length; + await expect(service.update(job.jobId!, [response])).rejects.toThrow("cannot be safely resent"); expect(f.execute).toHaveBeenCalledTimes(calls); + }); + it("combines backend records, rejects active backend mismatch, and reads cached terminal results", async () => { + const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const active = await service.start({ prompt: "x", sessionId: "session" }); + const finished = await running(f, service); f.step("answer"); f.event("execution.succeeded"); await service.get(finished.jobId!); + f.kind("v1"); const v1 = { getBaseUrl: f.client.getBaseUrl, post: vi.fn(async (path: string) => path === "/session" ? { id: "v1session" } : undefined) } as unknown as OpenCodeClient; + const legacy = await new JobService(v1, { storeRoot: root }).start({ prompt: "legacy" }); + expect((await service.list()).map(job => job.backend).sort()).toEqual(["v1", "v2", "v2"]); + await expect(service.get(active.jobId!)).rejects.toThrow("recorded backend"); expect((await service.get(finished.jobId!)).status).toBe("completed"); + const namespace = join(root, (await readdir(root))[0]); const record = JSON.parse(await readFile(join(namespace, `${legacy.jobId}.json`), "utf8")); + expect(record.version).toBe(1); expect(record.backendIdentity).toBeUndefined(); expect(record.observation).toBeUndefined(); + }); + it("bounds inherited observation deadlines and rejects a different remote directory", async () => { + const f = fixture(); f.api.message.list.mockImplementation(() => new Promise(() => {})); + await expect(withRequestOptions({ timeout: 20 }, () => observeSession(f.client, "session"))).rejects.toThrow("deadline"); + await expect(observeSession(f.client, "session", "/wrong", "target")).rejects.toThrow("different server directory"); + }); +}); diff --git a/tests/v2-task-transport.test.ts b/tests/v2-task-transport.test.ts new file mode 100644 index 0000000..64bd650 --- /dev/null +++ b/tests/v2-task-transport.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Transport } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { TaskTransport, TASKS_EXTENSION } from "../src/task-transport.js"; +import { JobService } from "../src/jobs.js"; +import { OpenCodeClient } from "../src/client.js"; +import { backend } from "../src/backends/adapter.js"; +import { V2Adapter } from "../src/backends/v2.js"; +import { setModelDefaults } from "../src/helpers.js"; +import { registerInputTools } from "../src/tools/input.js"; +import { registerWorkflowTools } from "../src/tools/workflow.js"; +import type { McpServer } from "../src/mcp-server.js"; + +// Protocol/adapter integration fixtures, not a live backend or GUI assertion. +const capable = { extensions: { [TASKS_EXTENSION]: {} }, elicitation: { form: {} } }; +const metadata = (capabilities: unknown = capable) => ({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": capabilities, +}); +afterEach(() => { setModelDefaults(undefined, undefined); vi.restoreAllMocks(); }); + +async function setup() { + const client = new OpenCodeClient({ baseUrl: "http://fixture.invalid", backend: "v2" }); + const events: any[] = []; + const messages = new Map(); + let forms: any[] = [], permissions: any[] = []; + const session = { id: "session", title: "Fixture", projectID: "project", location: { directory: "/remote/project" }, + time: { created: 1, updated: 2 }, agent: "existing-agent", model: { providerID: "existing-provider", id: "existing-model", variant: "existing-variant" } }; + const event = (type: string, data: any = {}) => events.push({ type: `session.${type}`, durable: { aggregateID: session.id, seq: events.length + 1, version: 1 }, data: { sessionID: session.id, ...data } }); + const api: any = { + location: { get: vi.fn(async () => ({ directory: "/remote/project", project: { id: "project", directory: "/remote/project" } })) }, + session: { + get: vi.fn(async () => session), create: vi.fn(), switchModel: vi.fn(), switchAgent: vi.fn(), + prompt: vi.fn(async ({ id }: any) => { + messages.set(id, { id, type: "user", text: "Fixture task", time: { created: 1 } }); + event("inbox.enqueued", { inboxID: id, item: { type: "user", delivery: "queue" } }); + event("execution.started"); event("inbox.delivered", { inboxID: id }); + return { id, sessionID: session.id }; + }), + log: vi.fn(({ after }: any) => (async function* () { + for (const item of events) if (item.durable.seq > after) yield item; + yield { type: "log.synced", aggregateID: session.id, seq: events.at(-1)?.durable.seq ?? 0 }; + })()), + inbox: { list: vi.fn(async () => []), cancel: vi.fn() }, + interrupt: vi.fn(), + message: { get: vi.fn(async ({ messageID }: any) => messages.get(messageID)) }, + form: { + list: vi.fn(async () => forms), + get: vi.fn(async ({ formID }: any) => forms.find(form => form.id === formID)), + reply: vi.fn(async ({ formID }: any) => { forms = forms.filter(form => form.id !== formID); return {}; }), + cancel: vi.fn(async () => { forms = []; return {}; }), + }, + }, + permission: { + list: vi.fn(async () => permissions), + reply: vi.fn(async () => { permissions = []; return {}; }), + }, + }; + Object.defineProperty(backend(client) as V2Adapter, "api", { value: api }); + const jobs = new JobService(client, { storeRoot: null }); + const sent: any[] = []; + const inner = { start: vi.fn(async () => {}), close: vi.fn(async () => {}), send: vi.fn(async message => { sent.push(message); }) } as unknown as Transport; + const transport = new TaskTransport(inner, jobs); + const forwarded = vi.fn(); transport.onmessage = forwarded; transport.onerror = vi.fn(); await transport.start(); + let requestId = 0; + const request = async (method: string, params: Record, capabilities: unknown = capable) => { + const id = ++requestId; + inner.onmessage!({ jsonrpc: "2.0", id, method, params: { ...params, _meta: metadata(capabilities) } }); + await vi.waitFor(() => expect(sent.some(message => message.id === id)).toBe(true)); + return sent.find(message => message.id === id); + }; + const entries = new Map(); + const registration = { tool: (name: string, _description: string, shape: z.ZodRawShape, ...rest: unknown[]) => entries.set(name, { shape, handler: rest.at(-1) as Function }) } as unknown as McpServer; + registerInputTools(registration, client, jobs); + registerWorkflowTools(registration, client, jobs); + const tool = (name: string, input: unknown, extra?: unknown) => { const entry = entries.get(name)!; return entry.handler(z.object(entry.shape).parse(input), extra); }; + const run = async () => { + const response = await request("tools/call", { name: "opencode_run", arguments: { prompt: "Fixture task", sessionId: "session", directory: "/remote/project" } }); + expect(response.error).toBeUndefined(); expect(response.result).toMatchObject({ resultType: "task", status: "working" }); + return response.result.taskId as string; + }; + return { api, jobs, request, tool, run, forwarded, + form(fields: any[]) { forms = [{ id: "form", sessionID: "session", title: "Choose values", fields, state: { status: "pending" } }]; }, + permission() { permissions = [{ id: "permission", sessionID: "session", action: "shell", resources: ["npm test"], save: ["npm *"] }]; }, + complete() { + event("step.started", { assistantMessageID: "answer" }); + messages.set("answer", { id: "answer", type: "assistant", time: { created: 3, completed: 4 }, content: [{ type: "text", text: "Fixture completed" }], finish: "stop" }); + event("step.ended", { assistantMessageID: "answer", finish: "stop" }); event("execution.succeeded"); + }, + }; +} +const fields = [ + { key: "count", type: "integer", required: true, minimum: 1, maximum: 5 }, + { key: "enabled", type: "boolean", required: true }, + { key: "target", type: "string", required: true, options: [{ label: "Web", value: "web" }, { label: "Mobile", value: "mobile" }] }, + { key: "checks", type: "multiselect", required: true, options: [{ label: "Unit", value: "unit" }, { label: "Integration", value: "integration" }], minItems: 1 }, +]; + +describe("V2 native Tasks use the durable JobService", () => { + it("returns a structured unsupported-capability error for native JSON-schema output without writing", async () => { + const f = await setup(); + const response = await f.request("tools/call", { name: "opencode_run", arguments: { prompt: "Fixture task", sessionId: "session", format: { type: "json_schema", schema: { type: "object" } } } }); + expect(response.error).toBeUndefined(); + expect(response.result).toMatchObject({ resultType: "complete", isError: true, structuredContent: { error: { code: "UNSUPPORTED_CAPABILITY" } } }); + expect(f.api.session.prompt).not.toHaveBeenCalled(); expect(f.api.session.create).not.toHaveBeenCalled(); + expect(f.api.session.switchModel).not.toHaveBeenCalled(); expect(f.api.session.switchAgent).not.toHaveBeenCalled(); + }); + it.each(["opencode_fire", "opencode_run"])("preserves the structured capability error through ordinary %s", async name => { + const f = await setup(); + const result = await f.tool(name, { prompt: "Fixture task", sessionId: "session", format: { type: "json_schema", schema: { type: "object" } } }); + expect(result).toMatchObject({ isError: true, structuredContent: { error: { code: "UNSUPPORTED_CAPABILITY" } } }); + expect(f.api.session.prompt).not.toHaveBeenCalled(); expect(f.api.session.create).not.toHaveBeenCalled(); + expect(f.api.session.switchModel).not.toHaveBeenCalled(); expect(f.api.session.switchAgent).not.toHaveBeenCalled(); + }); + it("preserves existing session selections despite configured default model values", async () => { + setModelDefaults("configured-provider", "different-default-model"); + const f = await setup(); const taskId = await f.run(); + expect((await f.jobs.get(taskId)).status).toBe("running"); + expect(f.api.session.prompt).toHaveBeenCalledOnce(); + expect(f.api.session.prompt.mock.calls[0][0]).toMatchObject({ sessionID: "session", text: "Fixture task", delivery: "queue" }); + expect(f.api.session.create).not.toHaveBeenCalled(); expect(f.api.session.switchModel).not.toHaveBeenCalled(); expect(f.api.session.switchAgent).not.toHaveBeenCalled(); + expect(f.forwarded).not.toHaveBeenCalled(); + }); + it("round-trips typed native form values and exposes the same job through ordinary tools and Tasks", async () => { + const f = await setup(); const taskId = await f.run(); f.form(fields); + const pending = await f.request("tasks/get", { taskId }); + expect(pending.result).toMatchObject({ resultType: "complete", status: "input_required", taskId }); + const schema = pending.result.inputRequests["question:form"].params.requestedSchema; + expect(schema.properties).toMatchObject({ count: { type: "integer", minimum: 1 }, enabled: { type: "boolean" }, target: { enum: ["web", "mobile"] }, checks: { type: "array" } }); + expect(schema.properties.enabled).not.toHaveProperty("default"); + const values = { count: 2, enabled: false, target: "web", checks: ["unit", "integration"] }; + const accepted = await f.request("tasks/update", { taskId, inputResponses: { "question:form": { action: "accept", content: values } } }); + expect(accepted.result).toEqual({ resultType: "complete" }); + expect(f.api.session.form.reply).toHaveBeenCalledWith({ sessionID: "session", formID: "form", answer: values }, expect.anything()); + expect((await f.tool("opencode_job_get", { jobId: taskId })).structuredContent).toMatchObject({ jobId: taskId, status: "running", backend: "v2" }); + // A consumed input key is acknowledged without replaying the backend write. + await f.request("tasks/update", { taskId, inputResponses: { "question:form": { action: "accept", content: values } } }); + expect(f.api.session.form.reply).toHaveBeenCalledOnce(); + f.complete(); + const done = await f.request("tasks/get", { taskId }); + expect(done.result).toMatchObject({ status: "completed", result: { structuredContent: { jobId: taskId, status: "completed", backend: "v2" } } }); + expect(JSON.stringify(done.result.result)).toContain("Fixture completed"); + expect(f.api.session.prompt).toHaveBeenCalledOnce(); + }); + it("rejects invalid typed native values before mutation and allows a corrected response", async () => { + const f = await setup(); const taskId = await f.run(); f.form([{ key: "count", type: "integer", required: true, minimum: 1 }]); + const invalid = await f.request("tasks/update", { taskId, inputResponses: { "question:form": { action: "accept", content: { count: "two" } } } }); + expect(invalid.error).toBeDefined(); expect(f.api.session.form.reply).not.toHaveBeenCalled(); + expect((await f.request("tasks/get", { taskId })).result.status).toBe("input_required"); + expect((await f.request("tasks/update", { taskId, inputResponses: { "question:form": { action: "accept", content: { count: 2 } } } })).result).toEqual({ resultType: "complete" }); + expect(f.api.session.form.reply).toHaveBeenCalledOnce(); + }); + it.each([ + { label: "conditional", fields: [{ key: "include", type: "boolean", required: true }, { key: "name", type: "string", required: true, when: [{ key: "include", op: "eq", value: true }] }], values: { include: true, name: "fixture" } }, + { label: "pattern", fields: [{ key: "code", type: "string", required: true, pattern: "^[A-Z]+$" }], values: { code: "FIXTURE" } }, + ])("provides manual fallback for $label forms and accepts typed input through the same job", async item => { + const f = await setup(); const taskId = await f.run(); f.form(item.fields); + const pending = await f.request("tasks/get", { taskId }, { extensions: { [TASKS_EXTENSION]: {} } }); + expect(pending.error).toBeUndefined(); expect(pending.result).toMatchObject({ status: "working", taskId, pendingInputs: [{ id: "form", fields: item.fields, backend: "v2" }] }); + expect(pending.result).not.toHaveProperty("inputRequests"); expect(pending.result.statusMessage).toContain("Manual input required"); + const manual = await f.tool("opencode_job_input", { jobId: taskId }, { mcpReq: { envelope: metadata() } }); + expect(manual.resultType).not.toBe("input_required"); expect(manual.structuredContent).toMatchObject({ jobId: taskId, status: "input_required", inputs: [{ fields: item.fields }] }); + const applied = await f.tool("opencode_job_input", { jobId: taskId, responses: [{ id: "form", kind: "question", values: item.values }] }); + expect(applied.isError).not.toBe(true); expect(f.api.session.form.reply).toHaveBeenCalledWith({ sessionID: "session", formID: "form", answer: item.values }, expect.anything()); + expect((await f.request("tasks/get", { taskId })).result.status).toBe("working"); + }); + it("requires native form capability per request for representable V2 input", async () => { + const f = await setup(); const taskId = await f.run(); f.form(fields); + const response = await f.request("tasks/get", { taskId }, { extensions: { [TASKS_EXTENSION]: {} } }); + expect(response.error).toMatchObject({ code: -32021, data: { requiredCapabilities: { elicitation: { form: {} } } } }); + expect(f.api.session.form.reply).not.toHaveBeenCalled(); + }); +}); + +describe("V2 Tasks preserve explicit permission scope", () => { + it.each([{ decision: "always", scope: "project" }, { decision: "reject", scope: "session" }, { decision: "once", scope: "request" }])("requires matching $scope acknowledgement for $decision", async choice => { + const f = await setup(); const taskId = await f.run(); f.permission(); + const pending = await f.request("tasks/get", { taskId }); + const form = pending.result.inputRequests["permission:permission"].params; + expect(form.message).toContain("project-wide"); expect(form.message).toContain("ALL pending requests"); + expect(form.requestedSchema.required).toEqual(["decision", "scope"]); + expect(form.requestedSchema.properties.decision).not.toHaveProperty("default"); + for (const content of [{ decision: choice.decision }, { decision: choice.decision, scope: "wrong" }]) { + expect((await f.request("tasks/update", { taskId, inputResponses: { "permission:permission": { action: "accept", content } } })).error).toBeDefined(); + expect(f.api.permission.reply).not.toHaveBeenCalled(); + } + const accepted = await f.request("tasks/update", { taskId, inputResponses: { "permission:permission": { action: "accept", content: choice } } }); + expect(accepted.result).toEqual({ resultType: "complete" }); + expect(f.api.permission.reply).toHaveBeenCalledWith(expect.objectContaining({ sessionID: "session", requestID: "permission", decision: choice.decision }), expect.anything()); + expect(f.api.permission.reply).toHaveBeenCalledOnce(); + }); + it.each(["decline", "cancel"])("does not infer session-wide rejection from native %s", async action => { + const f = await setup(); const taskId = await f.run(); f.permission(); + const result = await f.request("tasks/update", { taskId, inputResponses: { "permission:permission": { action } } }); + expect(result.result).toEqual({ resultType: "complete" }); + expect(f.api.permission.reply).not.toHaveBeenCalled(); expect(f.api.session.interrupt).not.toHaveBeenCalled(); + expect((await f.request("tasks/get", { taskId })).result).toMatchObject({ status: "input_required", inputRequests: { "permission:permission": expect.anything() } }); + }); +}); From 3454942416ec545304ae378e0e46d068f160f29f Mon Sep 17 00:00:00 2001 From: Alaeddine Messadi <10847134+AlaeddineMessadi@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:20:02 +0100 Subject: [PATCH 2/3] fix: stabilize macOS cleanup and support OpenCode 2.0.32 --- CHANGELOG.md | 9 +- MIGRATION.md | 10 +- docs/architecture.md | 2 +- docs/compatibility.md | 8 +- docs/configuration.md | 2 +- docs/releasing.md | 6 +- docs/tools.md | 2 +- package-lock.json | 273 +++++++--------------------- package.json | 2 +- plans/007-ci-and-opencode-2.0.32.md | 38 ++++ plans/README.md | 3 +- scripts/generate-tool-docs.mjs | 4 +- scripts/test-compat.mjs | 8 +- scripts/test-package.mjs | 19 +- src/backend-detection.ts | 2 +- src/backends/capabilities.ts | 2 +- src/backends/v2-guards.ts | 4 +- src/backends/v2.ts | 9 +- src/doctor.ts | 2 +- tests/compat-harness.test.ts | 34 +++- tests/v2-adapter.test.ts | 14 +- tests/v2-jobs.test.ts | 8 +- 22 files changed, 206 insertions(+), 255 deletions(-) create mode 100644 plans/007-ci-and-opencode-2.0.32.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c616c65..84b3e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Evaluation candidate for the V1/V2 compatibility foundation. The original 65-operation V2 completion gate remains blocked by upstream command correlation; this is not a completed milestone declaration. +### October 18 follow-up + +- Pin the V2 client and isolated CLI integration target to 2.0.32. Adapt project-list arguments and remove the URL-prefix workaround now handled by the client, including prefixed event-stream regression coverage. +- Make owned test-process cleanup tolerate groups exiting before a signal and transient Darwin EPERM while zombies are reaped, while preserving persistent permission failures. Classify cleanup errors without exposing raw server output. +- Refresh vulnerable lockfile dependencies within the existing declared ranges. No new dependency is introduced. +- Slash-command correlation remains blocked in 2.0.32. Parent session creation remains conservatively rejected pending validation of the newly added upstream option. + ### Added - Typed backend operations, normalized results, and one compatibility manifest for all 87 tools, 10 resources, 4 templates, and 6 prompts. V2 currently maps 64 tools, retains 22 explicit V1-only exceptions, and rejects `command_execute` before mutation because V2 2.0.6 supplies no execution receipt or caller-assigned command ID. @@ -20,7 +27,7 @@ Evaluation candidate for the V1/V2 compatibility foundation. The original 65-ope ### Compatibility and validation - V1 behavior and stored record format remain supported. Authentication errors and ambiguous mutations never trigger fallback submission or replay. -- Model-free integration exercised `opencode-ai@1.18.31` and `@opencode/cli@2.0.6` on Node.js 22.23.2, macOS arm64. The V2 client dependency is pinned to `@opencode/client@2.0.6`. +- The September candidate exercised `opencode-ai@1.18.31` and `@opencode/cli@2.0.6` on Node.js 22.23.2, macOS arm64. The current V2 client dependency and integration target are 2.0.32; see the [verification guide](docs/releasing.md) for current evidence. - Live provider inference, live job execution, OAuth completion, and interactive permission/form flows were not verified. Those paths have fixture-backed contract and recovery tests; client UI compatibility is not claimed from those fixtures. - See [migration guidance](MIGRATION.md) and the generated [compatibility matrix](docs/compatibility.md) for guarded options and the upstream blocker. No package has been published by this change. diff --git a/MIGRATION.md b/MIGRATION.md index 77ee6fe..a9d00fd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,6 @@ # OpenCode V1/V2 compatibility migration -The compatibility foundation preserves the existing V1 SDK and V1 job records. It adds the pinned `@opencode/client@2.0.6` adapter, explicit backend detection, and a read-only doctor. An API mapping is not proof of live end-to-end behavior: consult the [release verification guide](docs/releasing.md) and record actual backend check results separately. +The compatibility foundation preserves the existing V1 SDK and V1 job records. It adds the pinned `@opencode/client@2.0.32` adapter, explicit backend detection, and a read-only doctor. An API mapping is not proof of live end-to-end behavior: consult the [release verification guide](docs/releasing.md) and record actual backend check results separately. ## Select a backend @@ -10,6 +10,8 @@ Without an explicit URL, the bridge probes `http://127.0.0.1:4096`, then checks `OPENCODE_AUTO_SERVE=true` continues to allow an owned local V1 child when no existing backend or service registration is present. It never enables V2 `Service.ensure()` or stops a shared service. An owned child stops with MCP; externally managed and discovered servers can continue work while their processes remain alive. +For a manually started V2 server, set `OPENCODE_BACKEND=v2`, `OPENCODE_AUTO_SERVE=false`, `OPENCODE_BASE_URL` to its listening URL, and `OPENCODE_SERVER_PASSWORD` to the password it reports. The username defaults to `opencode`. The published 3.0.0 package does not support V2; use this compatibility candidate. Issue [#31](https://github.com/AlaeddineMessadi/opencode-mcp/issues/31) tracks the older V1 launcher's timeout when a V2 executable is installed. + ## Check readiness For a source build: @@ -31,14 +33,14 @@ The [generated inventory](docs/compatibility.md) records 87 tool names, with 64 |---|---| | Prompt output | `format` may be omitted or text. JSON-schema output, per-prompt `system`, and `noReply: true` are rejected. | | Existing sessions | Model, variant, and agent must match the existing selections. Create a new session to choose different values. | -| Session creation | `parentID` is rejected; fork explicitly. A variant requires an explicit provider/model pair. | +| Session creation | The bridge still rejects `parentID`; fork explicitly. Upstream 2.0.32 adds parent creation, but its semantics have not been validated by this bridge. A variant requires an explicit provider/model pair. | | Fork | A message boundary is **before** that message. The result reports the boundary. | | Diff/review | Supply explicit `from` and `to` message IDs; no range silently means “all session changes.” Cross-location ranges are rejected. | -| Revert | Whole-message revert is staged and remains reversible through `opencode_session_unrevert`; the bridge never automatically commits it. V2 2.0.6 implicitly commits a staged revert when prompting or compacting, so the bridge rejects those operations while a revert is staged. Clear it with `opencode_session_unrevert`, or explicitly commit it outside the bridge, before continuing. Individual `partID` reverts are rejected. | +| Revert | Whole-message revert is staged and remains reversible through `opencode_session_unrevert`; the bridge never automatically commits it. V2 2.0.32 implicitly commits a staged revert when prompting or compacting, so the bridge rejects those operations while a revert is staged. Clear it with `opencode_session_unrevert`, or explicitly commit it outside the bridge, before continuing. Individual `partID` reverts are rejected. | | Permissions | `once` affects the specified request. `always` requires `scope: "project"`; `reject` requires `scope: "session"` and rejects all pending permissions in that session. Present that scope before forwarding the user's decision. | | Questions/forms | Use `sessionId`, `requestId`, and typed, field-keyed `values` from the returned form fields. Legacy selected-label `answers` are not silently converted. | | Authentication | `opencode_auth_set` accepts API keys through an integration with a published key method. Use the explicit OAuth workflow for OAuth. | -| Slash command execution | `opencode_command_execute` is blocked: V2 2.0.6 supplies no caller ID or submission receipt that allows exact completion correlation. No command is submitted. | +| Slash command execution | `opencode_command_execute` is blocked: V2 2.0.32 supplies no caller ID or submission receipt that allows exact completion correlation. No command is submitted. | Configuration, providers, sessions, messages, and events may include explicitly labeled V2 raw data or pagination metadata. Consumers should use the bridge's normalized fields and inspect `isError`, structured state, and completeness instead of assuming V1 internals. diff --git a/docs/architecture.md b/docs/architecture.md index 8507b03..2266099 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,7 @@ Project-scoped calls include an absolute `directory` on the OpenCode server. Val Authentication routes are global. `opencode_project_init` is a separate local-filesystem operation: it accepts `path`, checks protected roots and symlinks, creates the directory if necessary, then opens it through OpenCode. It does not create remote projects. -On startup the process validates the selected backend's health/info contract. An explicit endpoint is authoritative. Otherwise, it probes the default loopback endpoint and checks the existing V2 registration. The pinned 2.0.6 discovery implementation hides authentication failures as an absent result, so the bridge preflights the documented registration file with a bounded authenticated probe before `Service.discover()` selects the service. Present-but-invalid registrations fail closed. It never calls V2 `Service.ensure()` or `Service.stop()`. +On startup the process validates the selected backend's health/info contract. An explicit endpoint is authoritative. Otherwise, it probes the default loopback endpoint and checks the existing V2 registration. The pinned 2.0.32 discovery implementation hides authentication failures as an absent result, so the bridge preflights the documented registration file with a bounded authenticated probe before `Service.discover()` selects the service. Present-but-invalid registrations fail closed. It never calls V2 `Service.ensure()` or `Service.stop()`. With automatic startup explicitly enabled and no existing endpoint or registration, the V1 SDK can launch `opencode serve` on a local loopback HTTP endpoint. Concurrent starts are coalesced by endpoint. Shutdown handlers stop only owned child processes; externally managed servers remain running. A failed owned-child startup closes only that child. HTTP 401/403 is terminal and never enables another probe or unauthenticated retry. diff --git a/docs/compatibility.md b/docs/compatibility.md index 31a4272..48d20f0 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -12,13 +12,13 @@ The full and essential profiles keep their existing names and schemas. An unavai - **unsupported:** an intentional V1-only exception for this milestone. - **blocked:** a required operation could not preserve the public behavior with the pinned upstream contract. It remains an outstanding compatibility limitation. -The integration targets are OpenCode V1 `opencode-ai@1.18.31` and OpenCode V2 `@opencode/cli@2.0.6`, with `@opencode/client@2.0.6` pinned. Version recognition and fixture coverage are separate from [live verification evidence](releasing.md). Doctor reports the target version without claiming that a live integration check has passed. +The integration targets are OpenCode V1 `opencode-ai@1.18.31` and OpenCode V2 `@opencode/cli@2.0.32`, with `@opencode/client@2.0.32` pinned. Version recognition and fixture coverage are separate from [live verification evidence](releasing.md). Doctor reports the target version without claiming that a live integration check has passed. -V2 whole-message reverts remain staged and reversible. Because upstream 2.0.6 automatically commits staged reverts when prompting or compacting, the bridge rejects those operations until the revert is cleared with `opencode_session_unrevert` or explicitly committed outside the bridge. +V2 whole-message reverts remain staged and reversible. Because upstream 2.0.32 automatically commits staged reverts when prompting or compacting, the bridge rejects those operations until the revert is cleared with `opencode_session_unrevert` or explicitly committed outside the bridge. ## Required upstream follow-up -- **`opencode_command_execute`**: V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. No detached command or guessed completion result is substituted. +- **`opencode_command_execute`**: V2 2.0.32 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. No detached command or guessed completion result is substituted. ## Tools (87) @@ -28,7 +28,7 @@ V2 whole-message reverts remain staged and reversible. Because upstream 2.0.6 au | `opencode_ask` | supported | supported | | | `opencode_auth_set` | supported | supported | | | `opencode_check` | supported | supported | | -| `opencode_command_execute` | supported | blocked | V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. | +| `opencode_command_execute` | supported | blocked | V2 2.0.32 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. | | `opencode_command_list` | supported | supported | | | `opencode_config_get` | supported | supported | | | `opencode_config_providers` | supported | supported | | diff --git a/docs/configuration.md b/docs/configuration.md index 4e6eb77..159376a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,7 +18,7 @@ An OpenCode server must already be running unless automatic startup is explicitl Choose provider/model IDs from `opencode_setup` and `opencode_provider_models`. Authentication credentials are global to OpenCode; `directory` does not make provider credentials project-specific. -An explicit URL never falls back to a different server. URLs must be HTTP(S), without embedded credentials, queries, or fragments; configure authentication using the environment variables above. HTTP 401/403 ends detection without alternate probes or startup. A present but invalid or unresponsive local service registration also prevents replacement startup. V2 discovery uses the pinned 2.0.6 registration-file contract and `Service.discover()`; it never calls `Service.ensure()` or `Service.stop()`. +An explicit URL never falls back to a different server. URLs must be HTTP(S), without embedded credentials, queries, or fragments; configure authentication using the environment variables above. HTTP 401/403 ends detection without alternate probes or startup. A present but invalid or unresponsive local service registration also prevents replacement startup. V2 discovery uses the pinned 2.0.32 registration-file contract and `Service.discover()`; it never calls `Service.ensure()` or `Service.stop()`. Run `opencode-mcp doctor --json [--directory ABSOLUTE_SERVER_PATH]` after installing this build, or `node dist/index.js doctor` from source. Doctor shares a 15-second deadline across checks, performs no inference or writes, and ignores auto-start permission. Project access is checked only when a directory is supplied. The report identifies backend, recognized version, connection source, ownership, and disconnect behavior; credentials and raw upstream responses are omitted. `--check` is an alias. Exit `0` means ready, `1` means a failed check, and `2` means invalid CLI arguments. diff --git a/docs/releasing.md b/docs/releasing.md index 93d0324..e760039 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -2,11 +2,13 @@ ## V1/V2 evidence requirements -The compatibility foundation targets `opencode-ai@1.18.31` for V1 and `@opencode/cli@2.0.6` for V2, with `@opencode/client@2.0.6` pinned. Run each in a disposable installation and isolated state directory. Do not replace a global OpenCode executable or attach to a user's shared service for mutation tests. +The compatibility foundation targets `opencode-ai@1.18.31` for V1 and `@opencode/cli@2.0.32` for V2, with `@opencode/client@2.0.32` pinned. Run each in a disposable installation and isolated state directory. Do not replace a global OpenCode executable or attach to a user's shared service for mutation tests. Fixture tests verify transport contracts, normalization, request guards, job correlation, and CLI behavior. They do not establish live model/provider compatibility. Likewise, doctor's `matchesIntegrationTarget` is a version comparison, not live verification evidence. Record the exact backend, command, exit status, covered operations, and skipped capabilities for each live run. Inference must remain opt-in with an explicitly selected provider/model. -The generated [capability inventory](compatibility.md) currently lists 64 V2 tool mappings, 22 intentional V1-only exceptions, and one upstream-blocked operation. `opencode_command_execute` remains blocked because the V2 2.0.6 contract cannot correlate exact command completion. Passing tests does not fulfill that missing behavior, and no detached command may be substituted. Keep this limitation in release notes until an upstream contract and its regression tests resolve it. +The generated [capability inventory](compatibility.md) currently lists 64 V2 tool mappings, 22 intentional V1-only exceptions, and one upstream-blocked operation. `opencode_command_execute` remains blocked because the V2 2.0.32 contract cannot correlate exact command completion. Passing tests does not fulfill that missing behavior, and no detached command may be substituted. Keep this limitation in release notes until an upstream contract and its regression tests resolve it. + +October 18 local verification: isolated V1 1.18.31 and V2 2.0.32 passed the model-free native archive checks on macOS arm64 with Node 22.23.3 and 24.20.0. The regression suite passed 764 tests with three platform/baseline skips on each Node version. These results do not cover live inference, job execution/recovery, forms/permissions, OAuth, or interactive clients. ## Verify the installed archive and isolated backends diff --git a/docs/tools.md b/docs/tools.md index ee3f849..589507e 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -3679,7 +3679,7 @@ Execute a slash command in a session (e.g. /init, /undo, /redo) **Profiles:** full. -**Backends:** V1 supported; V2 blocked. V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. +**Backends:** V1 supported; V2 blocked. V2 2.0.32 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely. **Annotations:** `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true`. diff --git a/package-lock.json b/package-lock.json index 64a8233..7f3d100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "^1.18.31", - "@opencode/client": "2.0.6", + "@opencode/client": "2.0.32", "zod": "^4.6.5" }, "bin": { @@ -132,16 +132,16 @@ } }, "node_modules/@modelcontextprotocol/client": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.0.0.tgz", - "integrity": "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", + "eventsource-parser": "^3.0.8", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" @@ -150,6 +150,19 @@ "node": ">=20" } }, + "node_modules/@modelcontextprotocol/client/node_modules/@modelcontextprotocol/core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/core": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", @@ -163,9 +176,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "dev": true, "license": "MIT", "dependencies": { @@ -216,84 +229,6 @@ "node": ">=20" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@opencode-ai/sdk": { "version": "1.18.31", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", @@ -304,16 +239,16 @@ } }, "node_modules/@opencode/client": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.6.tgz", - "integrity": "sha512-yS8Ztw5KJzoY94GTiEmaX+2OqJYVVdc2Pyj8joNvmFywMfNzhS5aqqX+LpprlZdiDHKBLN9GhMU775TRPLol0A==", + "version": "2.0.32", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.0.32.tgz", + "integrity": "sha512-QjejcJTOC5v51gCMt7cRZJJsIydT1uGUowtiNkXWlIhKD7+/260PyqQ3S/2StnmwXucPOq6YNNkLLPv2gio6Iw==", "license": "MIT", "dependencies": { - "@opencode/protocol": "2.0.6", - "@opencode/schema": "2.0.6" + "@opencode/protocol": "2.0.32", + "@opencode/schema": "2.0.32" }, "peerDependencies": { - "effect": "4.0.0-rc.112", + "effect": "4.0.1", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { @@ -326,23 +261,23 @@ } }, "node_modules/@opencode/protocol": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.6.tgz", - "integrity": "sha512-31pt2SC2/urvitx8q8A0i0IwewwAu70/knkSfY9NFhZyLfggfNrPMUvlzMV0rwbBgubXbyRFRshU6B2tX/6czQ==", + "version": "2.0.32", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.32.tgz", + "integrity": "sha512-aF0oW0T6vBUO+EfD0po5gNz9YzQnT+b3InhKaOGg3p8K1S1A1tVkEoOBR7gKWQjAmlpfAPP8zWx4Fgsc7nsCbQ==", "license": "MIT", "dependencies": { - "@opencode/schema": "2.0.6", - "effect": "4.0.0-rc.112" + "@opencode/schema": "2.0.32", + "effect": "4.0.1" } }, "node_modules/@opencode/schema": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.6.tgz", - "integrity": "sha512-oJC1Uc1ZhqQ5fBUhRza5tFc4Y8UKy0bWxpOv4f9qm6VCs16xgNGPi5Dts4bM29KbtfARr3W926tHvk7BxzUf9A==", + "version": "2.0.32", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.32.tgz", + "integrity": "sha512-FQOHjPUZwoEflWXFTaNirICglfOi+simArwm9UMj0/aFjYobsZbNh6l7AS8/sslZB+pkR8NB/JWEu29MlJpDeA==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", - "effect": "4.0.0-rc.112" + "effect": "4.0.1" } }, "node_modules/@oxc-project/types": { @@ -1096,7 +1031,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1125,14 +1060,10 @@ "license": "MIT" }, "node_modules/effect": { - "version": "4.0.0-rc.112", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", - "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", - "license": "MIT", - "dependencies": { - "fast-check": "^4.9.0", - "msgpackr": "^2.0.5" - } + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.1.tgz", + "integrity": "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw==", + "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", @@ -1225,9 +1156,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, "license": "MIT", "engines": { @@ -1308,28 +1239,6 @@ "express": ">= 4.11" } }, - "node_modules/fast-check": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.1.tgz", - "integrity": "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1338,9 +1247,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -1590,9 +1499,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "dev": true, "license": "MIT", "engines": { @@ -2070,37 +1979,6 @@ "dev": true, "license": "MIT" }, - "node_modules/msgpackr": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", - "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -2130,21 +2008,6 @@ "node": ">= 0.6" } }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2299,9 +2162,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2310,24 +2173,12 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, "node_modules/qs": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", @@ -2611,9 +2462,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index fd761b2..a8d2718 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@modelcontextprotocol/core": "^2.0.0", "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "^1.18.31", - "@opencode/client": "2.0.6", + "@opencode/client": "2.0.32", "zod": "^4.6.5" }, "devDependencies": { diff --git a/plans/007-ci-and-opencode-2.0.32.md b/plans/007-ci-and-opencode-2.0.32.md new file mode 100644 index 0000000..33e493e --- /dev/null +++ b/plans/007-ci-and-opencode-2.0.32.md @@ -0,0 +1,38 @@ +# CI cleanup and OpenCode 2.0.32 — 2026-10-18 + +User scope: fix PR #29's macOS/Node 24 CI failure, then check OpenCode 2.0.32 compatibility. No merge, npm publication, global CLI replacement, or inference is authorized by this continuation. + +## Cleanup diagnosis and fix + +The original [macOS/Node 24 job](https://github.com/AlaeddineMessadi/opencode-mcp/actions/runs/35317065224/job/105510934788) failed in `backend-cleanup`. Its diagnostics omitted the error code. A local run with the same Node 24.20.0 reproduced that phase on V1, with `EPERM` exposed by the expanded safe code allowlist (`artifacts/compat-2032-final-node24.log`). This isolates a process-cleanup failure independent of V2 API behavior; the old hosted log cannot prove the identical errno. + +Darwin's [killpg1 implementation](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/kern_sig.c) excludes zombie processes from group iteration and can return EPERM when no eligible group members remain. The helper previously treated this transient state as a fatal permission failure. It now retries Darwin EPERM for at most one second per signal/probe, allowing children to be reaped, and still throws persistent EPERM. ESRCH is accepted for both signals as well as probes to handle a group disappearing between calls. Only explicitly owned groups are signalled; the native harness still checks that the port closes. + +Regression tests cover transient Darwin EPERM at TERM/probe/KILL, disappearance at TERM/KILL, persistent permission failure, and a real wrapper/server process tree. No blanket permission-error suppression was introduced. + +An unrelated 30ms cancellation fixture timed out during filesystem setup in a parallel coverage run. It now injects a TimeoutError through the caller's abort signal only after interrupt dispatch starts, preserving the unknown/no-replay assertion without depending on scheduler speed. Production cancellation code is unchanged. + +## 2.0.32 compatibility + +- Pin `@opencode/client` and the isolated `@opencode/cli` target to 2.0.32; preserve V1 SDK and target 1.18.31. +- `project.list` now takes input before request options; forward the abort signal in the correct position. +- The new client preserves base URL prefixes. Remove our 2.0.6 workaround, which otherwise doubles the prefix. Existing HTTP tests caught the regression; event tests now also cover `/bridge` and `/api` prefixes. +- Published client/service declarations and core sources were inspected. Discovery still uses the same registration contract; prompt/compact still commit staged reverts, so guards remain. +- Command invocation identity and receipts are still absent. [Upstream #49690](https://github.com/anomalyco/opencode/issues/49690) remains open. V2 remains 64 supported tools, 22 intentional exceptions, and one blocked command. +- Upstream now accepts session-create parentID. This bridge still rejects it pending semantic validation; the error/documentation no longer claim the upstream option is absent. +- Refresh six vulnerable dependencies within existing declared ranges; audit is clean. No new dependency or global npm configuration is added. The explicit 2.0.32 install used a command-local release-age override because the requested version was newer than the user's default installation delay. + +## Verification + +- macOS arm64, Node 24.20.0: full regression 764 passed / 3 skipped (`artifacts/resume-tests-node24.log`). +- macOS arm64, Node 22.23.3: coverage suite 764 passed / 3 skipped; statements 72.05%, branches 70.85%, functions 72.56%, lines 75.24% (`artifacts/resume-coverage-node22.log`). +- Node 24.20.0: real isolated V1 1.18.31 and V2 2.0.32 plus installed bridge archive passed (`artifacts/compat-2032-cleanup-fixed-node24.log`). Includes runtime identity, read-only doctor, project list/current, paths, agents, configuration, file reads, session CRUD/list, bridge disconnect survival, process cleanup and port closure. +- Node 22.23.3: production archive executable, initialization, 87-tool catalog, fixture calls and EOF shutdown passed (`artifacts/package-2032-node22.log`). +- Node 22.23.3: the same V1/V2 native checks passed (`artifacts/compat-2032-node22.log`). Both Node versions tested the archive with integrity `sha512-ZD7tAE4YPF9Mrj82RecxLxxVGTkFwZ9JkGC3QsDGwVWfIZfSNUiDU6eQ6p4wGCRW2m7DnDnYd31WlfxyvNJxog==`. +- Generated docs check, audit (zero advisories), and whitespace checks passed. Updated hosted CI results will be recorded after completion. + +Live inference, durable execution recovery, forms/permissions, OAuth and GUI clients remain unverified. Existing fixture coverage is not live evidence. The retained September RC archive is unchanged and does not contain this continuation. + +## Related work + +The `Review repository contribution` chat owns a separate uncommitted fix for issue #31 in `codex/issue31-startup-diagnostics`. It makes the V1 launcher reject V2 promptly. Those files were not changed or copied here. PR #34 is a contributor's OpenChamber authentication change awaiting corrections; it has not been integrated. Our migration guide explains the V2 connection settings and published 3.0.0 limitation. No reply to bonsai or other contributor was posted during this work. diff --git a/plans/README.md b/plans/README.md index 947c118..993724f 100644 --- a/plans/README.md +++ b/plans/README.md @@ -2,7 +2,7 @@ Baseline: `0de817a`. User-approved scope: milestone 1 only. Original checkout and historical review remain intact. -Implementation and local verification are complete for the supported scope; release candidate `3.1.0-rc.1` is prepared. The original milestone acceptance gate remains **BLOCKED**: V2 supports 64 tools, with 22 planned V1-only exceptions and `opencode_command_execute` blocked by the upstream 2.0.6 contract. See [final evidence and limitations](verification.md#final-candidate-evidence-2026-09-17). No publication is claimed. +Implementation and local verification are complete for the supported scope; release candidate `3.1.0-rc.1` is prepared. The original milestone acceptance gate remains **BLOCKED**: V2 supports 64 tools, with 22 planned V1-only exceptions and `opencode_command_execute` still blocked by the upstream 2.0.32 contract. See [current CI/upgrade evidence](007-ci-and-opencode-2.0.32.md) and [historical candidate evidence](verification.md#final-candidate-evidence-2026-09-17). No publication is claimed. | Package | Status | Dependency | |---|---|---| @@ -12,6 +12,7 @@ Implementation and local verification are complete for the supported scope; rele | [004: V2 mutations, forms, OAuth, permissions](004-v2-writes-input.md) | Implemented except upstream-blocked command; forms/OAuth fixture-tested | 003 | | [005: Durable V2 jobs and native Tasks](005-durable-v2-jobs.md) | Implemented; recovery and native Tasks fixture-tested; live inference unverified | 004 | | [006: Compatibility, documentation, package CI and release candidate](006-release-verification.md) | RC retained bytes verified on Node 22/24; initial native-test failure unreproduced; hosted OS matrix unrun | 005 | +| [007: CI cleanup and OpenCode 2.0.32](007-ci-and-opencode-2.0.32.md) | Local cleanup fix and adapter upgrade verified; refreshed hosted checks pending | 006 | ## Open upstream blocker diff --git a/scripts/generate-tool-docs.mjs b/scripts/generate-tool-docs.mjs index 1c3bdd5..a63854b 100644 --- a/scripts/generate-tool-docs.mjs +++ b/scripts/generate-tool-docs.mjs @@ -105,8 +105,8 @@ const compatibility = [ "- **supported:** an adapter mapping exists. Individual options may still require explicit preflight rejection; see [migration](../MIGRATION.md). This status does not claim that every option or live execution has been verified.", "- **unsupported:** an intentional V1-only exception for this milestone.", "- **blocked:** a required operation could not preserve the public behavior with the pinned upstream contract. It remains an outstanding compatibility limitation.", "", - "The integration targets are OpenCode V1 `opencode-ai@1.18.31` and OpenCode V2 `@opencode/cli@2.0.6`, with `@opencode/client@2.0.6` pinned. Version recognition and fixture coverage are separate from [live verification evidence](releasing.md). Doctor reports the target version without claiming that a live integration check has passed.", "", - "V2 whole-message reverts remain staged and reversible. Because upstream 2.0.6 automatically commits staged reverts when prompting or compacting, the bridge rejects those operations until the revert is cleared with `opencode_session_unrevert` or explicitly committed outside the bridge.", "", + "The integration targets are OpenCode V1 `opencode-ai@1.18.31` and OpenCode V2 `@opencode/cli@2.0.32`, with `@opencode/client@2.0.32` pinned. Version recognition and fixture coverage are separate from [live verification evidence](releasing.md). Doctor reports the target version without claiming that a live integration check has passed.", "", + "V2 whole-message reverts remain staged and reversible. Because upstream 2.0.32 automatically commits staged reverts when prompting or compacting, the bridge rejects those operations until the revert is cleared with `opencode_session_unrevert` or explicitly committed outside the bridge.", "", "## Required upstream follow-up", "", ...toolCapabilities.filter(item => item.v2 === "blocked").map(item => `- **\`${item.name}\`**: ${item.reason} No detached command or guessed completion result is substituted.`), "", ]; diff --git a/scripts/test-compat.mjs b/scripts/test-compat.mjs index 6f4580b..b3db47b 100644 --- a/scripts/test-compat.mjs +++ b/scripts/test-compat.mjs @@ -35,12 +35,12 @@ async function assertPortClosed(port) { if (closed) return; await new Promise(resolve => setTimeout(resolve, 50)); } - throw new Error("Isolated backend port remained open after process-tree cleanup"); + throw Object.assign(new Error("Isolated backend port remained open after process-tree cleanup"), { code: "BACKEND_PORT_OPEN" }); } export const targets = Object.freeze({ v1: { package: "opencode-ai", version: "1.18.31", health: "/global/health" }, - v2: { package: "@opencode/cli", version: "2.0.6", health: "/api/info" }, + v2: { package: "@opencode/cli", version: "2.0.32", health: "/api/info" }, }); export function compatibilityOptions(argv) { @@ -89,7 +89,7 @@ function withFailurePhase(error, phase) { } export function safeFailureDetails(error, processInfo) { const names = new Set(["Error", "TypeError", "AssertionError", "TimeoutError", "AbortError", "SystemError"]); - const codes = new Set(["ERR_ASSERTION", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOTFOUND", "ETIMEDOUT", "ESRCH", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "ERR_STREAM_PREMATURE_CLOSE"]); + const codes = new Set(["ERR_ASSERTION", "ECONNREFUSED", "ECONNRESET", "EPIPE", "ENOTFOUND", "ETIMEDOUT", "ESRCH", "EPERM", "EINVAL", "BACKEND_PORT_OPEN", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "ERR_STREAM_PREMATURE_CLOSE"]); const signals = new Set(["SIGTERM", "SIGKILL", "SIGINT", "SIGABRT", "SIGSEGV"]); const result = {}; if (names.has(error?.name)) result.name = error.name; @@ -311,7 +311,7 @@ async function checkBackend(kind, root, installed, options) { const catalog = await connection.request("tools/list"); assert.ok(catalog.tools.some((tool) => tool.name === "opencode_session_create")); await call("opencode_health"); - for (const name of ["opencode_project_current", "opencode_path_get", "opencode_agent_list", "opencode_config_get"]) { + for (const name of ["opencode_project_list", "opencode_project_current", "opencode_path_get", "opencode_agent_list", "opencode_config_get"]) { await call(name, { directory: project }); } const file = await call("opencode_file_read", { path: "README.md", directory: project }); diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index c4ef7c3..95c46a6 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -86,11 +86,22 @@ export async function stopProcess(processInfo) { const killer = childProcess("taskkill", ["/pid", String(child.pid), "/t", "/f"], {}); await killer.exited; } else { - const alive = () => { try { process.kill(-child.pid, 0); return true; } catch (error) { if (error.code === "ESRCH") return false; throw error; } }; - if (alive()) process.kill(-child.pid, "SIGTERM"); + const signalGroup = async (signal) => { + const deadline = Date.now() + 1000; + for (;;) { + try { process.kill(-child.pid, signal); return true; } + catch (error) { + if (error.code === "ESRCH") return false; + // Darwin can report EPERM for a group containing only unreaped zombies. + if (process.platform !== "darwin" || error.code !== "EPERM" || Date.now() >= deadline) throw error; + await delay(50); + } + } + }; + await signalGroup("SIGTERM"); const deadline = Date.now() + 3000; - while (alive() && Date.now() < deadline) await delay(50); - if (alive()) process.kill(-child.pid, "SIGKILL"); + while (await signalGroup(0) && Date.now() < deadline) await delay(50); + await signalGroup("SIGKILL"); } await exited; processInfo.stopped = true; diff --git a/src/backend-detection.ts b/src/backend-detection.ts index 0a53a7a..1d0333f 100644 --- a/src/backend-detection.ts +++ b/src/backend-detection.ts @@ -57,7 +57,7 @@ export interface DetectionDependencies { registration?: () => Promise; start?: typeof ensureServer; } -/** The pinned 2.0.6 SDK documents service.json as the complete discovery contract. +/** The pinned 2.0.32 SDK documents service.json as the complete discovery contract. * Its discover() hides authentication failures, so preflight the registration * before discovery and never fall through a present/inconclusive registration. */ async function readRegistration(): Promise { diff --git a/src/backends/capabilities.ts b/src/backends/capabilities.ts index f768c70..489b0d6 100644 --- a/src/backends/capabilities.ts +++ b/src/backends/capabilities.ts @@ -43,7 +43,7 @@ export const capabilities: readonly Capability[] = [ { name: "opencode_message_get", kind: "tool", v1: "supported", v2: "supported" }, { name: "opencode_message_send", kind: "tool", v1: "supported", v2: "supported" }, { name: "opencode_message_send_async", kind: "tool", v1: "supported", v2: "supported" }, - { name: "opencode_command_execute", kind: "tool", v1: "supported", v2: "blocked", reason: "V2 2.0.6 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely." }, + { name: "opencode_command_execute", kind: "tool", v1: "supported", v2: "blocked", reason: "V2 2.0.32 command accepts no caller identifier and returns void; exact execution completion cannot be correlated safely." }, { name: "opencode_shell_execute", kind: "tool", v1: "supported", v2: "supported" }, { name: "opencode_session_list", kind: "tool", v1: "supported", v2: "supported" }, { name: "opencode_session_create", kind: "tool", v1: "supported", v2: "supported" }, diff --git a/src/backends/v2-guards.ts b/src/backends/v2-guards.ts index d03b5dc..15195c5 100644 --- a/src/backends/v2-guards.ts +++ b/src/backends/v2-guards.ts @@ -15,7 +15,7 @@ export function assertSessionSelection(session: SessionInfo, body: PromptOptions if (body.agent !== undefined && body.agent !== session.agent) throw new BackendCapabilityError("session.agent", "Existing V2 sessions preserve their agent."); } -/** Upstream 2.0.6 prompt/compact implicitly commit staged reverts. Never trigger that hidden write. */ +/** Upstream 2.0.32 prompt/compact implicitly commit staged reverts. Never trigger that hidden write. */ export function assertNoStagedRevert(session: SessionInfo): void { - if (session.revert) throw new BackendCapabilityError("session.staged_revert", "V2 2.0.6 would automatically commit this staged revert when prompting or compacting. Clear it with session_unrevert, or explicitly commit it outside this bridge, before continuing."); + if (session.revert) throw new BackendCapabilityError("session.staged_revert", "V2 2.0.32 would automatically commit this staged revert when prompting or compacting. Clear it with session_unrevert, or explicitly commit it outside this bridge, before continuing."); } diff --git a/src/backends/v2.ts b/src/backends/v2.ts index 36ea8d7..f7cc395 100644 --- a/src/backends/v2.ts +++ b/src/backends/v2.ts @@ -19,11 +19,8 @@ export class V2Adapter implements BackendAdapter { readonly api: ReturnType; private readonly mutation = new AsyncLocalStorage<{ submitted: boolean; method?: string; path?: string; recovery?: {sessionId:string;messageId?:string} }>(); constructor(client: OpenCodeClient) { - const base = new URL(client.getBaseUrl()); this.api = OpenCode.make({ baseUrl: client.getBaseUrl(), headers: client.connectionHeaders, fetch: async (input, init) => { const url = new URL(input instanceof Request ? input.url : input.toString()); - const prefix = base.pathname.replace(/\/$/, ""); - if (prefix && prefix !== "/" && url.origin === base.origin) url.pathname = prefix + url.pathname; const method = init?.method ?? (input instanceof Request ? input.method : "GET"); const write = method !== "GET" && method !== "HEAD"; throwIfAborted(init?.signal ?? undefined); @@ -58,7 +55,7 @@ export class V2Adapter implements BackendAdapter { switch (operation) { case "lifecycle.health": { const info = await api.server.info(options); if (!info || typeof info.version !== "string" || typeof info.pid !== "number" || !Array.isArray(info.urls)) throw new Error("Malformed V2 server information"); return { healthy: true, version: info.version, backend: "v2" }; } case "configuration.get": return { backend: "v2", sources: await api.config.get({ location }, options), effectiveConfiguration: false }; - case "projects.list": return (await api.project.list(options)).map(project => ({ ...project, worktree: project.canonical })); + case "projects.list": return (await api.project.list({}, options)).map(project => ({ ...project, worktree: project.canonical })); case "projects.current": { const value = await api.location.get({ location }, options); return { ...value.project, worktree: value.project.directory, directory: value.directory }; } case "files.paths": { const value = await api.location.get({ location }, options); return { directory: value.directory, worktree: value.project.directory, backend: "v2" }; } case "files.vcs": return (await api.vcs.get({ location }, options)).data; @@ -134,7 +131,7 @@ export class V2Adapter implements BackendAdapter { case "forms.list": return (await api.form.list({ location }, options)).data.map(form => ({ ...form, backend: "v2" })); case "sessions.create": { assertPromptOptions(body); - if (body.parentID !== undefined) throw new BackendCapabilityError("session_create.parentID", "V2 session creation has no parentID equivalent; use an explicit fork instead."); + if (body.parentID !== undefined) throw new BackendCapabilityError("session_create.parentID", "This bridge does not support parentID creation; use an explicit fork instead."); const model = body.model ?? applyModelDefaults(); if (body.variant && !model) throw new BackendCapabilityError("session.variant", "Select a provider and model before requesting a V2 model variant."); return normalizeSession(await api.session.create({ title: body.title, agent: body.agent, @@ -186,7 +183,7 @@ export class V2Adapter implements BackendAdapter { if (operation === "messages.enqueue") return receipt; return this.waitPrompt(sessionID(), id, options); } - case "messages.command": throw new BackendCapabilityError("command_execute", "V2 2.0.6 command submission returns no receipt and accepts no caller identifier. Exact execution completion cannot be correlated safely; no command was submitted."); + case "messages.command": throw new BackendCapabilityError("command_execute", "V2 2.0.32 command submission returns no receipt and accepts no caller identifier. Exact execution completion cannot be correlated safely; no command was submitted."); case "messages.shell": { const session = await this.scopedSession(args, options); assertSessionSelection(session, body); const id = createMessageId(); diff --git a/src/doctor.ts b/src/doctor.ts index 6a4918b..93e4ff3 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -62,7 +62,7 @@ export async function runDoctor(options: DoctorOptions = {}): Promise { + it.skipIf(process.platform !== "darwin").each([0, "SIGTERM", "SIGKILL"])("waits for Darwin to reap a zombie-only group during %s", async transientSignal => { + let transient = true; + const kill = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => { + if (signal === transientSignal && transient) { + transient = false; + throw Object.assign(new Error("zombie group"), { code: "EPERM" }); + } + throw Object.assign(new Error("group exited"), { code: "ESRCH" }); + }); + const owned = { child: { pid: 12345 }, exited: Promise.resolve({ code: 0 }), ownedProcessGroup: true }; + try { await expect(stopProcess(owned)).resolves.toBeUndefined(); expect(transient).toBe(false); } + finally { kill.mockRestore(); } + }); + it.skipIf(process.platform === "win32").each(["SIGTERM", "SIGKILL"])("accepts a process group exiting before %s", async missingSignal => { + let probes = 0; + const kill = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => { + if (signal === missingSignal || (signal === 0 && ++probes === 2)) throw Object.assign(new Error("group exited"), { code: "ESRCH" }); + return true; + }); + const owned = { child: { pid: 12345 }, exited: Promise.resolve({ code: 0 }), ownedProcessGroup: true }; + try { await expect(stopProcess(owned)).resolves.toBeUndefined(); } + finally { kill.mockRestore(); } + }); + it.skipIf(process.platform === "win32")("does not hide process-group permission failures", async () => { + const error = Object.assign(new Error("permission denied"), { code: "EPERM" }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => { throw error; }); + try { + await expect(stopProcess({ child: { pid: 12345 }, exited: Promise.resolve({ code: 0 }), ownedProcessGroup: true })).rejects.toBe(error); + } finally { kill.mockRestore(); } + }); it("invokes JavaScript shebang wrappers through the current Node executable", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-launcher-")); try { @@ -111,7 +141,7 @@ describe("retained compatibility archives", () => { function diagnostic(configured: boolean) { return { schemaVersion: 1, ready: configured, checks: ["node", "configuration", "backend", "authentication", "supported_version", "project_access", "lifecycle"].map(id => ({ id, status: "pass" })).concat([ { id: "provider_configuration", status: configured ? "pass" : "fail", message: configured ? "Provider configured" : "No configured provider was found." } as any, - ]), backend: { version: "2.0.6", integrationTargetVersion: "2.0.6", matchesIntegrationTarget: true, processOwnership: "external", connectionSource: "explicit", survivesDisconnect: true } }; + ]), backend: { version: "2.0.32", integrationTargetVersion: "2.0.32", matchesIntegrationTarget: true, processOwnership: "external", connectionSource: "explicit", survivesDisconnect: true } }; } describe("native doctor contract assertions", () => { it.each([false, true])("accepts accurate readiness when configured=%s", configured => { @@ -141,5 +171,7 @@ describe("safe native failure classification", () => { it("allows only known names, codes, signals and numeric exit status", () => { expect(safeFailureDetails({ name: "secret-sentinel", code: "secret-sentinel" }, { child: { exitCode: "secret-sentinel", signalCode: "secret-sentinel" } })).toEqual({ backendExitCode: null, backendSignal: null }); expect(safeFailureDetails({ name: "Error", code: "ESRCH" }, { child: { exitCode: 1, signalCode: "SIGTERM" } })).toEqual({ name: "Error", code: "ESRCH", backendExitCode: 1, backendSignal: "SIGTERM" }); + expect(safeFailureDetails({ name: "Error", code: "EPERM", message: "secret-sentinel" })).toEqual({ name: "Error", code: "EPERM" }); + expect(safeFailureDetails({ name: "Error", code: "BACKEND_PORT_OPEN" })).toEqual({ name: "Error", code: "BACKEND_PORT_OPEN" }); }); }); diff --git a/tests/v2-adapter.test.ts b/tests/v2-adapter.test.ts index 91f0336..8d8aa43 100644 --- a/tests/v2-adapter.test.ts +++ b/tests/v2-adapter.test.ts @@ -31,6 +31,11 @@ async function fixture(handler: (call: Call, res: ServerResponse) => unknown, pr } describe("V2 actual SDK HTTP contracts",()=>{ + it("lists projects with the 2.0.32 input/options signature",async()=>{ + const {client,calls}=await fixture(()=>[{id:"prj_a",canonical:"/server/project"}]); + await expect(operate(client,"projects.list")).resolves.toMatchObject([{id:"prj_a",worktree:"/server/project"}]); + expect(calls[0].url.pathname).toBe("/api/project"); + }); it.each(["/bridge","/api"])("preserves authoritative base URL prefix %s",async prefix=>{ const {client,calls}=await fixture(()=>({version:"2.0.6",pid:1,urls:[],paths:{tmp:"/tmp"}}),prefix); await expect(operate(client,"lifecycle.health")).resolves.toMatchObject({healthy:true,backend:"v2"}); @@ -219,13 +224,14 @@ describe("V2 normalized reads and scoped mutations over HTTP",()=>{ }); const result=await operate(client,"messages.shell",{sessionId:"ses_a",body:{command:"false",agent:"build"}});expect(result.isError).toBe(true);expect(result.info.id).toBe(assigned);expect(result.parts[0].text).toContain("failed");expect(calls.filter(call=>call.method==="POST")).toHaveLength(1); }); - it("filters project events by actual server location and releases the stream",async()=>{ - const {client}=await fixture(({url},res)=>{ - if(url.pathname==="/api/location")return{directory:"/canonical/project",project:{id:"prj",directory:"/canonical/project",canonical:"/canonical/project"}}; + it.each(["", "/bridge", "/api"])("filters project events and preserves URL prefix %s",async prefix=>{ + const {client,calls}=await fixture(({url},res)=>{ + if(url.pathname===prefix+"/api/location")return{directory:"/canonical/project",project:{id:"prj",directory:"/canonical/project",canonical:"/canonical/project"}}; res.setHeader("content-type","text/event-stream");res.end([{type:"server.connected"},{type:"session.created",location:{directory:"/other"},data:{}},{type:"session.updated",location:{directory:"/canonical/project"},data:{}}].map(event=>`data: ${JSON.stringify(event)}\n\n`).join("")); - }); + },prefix); const received=[];for await(const event of events(client,"project",{directory:"/server/alias",timeout:1000})){received.push(event);break;} expect(received.map(event=>event.event)).toEqual(["session.updated"]); + expect(calls.map(call=>call.url.pathname)).toEqual([prefix+"/api/location",prefix+"/api/event"]); }); }); diff --git a/tests/v2-jobs.test.ts b/tests/v2-jobs.test.ts index a26a4b2..21950c6 100644 --- a/tests/v2-jobs.test.ts +++ b/tests/v2-jobs.test.ts @@ -185,8 +185,12 @@ describe("V2 durable job controls", () => { }); it("bounds a hung cancellation and never replays its ambiguous mutation", async () => { const f = fixture(); const service = new JobService(f.client, { storeRoot: root }); const job = await running(f, service); - f.api.session.interrupt.mockImplementation(() => new Promise(() => {})); - expect((await withRequestOptions({ timeout: 30 }, () => service.cancel(job.jobId!))).status).toBe("unknown"); + const controller = new AbortController(); + f.api.session.interrupt.mockImplementation(() => { + controller.abort(new DOMException("Operation deadline exceeded", "TimeoutError")); + return new Promise(() => {}); + }); + expect((await withRequestOptions({ signal: controller.signal }, () => service.cancel(job.jobId!))).status).toBe("unknown"); expect((await service.cancel(job.jobId!)).status).toBe("unknown"); expect(f.api.session.interrupt).toHaveBeenCalledTimes(1); }); it("allows retry after a definite nested SDK cancellation rejection", async () => { From d173d13d38961d350ad07f9644ddd5bd2b21ffd9 Mon Sep 17 00:00:00 2001 From: Alaeddine Messadi <10847134+AlaeddineMessadi@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:47:17 +0100 Subject: [PATCH 3/3] fix: guard V1 startup and prepare experimental V2 prerelease --- CHANGELOG.md | 9 ++++++ MIGRATION.md | 4 ++- README.md | 4 ++- docs/compatibility.md | 2 ++ docs/configuration.md | 2 +- docs/getting-started.md | 4 ++- docs/releasing.md | 10 +++++- package-lock.json | 4 +-- package.json | 2 +- plans/007-ci-and-opencode-2.0.32.md | 2 +- plans/008-experimental-prerelease.md | 34 ++++++++++++++++++++ plans/README.md | 8 +++-- scripts/generate-tool-docs.mjs | 1 + src/backend-detection.ts | 3 +- src/server-manager.ts | 31 +++++++++++++++--- tests/backend-detection.test.ts | 19 +++++++++++ tests/server-manager.test.ts | 48 ++++++++++++++++++++++++++-- 17 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 plans/008-experimental-prerelease.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b3e41..f3bf660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.1.0-rc.2] - 2026-10-18 + +Unpublished prerelease candidate with **experimental V2 support**. V1 behavior remains supported. The prerelease scope is 64 V2 tool mappings, 22 V1-only exceptions, and one disabled V2 operation; it does not complete the original 65-tool milestone. + +- Keep `opencode_command_execute` disabled on V2 with `UNSUPPORTED_CAPABILITY` before submission until [upstream #49690](https://github.com/anomalyco/opencode/issues/49690) provides reliable command identity and completion correlation. V1 commands remain available. +- Integrate the applicable [#31](https://github.com/AlaeddineMessadi/opencode-mcp/issues/31) startup diagnostic fix: check the local executable before opt-in V1 startup, reject recognized V2 versions promptly, and preserve actionable connection instructions. Invalid V1 health responses cannot trigger replacement startup. Authentication failures and owned-child cleanup remain fail-closed. +- Include the October 18 client/CLI 2.0.32 compatibility, prefixed event-stream, macOS process-cleanup, and dependency updates described below. +- Live model workflows, recovery/cancellation, forms/permissions, OAuth completion, and interactive-client behavior remain unverified. Contract tests are not live validation. See [release checks](docs/releasing.md). + ## [3.1.0-rc.1] - 2026-09-17 Evaluation candidate for the V1/V2 compatibility foundation. The original 65-operation V2 completion gate remains blocked by upstream command correlation; this is not a completed milestone declaration. diff --git a/MIGRATION.md b/MIGRATION.md index a9d00fd..d1bc045 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,7 @@ # OpenCode V1/V2 compatibility migration +> **Experimental V2 support in 3.1.0-rc.2.** V2 slash-command execution is disabled before submission. V1 behavior remains supported. Live inference, recovery, forms, OAuth, and interactive clients are not yet verified; see [release evidence](docs/releasing.md). + The compatibility foundation preserves the existing V1 SDK and V1 job records. It adds the pinned `@opencode/client@2.0.32` adapter, explicit backend detection, and a read-only doctor. An API mapping is not proof of live end-to-end behavior: consult the [release verification guide](docs/releasing.md) and record actual backend check results separately. ## Select a backend @@ -10,7 +12,7 @@ Without an explicit URL, the bridge probes `http://127.0.0.1:4096`, then checks `OPENCODE_AUTO_SERVE=true` continues to allow an owned local V1 child when no existing backend or service registration is present. It never enables V2 `Service.ensure()` or stops a shared service. An owned child stops with MCP; externally managed and discovered servers can continue work while their processes remain alive. -For a manually started V2 server, set `OPENCODE_BACKEND=v2`, `OPENCODE_AUTO_SERVE=false`, `OPENCODE_BASE_URL` to its listening URL, and `OPENCODE_SERVER_PASSWORD` to the password it reports. The username defaults to `opencode`. The published 3.0.0 package does not support V2; use this compatibility candidate. Issue [#31](https://github.com/AlaeddineMessadi/opencode-mcp/issues/31) tracks the older V1 launcher's timeout when a V2 executable is installed. +For a manually started V2 server, set `OPENCODE_BACKEND=v2`, `OPENCODE_AUTO_SERVE=false`, `OPENCODE_BASE_URL` to its listening URL, and `OPENCODE_SERVER_PASSWORD` to the password it reports. The username defaults to `opencode`. The published 3.0.0 package does not support V2; use this compatibility candidate. The [#31](https://github.com/AlaeddineMessadi/opencode-mcp/issues/31) startup guard now checks `opencode --version` before owned-child startup and rejects a recognized V2 executable promptly. If a custom launcher cannot report a version, the bounded SDK startup path remains. ## Check readiness diff --git a/README.md b/README.md index 5e05107..72b4e0d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ opencode-mcp connects Claude, Cursor, VS Code, and other MCP clients to OpenCode > Version 3.0.0 requires **Node.js 22 or newer**. Upgrading from 2.x? See the [migration notes](CHANGELOG.md#300---2026-09-16). -This source branch adds an OpenCode V1/V2 compatibility foundation. It retains the V1 API and adds the pinned V2 client contract. V2 currently maps **64 tools**, keeps **22 V1-only exceptions**, and blocks **1 command operation** whose completion cannot be correlated safely. See the [generated compatibility inventory](docs/compatibility.md) and [V1/V2 migration guide](MIGRATION.md). This is not a claim that every live backend workflow has been verified. +**3.1.0-rc.2 is a prerelease candidate; V2 support is experimental.** The stable npm package remains 3.0.0 and supports V1. Use the source-build instructions below to evaluate this unpublished candidate. + +This source branch adds an OpenCode V1/V2 compatibility foundation. It retains the V1 API and adds the pinned V2 client contract. V2 currently maps **64 tools**, keeps **22 V1-only exceptions**, and disables **`opencode_command_execute` on V2** because its completion cannot be correlated safely. V1 command execution remains available. See the [generated compatibility inventory](docs/compatibility.md) and [V1/V2 migration guide](MIGRATION.md). This is not a claim that every live backend workflow has been verified. ## Quick Start diff --git a/docs/compatibility.md b/docs/compatibility.md index 48d20f0..95daf3d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -6,6 +6,8 @@ The catalog contains **87 tools**. V1 supports all 87; V2 has **64 supported too The full and essential profiles keep their existing names and schemas. An unavailable V2 call returns a structured `UNSUPPORTED_CAPABILITY` error before mutation; it never falls back to a V1 route. Resource and prompt inventories are included below. +**V2 support is experimental in the 3.1 prerelease.** V2 slash-command execution is disabled before submission. V1 command execution remains supported. Live inference, recovery, forms, OAuth and interactive clients still require verification. + ## Meaning of support - **supported:** an adapter mapping exists. Individual options may still require explicit preflight rejection; see [migration](../MIGRATION.md). This status does not claim that every option or live execution has been verified. diff --git a/docs/configuration.md b/docs/configuration.md index 159376a..d723f3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -155,6 +155,6 @@ opencode serve --hostname 127.0.0.1 --port 4096 Or share the TUI's server by starting it with `opencode --port 4096`. For custom flags, start OpenCode manually. `OPENCODE_SERVE_ARGS` is unsupported by the SDK launcher. -To opt in to a separate local V1 child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. Automatic startup accepts loopback HTTP endpoints only and shuts down only the child it launched. An existing external server or discovered V2 service remains running when the MCP client disconnects. Start V2 separately and use its URL or existing local registration. +To opt in to a separate local V1 child server, add `"OPENCODE_AUTO_SERVE": "true"` under your MCP server's `env`. Automatic startup accepts loopback HTTP endpoints only and shuts down only the child it launched. An existing external server or discovered V2 service remains running when the MCP client disconnects. A preflight `opencode --version` check rejects recognized V2 executables before V1 SDK startup. Start V2 separately and use its URL or existing local registration; V2 support is experimental. When other TUI instances are running, prefer a shared explicitly configured server. [Issue #18](https://github.com/AlaeddineMessadi/opencode-mcp/issues/18) reports hangs when a second server shares OpenCode storage; the underlying cause has not been confirmed. MCP does not scan processes to select a TUI. diff --git a/docs/getting-started.md b/docs/getting-started.md index ca060a1..33815b4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,5 +1,7 @@ # Getting Started +> This candidate adds **experimental V2 support**. Stable npm 3.0.0 supports V1; build this branch to evaluate V2. V2 slash-command execution is disabled. See the [migration guide](../MIGRATION.md) for connection settings and limitations. + ## Prerequisites - Node.js **22 or newer** for version 3.0.0 and later. @@ -16,7 +18,7 @@ From your project directory: opencode serve --hostname 127.0.0.1 --port 4096 ``` -Alternatively, share your TUI's server by starting it with `opencode --port 4096`. Automatic startup is disabled by default. Opt in with `OPENCODE_AUTO_SERVE=true` only when you want a separate child server. +Alternatively, share your TUI's server by starting it with `opencode --port 4096`. Automatic startup is disabled by default. Opt in with `OPENCODE_AUTO_SERVE=true` only when you want a separate V1 child server. A recognized V2 executable is rejected before launch; start V2 separately and configure its URL and credentials. ## 2. Add MCP to Your Client diff --git a/docs/releasing.md b/docs/releasing.md index e760039..10b028d 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,5 +1,13 @@ # Release and live verification +## Experimental prerelease scope + +Candidate `3.1.0-rc.2` preserves V1 behavior and marks V2 experimental: 64 mapped tools, 22 explicit V1-only exceptions, and `opencode_command_execute` disabled before submission. This user-approved reduced scope allows prerelease preparation without waiting for upstream #49690; the original 65-tool milestone remains incomplete. + +Publication is a separate authorized action. Publish the tested archive with `npm publish /absolute/path/opencode-mcp-3.1.0-rc.2.tgz --tag next`, never the default tag. Keep `latest` on 3.0.0 and verify both tags afterward. No tag, GitHub release, or npm publication is created by preparation. + +Before stable V2 promotion, verify live prompt/job completion, restart recovery, cancellation ownership, forms/permissions, OAuth and at least one actual MCP client with exact provider/model/backend versions recorded. Until then, keep experimental and unverified labels visible. Upstream command support requires its own contract and regression evidence before enabling it. + ## V1/V2 evidence requirements The compatibility foundation targets `opencode-ai@1.18.31` for V1 and `@opencode/cli@2.0.32` for V2, with `@opencode/client@2.0.32` pinned. Run each in a disposable installation and isolated state directory. Do not replace a global OpenCode executable or attach to a user's shared service for mutation tests. @@ -8,7 +16,7 @@ Fixture tests verify transport contracts, normalization, request guards, job cor The generated [capability inventory](compatibility.md) currently lists 64 V2 tool mappings, 22 intentional V1-only exceptions, and one upstream-blocked operation. `opencode_command_execute` remains blocked because the V2 2.0.32 contract cannot correlate exact command completion. Passing tests does not fulfill that missing behavior, and no detached command may be substituted. Keep this limitation in release notes until an upstream contract and its regression tests resolve it. -October 18 local verification: isolated V1 1.18.31 and V2 2.0.32 passed the model-free native archive checks on macOS arm64 with Node 22.23.3 and 24.20.0. The regression suite passed 764 tests with three platform/baseline skips on each Node version. These results do not cover live inference, job execution/recovery, forms/permissions, OAuth, or interactive clients. +Previous verification at `3454942` (October 18): isolated V1 1.18.31 and V2 2.0.32 passed the model-free native archive checks on macOS arm64 with Node 22.23.3 and 24.20.0. The regression suite passed 764 tests with three platform/baseline skips on each Node version. These results do not cover live inference, job execution/recovery, forms/permissions, OAuth, or interactive clients. ## Verify the installed archive and isolated backends diff --git a/package-lock.json b/package-lock.json index 7f3d100..ae656f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-mcp", - "version": "3.1.0-rc.1", + "version": "3.1.0-rc.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-mcp", - "version": "3.1.0-rc.1", + "version": "3.1.0-rc.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/core": "^2.0.0", diff --git a/package.json b/package.json index a8d2718..7ee1dd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-mcp", - "version": "3.1.0-rc.1", + "version": "3.1.0-rc.2", "description": "MCP server for OpenCode coding workflows, durable async jobs, project resources, and explicit user input", "type": "module", "main": "dist/index.js", diff --git a/plans/007-ci-and-opencode-2.0.32.md b/plans/007-ci-and-opencode-2.0.32.md index 33e493e..35cdf01 100644 --- a/plans/007-ci-and-opencode-2.0.32.md +++ b/plans/007-ci-and-opencode-2.0.32.md @@ -29,7 +29,7 @@ An unrelated 30ms cancellation fixture timed out during filesystem setup in a pa - Node 24.20.0: real isolated V1 1.18.31 and V2 2.0.32 plus installed bridge archive passed (`artifacts/compat-2032-cleanup-fixed-node24.log`). Includes runtime identity, read-only doctor, project list/current, paths, agents, configuration, file reads, session CRUD/list, bridge disconnect survival, process cleanup and port closure. - Node 22.23.3: production archive executable, initialization, 87-tool catalog, fixture calls and EOF shutdown passed (`artifacts/package-2032-node22.log`). - Node 22.23.3: the same V1/V2 native checks passed (`artifacts/compat-2032-node22.log`). Both Node versions tested the archive with integrity `sha512-ZD7tAE4YPF9Mrj82RecxLxxVGTkFwZ9JkGC3QsDGwVWfIZfSNUiDU6eQ6p4wGCRW2m7DnDnYd31WlfxyvNJxog==`. -- Generated docs check, audit (zero advisories), and whitespace checks passed. Updated hosted CI results will be recorded after completion. +- Generated docs check, audit (zero advisories), and whitespace checks passed. All six hosted Ubuntu/macOS/Windows × Node 22/24 jobs passed at `3454942` in [run 39153690429](https://github.com/AlaeddineMessadi/opencode-mcp/actions/runs/39153690429). Live inference, durable execution recovery, forms/permissions, OAuth and GUI clients remain unverified. Existing fixture coverage is not live evidence. The retained September RC archive is unchanged and does not contain this continuation. diff --git a/plans/008-experimental-prerelease.md b/plans/008-experimental-prerelease.md new file mode 100644 index 0000000..e8c1b05 --- /dev/null +++ b/plans/008-experimental-prerelease.md @@ -0,0 +1,34 @@ +# 008: Experimental V2 prerelease and issue #31 + +Date: 2026-10-18. Supersedes the release acceptance scope in earlier work packages; their historical evidence remains intact. + +## Approved release scope + +The user requested integrating the other agent's #31 fix, finishing merge checks, and preparing a prerelease with V2 command execution disabled and V2 support experimental. Candidate: `3.1.0-rc.2`. Preserve V1 behavior and the 64 V2 mappings / 22 V1-only exceptions / one disabled command accounting. The original 65-tool milestone is still incomplete; upstream [#49690](https://github.com/anomalyco/opencode/issues/49690) no longer blocks this explicitly reduced prerelease scope. + +No npm publication, Git tag, GitHub release or merge is part of preparation. After separate publication authorization, publish the exact tested archive to `next`, preserving stable `latest` at 3.0.0. The September rc.1 archive is untouched. + +## Integration and review + +Adapted the local fix from the **Review repository contribution** chat's `codex/issue31-startup-diagnostics` worktree; its uncommitted files are left intact. The V1 startup path now probes `opencode --version`, rejects recognized V2/newer executables before SDK launch, and explains how to connect to an existing V2 server. Probe time counts against the total startup budget. Unknown launcher versions retain bounded SDK startup. Invalid V1 health JSON cannot trigger replacement startup; owned children close exactly once on failed validation. Authentication remains terminal and never retries without credentials. + +The V2-aware detector still accepts a V1 HTML fallback followed by valid V2 server information; it propagates actionable startup incompatibility errors. Focused tests cover versions, HTML, cleanup, total budget and existing V2 connection. Existing manifest tests verify all 23 unavailable V2 tools fail before transport or mutation, including `opencode_command_execute`. V1 command behavior remains covered by HTTP integration tests. + +Scope reaches server-manager callers (`backend-detection` and V1 reconnect), their tests, package/lock metadata, generated compatibility documentation, setup/migration/release guides and changelog. No new dependency, no record-format change, no credential output. PR #34 remains separate and is not integrated. No external comment was posted. + +## Candidate verification + +- Clean `npm ci`, `npm test`, coverage and generated-doc checks passed on macOS arm64 / Node 22.23.3: **774 passed, 3 skipped**. Coverage: 72.09% statements, 70.85% branches, 72.74% functions, 75.28% lines. Node 24.20.0 also passed 774/3 before the final SyntaxError-only health-parser refinement; final revision coverage is enforced by CI on both runtimes. +- `npm audit`: zero advisories. `git diff --check`: passed. +- Retained archive: `artifacts/opencode-mcp-3.1.0-rc.2.tgz`, 196116 bytes, 134 entries. Inspected the allowlisted contents, version, experimental label and startup guard. Integrity: + `sha512-coxSRIxFzipefaddTrZNs0iQ4Z0y8U1Bciu5FSKoWyk2FWLcfARV0w3BMBUVRJG5+CMUMgEjv7BToAeMoj46hw==`. +- Exact retained archive passed production-only executable/stdio/catalog/fixture/shutdown checks on Node 22.23.3. +- That same archive passed isolated native V1 `opencode-ai@1.18.31` and V2 `@opencode/cli@2.0.32` checks on Node 22.23.3: runtime versions, doctor read-only/no startup/redaction behavior, project/path/agents/config/file reads, session CRUD/list, disconnect survival and cleanup. V2 doctor correctly reported no configured provider in the isolated test environment; no readiness or inference claim is made from that check. +- Logs: `artifacts/rc2-final-tests-node22.log`, `rc2-final-coverage-node22.log`, `rc2-docs-check.log`, `rc2-audit.json`, `rc2-package-node22.log`, `rc2-compat-node22.log` and `rc2-pack.json`. Local artifacts/logs remain ignored; the archive must not be rebuilt between validation and eventual publication. +- Hosted Ubuntu/macOS/Windows × Node 22/24 remains a required merge check on the pushed revision. Final hosted results and any additional Node 24 archive results are recorded in [PR #29](https://github.com/AlaeddineMessadi/opencode-mcp/pull/29). No human approval is claimed; the current ruleset requires a PR but zero approving reviews. + +An initial Node 22 coverage invocation overlapped a Node 24 build writing the shared `dist` directory and saw one CLI invalid-argument exit mismatch (1 versus 2). A standalone rerun passed. Build overlap is the suspected cause, not a reproduced application defect; preserve the first failure log and run subsequent checks sequentially. + +## Remaining evidence boundaries + +Model-free native checks do not verify live inference, durable execution/restart recovery, cancellation against a provider, forms/permissions, OAuth completion or GUI client behavior. Those need exact backend/provider/model/client versions and explicit test results before stable V2 promotion. Parent-session creation remains conservatively rejected pending semantic validation; V2 service startup is intentionally external. Windows Node 26 from the issue report is outside the tested Node 22/24 matrix. diff --git a/plans/README.md b/plans/README.md index 993724f..af09328 100644 --- a/plans/README.md +++ b/plans/README.md @@ -2,7 +2,7 @@ Baseline: `0de817a`. User-approved scope: milestone 1 only. Original checkout and historical review remain intact. -Implementation and local verification are complete for the supported scope; release candidate `3.1.0-rc.1` is prepared. The original milestone acceptance gate remains **BLOCKED**: V2 supports 64 tools, with 22 planned V1-only exceptions and `opencode_command_execute` still blocked by the upstream 2.0.32 contract. See [current CI/upgrade evidence](007-ci-and-opencode-2.0.32.md) and [historical candidate evidence](verification.md#final-candidate-evidence-2026-09-17). No publication is claimed. +The user approved preparing `3.1.0-rc.2` with V2 marked experimental and V2 slash-command execution disabled. See [prerelease acceptance and evidence](008-experimental-prerelease.md). The original milestone acceptance gate remains **BLOCKED**: V2 supports 64 tools, with 22 planned V1-only exceptions and `opencode_command_execute` still blocked by the upstream 2.0.32 contract. See [current CI/upgrade evidence](007-ci-and-opencode-2.0.32.md) and [historical candidate evidence](verification.md#final-candidate-evidence-2026-09-17). No publication is claimed. | Package | Status | Dependency | |---|---|---| @@ -11,8 +11,10 @@ Implementation and local verification are complete for the supported scope; rele | [003: V2 reads, resources, and normalization](003-v2-reads.md) | Implemented; contract tests and model-free native subset passed | 002 | | [004: V2 mutations, forms, OAuth, permissions](004-v2-writes-input.md) | Implemented except upstream-blocked command; forms/OAuth fixture-tested | 003 | | [005: Durable V2 jobs and native Tasks](005-durable-v2-jobs.md) | Implemented; recovery and native Tasks fixture-tested; live inference unverified | 004 | -| [006: Compatibility, documentation, package CI and release candidate](006-release-verification.md) | RC retained bytes verified on Node 22/24; initial native-test failure unreproduced; hosted OS matrix unrun | 005 | -| [007: CI cleanup and OpenCode 2.0.32](007-ci-and-opencode-2.0.32.md) | Local cleanup fix and adapter upgrade verified; refreshed hosted checks pending | 006 | +| [006: Compatibility, documentation, package CI and release candidate](006-release-verification.md) | Historical rc.1 evidence retained; superseded by 007/008 | 005 | +| [007: CI cleanup and OpenCode 2.0.32](007-ci-and-opencode-2.0.32.md) | Verified locally and in all six hosted OS/Node jobs at 3454942 | 006 | + +| [008: Experimental prerelease and issue #31](008-experimental-prerelease.md) | Local rc.2 archive verified; final hosted checks tracked in PR #29; no publication | 007 | ## Open upstream blocker diff --git a/scripts/generate-tool-docs.mjs b/scripts/generate-tool-docs.mjs index a63854b..134ec54 100644 --- a/scripts/generate-tool-docs.mjs +++ b/scripts/generate-tool-docs.mjs @@ -101,6 +101,7 @@ const compatibility = [ "", "", `The catalog contains **${toolCapabilities.length} tools**. V1 supports all ${toolCapabilities.length}; V2 has **${countV2("supported")} supported tools**, **${countV2("unsupported")} planned V1-only exceptions**, and **${countV2("blocked")} operation blocked by an upstream contract limitation**.`, "", "The full and essential profiles keep their existing names and schemas. An unavailable V2 call returns a structured `UNSUPPORTED_CAPABILITY` error before mutation; it never falls back to a V1 route. Resource and prompt inventories are included below.", "", + "**V2 support is experimental in the 3.1 prerelease.** V2 slash-command execution is disabled before submission. V1 command execution remains supported. Live inference, recovery, forms, OAuth and interactive clients still require verification.", "", "## Meaning of support", "", "- **supported:** an adapter mapping exists. Individual options may still require explicit preflight rejection; see [migration](../MIGRATION.md). This status does not claim that every option or live execution has been verified.", "- **unsupported:** an intentional V1-only exception for this milestone.", diff --git a/src/backend-detection.ts b/src/backend-detection.ts index 1d0333f..3f4368b 100644 --- a/src/backend-detection.ts +++ b/src/backend-detection.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { Service, type Endpoint } from "@opencode/client/service"; import { createRequestContext, throwIfAborted, withAbort, type RequestOptions } from "./async.js"; -import { ensureServer, ServerAuthenticationError, stopServer } from "./server-manager.js"; +import { ensureServer, IncompatibleServerError, ServerAuthenticationError, stopServer } from "./server-manager.js"; import type { BackendIdentity, BackendKind } from "./backends/contracts.js"; export const DEFAULT_BASE_URL = "http://127.0.0.1:4096"; @@ -162,6 +162,7 @@ export async function detectBackend(config: BackendConfig, options: RequestOptio } catch (error) { if (context.signal.aborted) throw new BackendDetectionError("timeout", "Backend detection exceeded its time budget."); if (error instanceof ServerAuthenticationError) throw new BackendDetectionError("authentication", "Server authentication failed; no alternate server was started.", error.status); + if (error instanceof IncompatibleServerError) throw new BackendDetectionError("incompatible", error.message); if (error instanceof BackendDetectionError) throw error; throw new BackendDetectionError("unavailable", "OpenCode discovery failed. Check the server and service registration."); } finally { context.dispose(); } diff --git a/src/server-manager.ts b/src/server-manager.ts index de2de65..e4bf93e 100644 --- a/src/server-manager.ts +++ b/src/server-manager.ts @@ -1,4 +1,5 @@ import { createOpencodeServer, OpencodeClient } from "@opencode-ai/sdk"; +import { execFile } from "node:child_process"; export interface ServerManagerOptions { baseUrl: string; @@ -116,20 +117,29 @@ export async function isServerRunning( throw new ServerAuthenticationError(response.status); } if (!response.ok) return { healthy: false }; - const res = await response.json() as any; - if (res && typeof res === 'object' && 'healthy' in res) { + const res = await response.json().catch((error: unknown) => { + if (error instanceof SyntaxError) throw new IncompatibleServerError(); + throw error; + }) as any; + if (res && typeof res === 'object' && typeof res.healthy === 'boolean') { return { healthy: res.healthy === true, version: typeof res.version === "string" ? res.version : undefined, }; } - return { healthy: false }; + throw new IncompatibleServerError(); } catch (error) { - if (error instanceof ServerAuthenticationError) throw error; + if (error instanceof ServerAuthenticationError || error instanceof IncompatibleServerError) throw error; return { healthy: false }; } } +export class IncompatibleServerError extends Error { + constructor(message = "The endpoint does not expose V1 health JSON. For experimental V2 support, connect to a separately started V2 server with OPENCODE_BACKEND=v2, OPENCODE_AUTO_SERVE=false, its URL and credentials.") { + super(message); this.name = "IncompatibleServerError"; + } +} + export class ServerAuthenticationError extends Error { constructor(readonly status: number) { super("OpenCode server authentication failed; no alternate server will be started."); @@ -150,6 +160,17 @@ export async function startServer( } const { hostname, port } = parseBaseUrl(baseUrl); + const version = await new Promise((resolve) => { + execFile("opencode", ["--version"], { + encoding: "utf8", timeout: Math.min(3000, Math.max(1, deadline - Date.now())), + windowsHide: true, shell: process.platform === "win32", + }, (error, stdout) => resolve(error ? "" : stdout)); + }); + if (Number(version.trim().match(/^v?(\d+)\./)?.[1]) >= 2) { + throw new IncompatibleServerError("OpenCode V2 or newer cannot be auto-started by the V1 SDK. Use an opencode-ai 1.x executable for auto-start, or connect to a separately started V2 server with OPENCODE_BACKEND=v2, OPENCODE_AUTO_SERVE=false, its URL and credentials (experimental support)."); + } + // shortcut: launchers without version output keep SDK startup, extend detection when a wrapper needs it. + if (Date.now() >= deadline) throw new Error("OpenCode startup exceeded its time budget."); console.error(`Starting OpenCode SDK server on ${hostname}:${port}`); // Capture into a local so concurrent `startServer` calls (against @@ -160,7 +181,7 @@ export async function startServer( const created = await createOpencodeServer({ hostname, port, - timeout: timeoutMs, + timeout: Math.max(1, deadline - Date.now()), }); managedServer = created; diff --git a/tests/backend-detection.test.ts b/tests/backend-detection.test.ts index e99a053..f00e1a1 100644 --- a/tests/backend-detection.test.ts +++ b/tests/backend-detection.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { BackendDetectionError, DEFAULT_BASE_URL, detectBackend, readBackendConfig } from "../src/backend-detection.js"; +import { IncompatibleServerError } from "../src/server-manager.js"; + const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); const info = { version: "2.0.6", pid: 42, urls: ["http://127.0.0.1:4100"], paths: { tmp: "/tmp" } }; const config = (values: NodeJS.ProcessEnv = {}) => readBackendConfig(values); @@ -92,3 +94,20 @@ describe("backend detection", () => { expect(Date.now() - before).toBeLessThan(500); expect(start).not.toHaveBeenCalled(); }); }); + + +it("preserves the actionable startup incompatibility diagnostic", async () => { + const error = new IncompatibleServerError(); + await expect(detectBackend(config({ OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { + fetch: vi.fn(async () => { throw new Error("offline"); }), registration: absent, + start: vi.fn(async () => { throw error; }), + })).rejects.toMatchObject({ code: "incompatible", message: error.message }); +}); + +it("detects existing V2 behind V1 HTML without starting a child", async () => { + const start = vi.fn(); + const fetch = vi.fn(async (url: any) => String(url).endsWith("/api/info") ? response(info) : new Response("OpenCode")); + const result = await detectBackend(config({ OPENCODE_BASE_URL: "http://host.test", OPENCODE_AUTO_SERVE: "true" }), { allowStartup: true }, { fetch, start }); + expect(result.identity.kind).toBe("v2"); + expect(start).not.toHaveBeenCalled(); +}); diff --git a/tests/server-manager.test.ts b/tests/server-manager.test.ts index 4a93452..aa5f181 100644 --- a/tests/server-manager.test.ts +++ b/tests/server-manager.test.ts @@ -16,9 +16,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock the SDK before importing the server-manager module so the import chain // picks up the mock. `vi.hoisted` keeps the mock reference accessible from // both the (hoisted) `vi.mock` factory and the assertions below. -const { createOpencodeServerMock } = vi.hoisted(() => ({ +const { createOpencodeServerMock, execFileMock } = vi.hoisted(() => ({ createOpencodeServerMock: vi.fn(), + execFileMock: vi.fn(), })); +vi.mock("node:child_process", () => ({ execFile: execFileMock })); vi.mock("@opencode-ai/sdk", () => ({ createOpencodeServer: createOpencodeServerMock, OpencodeClient: vi.fn(), @@ -32,6 +34,7 @@ import { registerShutdownHandlers, resetShutdownRegisteredForTests, ServerAuthenticationError, + IncompatibleServerError, } from "../src/server-manager.js"; // ─── Helpers ───────────────────────────────────────────────────────────── @@ -72,6 +75,7 @@ beforeEach(() => { globalThis.fetch = fetchMock as unknown as typeof fetch; consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); createOpencodeServerMock.mockReset(); + execFileMock.mockReset().mockImplementation((_file, _args, _options, callback) => callback(null, "1.18.31\n", "")); resetShutdownRegisteredForTests(); // Capture lifecycle registration without installing handlers on the test runner. vi.spyOn(process, "on").mockReturnValue(process); @@ -184,7 +188,7 @@ describe("startServer", () => { expect.objectContaining({ hostname: "127.0.0.1", port: 4096, - timeout: 5000, + timeout: expect.any(Number), }), ); expect(result.url).toBe("http://127.0.0.1:4096"); @@ -507,3 +511,43 @@ describe("Issue #18: explicit server ownership", () => { expect(createOpencodeServerMock).not.toHaveBeenCalled(); }); }); + + +describe("issue #31 startup compatibility", () => { + it.each(["2.0.28", "2.0.32", "v2.1.0-rc.1"])("rejects %s before SDK startup", async version => { + execFileMock.mockImplementation((_file, _args, _options, callback) => callback(null, version, "")); + await expect(startServer("http://127.0.0.1:4096")).rejects.toThrow("OPENCODE_BACKEND=v2"); + expect(createOpencodeServerMock).not.toHaveBeenCalled(); + }); + it("preserves SDK startup for launchers without version support", async () => { + execFileMock.mockImplementation((_file, _args, _options, callback) => callback(new Error("unsupported"), "", "")); + createOpencodeServerMock.mockResolvedValueOnce({ url: "http://127.0.0.1:4096", close: vi.fn() }); + mockFetchHealthy(); + await startServer("http://127.0.0.1:4096"); + expect(createOpencodeServerMock).toHaveBeenCalledOnce(); + }); + it("charges version probing against the total startup budget", async () => { + vi.spyOn(Date, "now").mockReturnValue(1000); + execFileMock.mockImplementation((_file, _args, options, callback) => { + expect(options.timeout).toBe(40); + vi.mocked(Date.now).mockReturnValue(1041); + callback(new Error("timeout"), "", ""); + }); + await expect(startServer("http://127.0.0.1:4096", 40)).rejects.toThrow("time budget"); + expect(createOpencodeServerMock).not.toHaveBeenCalled(); + }); + it.each([true, false])("does not replace an HTML endpoint with autoServe=%s", async autoServe => { + fetchMock.mockResolvedValueOnce(new Response("OpenCode")); + await expect(ensureServer({ baseUrl: "http://127.0.0.1:4096", autoServe })).rejects.toBeInstanceOf(IncompatibleServerError); + expect(execFileMock).not.toHaveBeenCalled(); + expect(createOpencodeServerMock).not.toHaveBeenCalled(); + }); + it("closes an owned child exactly once after malformed health JSON", async () => { + const close = vi.fn(); + createOpencodeServerMock.mockResolvedValueOnce({ url: "http://127.0.0.1:4096", close }); + fetchMock.mockResolvedValueOnce(new Response("OpenCode")); + await expect(startServer("http://127.0.0.1:4096")).rejects.toBeInstanceOf(IncompatibleServerError); + stopServer(); + expect(close).toHaveBeenCalledOnce(); + }); +});