Compare commits

..
164 changed files with 1963 additions and 2445 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.16",
"version": "2.0.15",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -216,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -252,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -279,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -296,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -320,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -340,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -408,7 +408,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -457,7 +457,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -494,7 +494,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -510,7 +510,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -529,7 +529,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -542,7 +542,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -556,7 +556,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -571,7 +571,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -610,7 +610,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -640,7 +640,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -655,7 +655,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -679,7 +679,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -700,7 +700,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -722,7 +722,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -757,7 +757,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -777,7 +777,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -811,7 +811,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -830,7 +830,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -876,7 +876,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -890,7 +890,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -925,7 +925,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -960,7 +960,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.16",
"version": "2.0.15",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+2 -2
View File
@@ -55,7 +55,7 @@ const request = LLM.request({
prompt: "Say hello.",
})
const response = yield * LLMClient.generate(request) // inside Effect.gen
const response = yield * LLMClient.generate(request)
```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
@@ -98,7 +98,7 @@ When a provider supports multiple physical transports, selection remains executi
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `Generation.AwaitOptions` (`{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `MediaProtocol.incomplete`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `MediaProtocol.decodeFrame` and raise stream-time failures with `MediaProtocol.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
+114 -171
View File
@@ -1,10 +1,11 @@
# @opencode/ai
Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
Schema-first language model and image-generation APIs built with Effect.
```ts
import { Effect } from "effect"
import { AIClient, LLM } from "@opencode/ai"
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
@@ -17,23 +18,23 @@ const request = LLM.request({
})
const program = Effect.gen(function* () {
const response = yield* LLM.generate(request)
const response = yield* LLMClient.generate(request)
console.log(response.text)
})
// Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
```
Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
The same configured facade names image models. `Image.request` resolves the provider's image route from the ref and
returns `Media.Asset`s with lazily decoded bytes:
```ts
import { NodeFileSystem } from "@effect/platform-node"
import { Image, Media } from "@opencode/ai"
import { Image, ImageClient, Media } from "@opencode/ai"
const image = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -45,28 +46,13 @@ const image = Effect.gen(function* () {
yield* Media.write(response.image, "./garden.png")
})
// `Media.file` / `Media.write` use the Effect `FileSystem` service; provide your platform's layer.
await Effect.runPromise(image.pipe(Effect.provide(AIClient.layer), Effect.provide(NodeFileSystem.layer)))
// `asset.bytes()` / `Media.write` also need the executor, so merge it into the environment instead of hiding it.
const imageLayer = ImageClient.layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
await Effect.runPromise(image.pipe(Effect.provide(imageLayer), Effect.provide(NodeFileSystem.layer)))
```
Advanced: each client also has its own `layer`, which requires `RequestExecutor.Service`. Compose client layers with
`Layer.provideMerge`, not `Layer.provide`: `asset.bytes()`, `Media.write`, and Gemini's `media` output parts need the
executor too, and hiding it fails type-checking with `RequestExecutorService` left in the requirements.
To share a policy such as logging across every client, wrap the executor once with `RequestExecutor.middleware`:
```ts
import { RequestExecutor } from "@opencode/ai/route"
const logged = RequestExecutor.middleware((request, next) =>
Effect.log(`${request.method} ${request.url}`).pipe(Effect.andThen(next(request))),
)
const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
```
Prefer promises? `@opencode/ai/promise` exposes the same LLM and media APIs over one managed runtime, plus asset
helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
Prefer promises? `@opencode/ai/promise` exposes the same LLM and image APIs over one managed runtime:
```ts
import { AI } from "@opencode/ai/promise"
@@ -74,7 +60,6 @@ import { AI } from "@opencode/ai/promise"
const ai = AI.make()
const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
// LLMEvent
}
@@ -337,9 +322,10 @@ and `moonshot/responses`; each exports `model(modelID, settings)`.
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
```ts
import { Effect } from "effect"
import { AIClient, LLM } from "@opencode/ai"
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { MiniMax } from "@opencode/ai/providers"
import { RequestExecutor } from "@opencode/ai/route"
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
const request = LLM.request({
@@ -349,7 +335,8 @@ const request = LLM.request({
generation: { maxTokens: 1536 },
})
const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
console.log(response.text)
```
@@ -418,14 +405,14 @@ Use `Image.generate` for one-off generation or editing:
import { Image, Media } from "@opencode/ai"
const generation = Image.generate({
model: meta.image("muse-image-1.0"),
model: meta("muse-image-1.0"),
prompt: "A flat black square on a white background.",
n: 1,
providerOptions: { reasoningStrength: "low" },
})
const edit = Image.generate({
model: meta.image("muse-image-1.0"),
model: meta("muse-image-1.0"),
prompt: "Make the square purple.",
images: [Media.bytes(imageBytes, "image/webp")],
format: "png",
@@ -472,25 +459,6 @@ const program = Effect.gen(function* () {
})
```
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
call rather than being dropped, so check this table before swapping only the `model`:
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
`asset.base64()`, and `asset.dataUrl()` decode or download lazily and cache; `asset.materialize()` pulls a `url`
@@ -500,8 +468,9 @@ asset into owned bytes before the provider URL expires. Construct assets with `M
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
```ts
const composed = Effect.gen(function* () {
const response = yield* Image.generate({
const response =
yield *
Image.generate({
model,
prompt: "Combine these product photos into one studio scene",
images: [
@@ -512,25 +481,23 @@ const composed = Effect.gen(function* () {
providerOptions,
http,
})
return response.images
})
```
`Media.ref(provider, id)` represents provider file handles such as OpenAI file IDs or Gemini Files URIs; routes
only forward refs that belong to their own provider (OpenAI, xAI, and Gemini images accept them). No shipped route
returns a ref yet, and `asset.bytes()` / `materialize()` on a ref fail by design. Raw strings are not accepted as
image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
non-empty array selects the provider's edit behavior (see the table above for routes that limit the count). OpenAI
only forward refs that belong to their own provider. Raw strings are not accepted as image inputs, avoiding
ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
non-empty array selects the provider's edit behavior without enforcing provider image-count limits locally. OpenAI
uses multipart for byte/data-URL edits and its JSON reference body for URL or file-ID edits. The common `mask`
field selects inpainting; routes that cannot honor it fail with `UnsupportedOperation`:
```ts
const inpainted = Image.generate({
model: openai.image("gpt-image-2"),
prompt,
images: [Media.bytes(sourceBytes, "image/png")],
mask: Media.bytes(maskBytes, "image/png"),
})
yield *
Image.generate({
model: openai.image("gpt-image-2"),
prompt,
images: [Media.bytes(sourceBytes, "image/png")],
mask: Media.bytes(maskBytes, "image/png"),
})
```
On multipart requests, `http.body` can override option fields but not structural `model`, `prompt`, `image[]`,
@@ -541,31 +508,31 @@ not accept image inputs. These cases fail with a typed `AIError` before network
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
```ts
const medium = Image.generate({
model: openai.image("gpt-image-2"),
prompt,
providerOptions: { quality: "medium" },
http,
})
yield *
Image.generate({
model: openai.image("gpt-image-2"),
prompt,
providerOptions: { quality: "medium" },
http,
})
```
xAI image models use the same request API with xAI-native controls:
```ts
import { XAI } from "@opencode/ai/providers"
const xai = Image.generate({
model: XAI.configure({ apiKey }).image("any-model-id"),
prompt,
n: 2,
aspectRatio: "16:9",
providerOptions: {
resolution: "1k",
responseFormat: "b64_json",
future_option: true,
},
http,
})
yield *
Image.generate({
model: XAI.configure({ apiKey })("any-model-id"),
prompt,
n: 2,
aspectRatio: "16:9",
providerOptions: {
resolution: "1k",
responseFormat: "b64_json",
future_option: true,
},
http,
})
```
Google's current Gemini image models use the same direct API:
@@ -575,7 +542,7 @@ import { Google } from "@opencode/ai/providers"
const googleProgram = Effect.gen(function* () {
const response = yield* Image.generate({
model: Google.configure({ apiKey }).image("any-model-id"),
model: Google.configure({ apiKey })("any-model-id"),
prompt: "A robot tending a rooftop garden",
aspectRatio: "16:9",
seed: 42,
@@ -600,18 +567,17 @@ their mapped aliases, and `http.body` is the final deep overlay. The selected mo
Z.ai image models infer open Z.ai-native options from the selected model:
```ts
import { ZAI } from "@opencode/ai/providers"
const zai = Image.generate({
model: ZAI.configure({ apiKey }).image("any-model-id"),
prompt,
providerOptions: {
quality: "hd",
userID: "user-123",
future_option: true,
},
http,
})
yield *
Image.generate({
model: ZAI.configure({ apiKey })("any-model-id"),
prompt,
providerOptions: {
quality: "hd",
userID: "user-123",
future_option: true,
},
http,
})
```
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
@@ -625,14 +591,12 @@ and emits `image-partial` events before each final `image`; `Image.generate` kee
`dall-e-*` models do not stream and fail typed:
```ts
import { Stream } from "effect"
import { ImageEvent } from "@opencode/ai"
const previews = Image.stream({
model: openai.image("gpt-image-2"),
prompt: "A lighthouse at dusk",
providerOptions: { partialImages: 2 },
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
yield *
Image.stream({
model: openai.image("gpt-image-2"),
prompt: "A lighthouse at dusk",
providerOptions: { partialImages: 2 },
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
```
The provider may send fewer previews than requested when the final image is ready first.
@@ -648,20 +612,13 @@ import { BlackForestLabs, Stability } from "@opencode/ai/providers"
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
const submit = Effect.gen(function* () {
const generation = yield* Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
persist({ provider: "black-forest-labs", modelID: "flux-2-pro", token: generation.token })
})
const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
persist(generation.token)
const finish = Effect.gen(function* () {
const saved = load()
const resumed = yield* Image.resume(bfl.image(saved.modelID), saved.token)
return yield* resumed.await({ poll: { interval: "2 seconds" } })
})
const resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
```
The token carries no route identity, so persist the provider and model ID alongside it: `resume` needs the model.
- **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
- **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
`providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
@@ -671,13 +628,12 @@ The token carries no route identity, so persist the provider and model ID alongs
```ts
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
const upscaled = Effect.gen(function* () {
const small = yield* Media.file("./small.png")
return yield* Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [small] },
const upscaled =
yield *
Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
{ poll: { interval: "5 seconds" } },
)
})
```
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
@@ -710,8 +666,8 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`
under `providerOptions`, inferred from the selected model.
```ts
import { Video } from "@opencode/ai"
import { Google, Runway } from "@opencode/ai/providers"
import { Video, VideoClient } from "@opencode/ai"
import { Google } from "@opencode/ai/providers"
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
@@ -740,7 +696,6 @@ const controlled = Effect.gen(function* () {
generation.id // provider operation / task / request id
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
// The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
const saved = JSON.stringify(generation.token)
const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
@@ -751,8 +706,8 @@ const controlled = Effect.gen(function* () {
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
```
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
auth. `Generation.await` and `Generation.events` fail with a
`VideoClient.layer` needs `RequestExecutor.Service`, and status polls, result fetches, cancels, and asset downloads
all run through the same executor with the route's auth. `Generation.await` and `Generation.events` fail with a
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
@@ -771,18 +726,15 @@ Provider notes:
- **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
`usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
created; `refresh()` resolves to a new handle.
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `refresh`,
`cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
API.
```ts
import { ai } from "@opencode/ai/promise"
const generation = await ai.video.start({ model, prompt })
for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
const video = await generation.result({ signal })
await ai.write(video.video, "./kite.mp4")
const video = await generation.await({ poll: { interval: 10_000 }, signal })
```
## Speech generation
@@ -834,6 +786,7 @@ ElevenLabs and Cartesia. `{ id }` selects an OpenAI custom voice (`{ id: "voice_
plain string elsewhere. There is no cross-provider voice catalog. `format` is the container-level word (`mp3`, `wav`,
`pcm`, `opus`, `aac`, `flac`); sample rates and bitrates live under `providerOptions`, and a value the route cannot
produce fails as `UnsupportedOperation`. Streams buffer every chunk so `finish` can carry the whole clip.
`SpeechClient.layer` needs `RequestExecutor.Service`.
Provider notes:
@@ -861,7 +814,7 @@ The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterab
import { ai } from "@opencode/ai/promise"
const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" })
await ai.write(response.audio, "hello.mp3")
await Bun.write("hello.mp3", await ai.run(response.audio.bytes()))
for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
if (event.type === "audio-delta") player.write(event.chunk)
@@ -878,7 +831,6 @@ facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" |
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
```ts
import { Console, Effect, Stream } from "effect"
import { Media, Transcription, TranscriptionEvent } from "@opencode/ai"
import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers"
@@ -905,7 +857,7 @@ const program = Effect.gen(function* () {
Stream.runDrain,
)
// Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, and await.
// Queued: persist the token, resume from another process, and await.
const model = AssemblyAI.configure({ apiKey }).transcription("universal-3-5-pro")
const generation = yield* Transcription.start({ model, audio })
const resumed = yield* Transcription.resume(model, JSON.parse(JSON.stringify(generation.token)))
@@ -914,7 +866,7 @@ const program = Effect.gen(function* () {
```
Inline routes emit only `finish` from `stream` (no faked deltas); queued routes emit `generation-queued` /
`generation-progress` before it.
`generation-progress` before it. `TranscriptionClient.layer` needs `RequestExecutor.Service`.
Provider notes:
@@ -926,7 +878,6 @@ Provider notes:
The promise client mirrors the Effect API:
```ts
const audio = await ai.file("./call.mp3")
const text = (await ai.transcription.generate({ model, audio })).text
for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta)
const generation = await ai.transcription.start({ model: assemblyai, audio })
@@ -946,8 +897,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs.
## Testing
@@ -1010,13 +960,11 @@ This is different from prompt caching, server-side history storage, or truncatio
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
```ts
const compacted = Effect.gen(function* () {
const result = yield* LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
})
return yield* LLMClient.generate(next)
const result = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
})
const response = yield * LLMClient.generate(next)
```
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
@@ -1032,16 +980,16 @@ The input must still fit the model's context window. Explicit compaction is not
OpenAI Responses also exposes a separate, explicitly selected mechanism:
```ts
const checkpoint = Effect.gen(function* () {
const result = yield* LLMClient.compact(request, {
const result =
yield *
LLMClient.compact(request, {
mechanism: "trigger",
webSocket, // Optional: without it, the request uses HTTP/SSE.
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
@@ -1055,11 +1003,9 @@ The supplied WebSocket executor can reuse a compatible append baseline for the c
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
```ts
const narrowed = Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield* LLMClient.compact(request, { mechanism: "trigger" })
}
})
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
```
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
@@ -1068,10 +1014,9 @@ This capability describes protocol implementation, **not universal availability
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
Enable OpenAI compaction with typed provider options:
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
```ts
import { Effect } from "effect"
import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
@@ -1082,11 +1027,9 @@ const request = LLM.request({
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
},
})
const continued = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
return LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
const response = yield * LLMClient.generate(request)
const next = LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
```
@@ -1314,7 +1257,7 @@ Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. T
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `AIClient.layer` (or `AIClient.layerWith(executor)`) for every modality, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
+65 -82
View File
@@ -1,6 +1,6 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
Status: phases 1–3 implemented (Speech and Transcription); phases 4–5 proposal.
## Goal
@@ -54,7 +54,7 @@ Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
```
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
### `Media` — the asset type
@@ -86,18 +86,10 @@ class Media.Asset {
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
Media.url(url, options?) Media.ref(provider, id)
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
Media.file(path) // Bun/Node: reads + sniffs; Effect FileSystem variant for layers
Media.write(asset, path) // convenience, uses FileSystem
```
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
`ai.materialize` run the asset methods in its runtime.
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
### Modality namespaces
@@ -112,31 +104,27 @@ import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
#### Image
```ts
Effect.gen(function* () {
const request = Image.request({
model: openai.image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
images: [yield* Media.file("./ref.png")], // references / edit sources
mask: yield* Media.file("./mask.png"),
n: 2,
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
format: "webp",
providerOptions: { quality: "high", background: "transparent" }, // typed per model
})
const response = yield* Image.generate(request) // ImageResponse
response.image // Media.Asset (first)
response.images // Media.Asset[]
response.usage // Usage union (see below)
response.notices // moderation / partial-result notices
Image.stream(request) // Stream<ImageEvent>
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
const request = Image.request({
model: openai.image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
images: [Media.file("./ref.png")], // references / edit sources
mask: Media.file("./mask.png"),
n: 2,
size: "1536x1024", // or aspectRatio: "3:2"
seed: 7,
format: "webp",
providerOptions: { quality: "high", background: "transparent" }, // typed per model
})
```
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
portability matrix.
const response = yield* Image.generate(request) // ImageResponse
response.image // Media.Asset (first)
response.images // Media.Asset[]
response.usage // Usage union (see below)
response.notices // moderation / partial-result notices
yield* Image.stream(request) // Stream<ImageEvent>
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
```
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
@@ -147,43 +135,40 @@ Editing is not a separate function; `images`/`mask` on the request select the ed
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
```ts
Effect.gen(function* () {
const request = Video.request({
model: google.video("veo-3.1-generate-preview"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
references: [yield* Media.file("./style.png")],
video: Media.bytes(previous, "video/mp4"), // edit / extend source
durationSeconds: 8,
aspectRatio: "16:9",
resolution: "1080p",
audio: true,
n: 1,
seed: 7,
negativePrompt: "text, watermark", // common, not provider-native
providerOptions: { personGeneration: "allow_adult" },
})
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
// Explicit generation control.
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
const resumed = yield* Video.resume(model, JSON.parse(saved))
// Progress as a stream.
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
const request = Video.request({
model: google.video("veo-3.1-generate-preview"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
frames: { first: Media.file("./start.png"), last: Media.file("./end.png") },
references: [Media.file("./style.png")],
video: Media.bytes(previous, "video/mp4"), // edit / extend source
durationSeconds: 8,
aspectRatio: "16:9",
resolution: "1080p",
audio: true,
n: 1,
seed: 7,
negativePrompt: "text, watermark", // common, not provider-native
providerOptions: { personGeneration: "allow_adult" },
})
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
// Explicit generation control.
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once.
const resumed = yield* Video.resume(model, JSON.parse(saved))
// Progress as a stream.
yield* Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
```
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
@@ -345,12 +330,12 @@ class Generation<Response> {
readonly expiresAt?: number
refresh(): Effect<Generation<Response>, AIError>
result(): Effect<Response, AIError>
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
await(options?: AwaitOptions): Effect<Response, AIError>
cancel(): Effect<void, AIError>
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
}
GenerationAwaitOptions = { poll?: Poll }
AwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
```
@@ -380,17 +365,15 @@ const ai = AI.make() // ManagedRuntime over Reque
// AI.make({ layer }) to inject a custom executor / recorder / middleware
const image = await ai.image.generate({ model, prompt })
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
const reference = await ai.file("./ref.png")
await image.image.bytes()
for await (const event of ai.speech.stream({ model, text, voice })) { … }
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
const generation = await ai.video.start({ model, prompt })
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const resumed = ai.video.resume(model, JSON.parse(saved))
const text = await ai.llm.generate({ model, prompt })
const text = await ai.llm.generate({ model, prompt }) // closes today's gap: LLM has no promise API either
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
@@ -418,9 +401,9 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
| `Runway` | | | ✓ | | | |
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
New facades follow the existing one-file-per-provider rule. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
`ImageModel<Options>` already gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, `TranscriptionModel` follow the same generic. A shared `MediaModel` union is what `Generation` and the promise client key on.
### Routes and protocols
+20 -7
View File
@@ -1,7 +1,18 @@
import { Config, Effect, Formatter, Schema, Stream } from "effect"
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
import { Route, Auth, Endpoint, Framing, Protocol } from "@opencode/ai/route"
import {
Image,
ImageClient,
LLM,
LLMClient,
LLMRequest,
Media,
Message,
ProviderID,
Tool,
ToolRuntime,
} from "@opencode/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"
/**
@@ -232,10 +243,12 @@ const generateImage = Effect.gen(function* () {
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
})
// Provide every modality client and the HTTP request executor once with
// `AIClient.layer` (`AIClient.layerWith(executor)` swaps the executor). Keep one
// path enabled at a time so the tutorial can demonstrate generate, stream, or
// Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const imageClientLayer = ImageClient.layer.pipe(Layer.provide(requestExecutorLayer))
const program = Effect.gen(function* () {
// yield* generateOnce
@@ -244,6 +257,6 @@ const program = Effect.gen(function* () {
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
// yield* generateImage
yield* streamWithTools
}).pipe(Effect.provide(AIClient.layer))
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer, imageClientLayer)))
Effect.runPromise(program)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.16",
"version": "2.0.15",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
-24
View File
@@ -1,24 +0,0 @@
import { Layer } from "effect"
import { ImageClient } from "./image-client.js"
import { LLMClient } from "./route/client.js"
import { RequestExecutor } from "./route/executor.js"
import { SpeechClient } from "./speech-client.js"
import { TranscriptionClient } from "./transcription-client.js"
import { VideoClient } from "./video-client.js"
/** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
export const layerWith = <E, R>(executor: Layer.Layer<RequestExecutor.Service, E, R>) =>
Layer.mergeAll(
LLMClient.layer,
ImageClient.layer,
VideoClient.layer,
SpeechClient.layer,
TranscriptionClient.layer,
).pipe(Layer.provideMerge(executor))
/** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
export const layer = layerWith(RequestExecutor.fetchLayer)
export type Services = Layer.Success<typeof layer>
export * as AIClient from "./ai-client.js"
@@ -86,7 +86,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
})
}),
)
export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
export const EvaluationClient = {
Service,
+1 -3
View File
@@ -30,9 +30,7 @@ export interface Interface {
) => Effect.Effect<Generation<ImageResponse>, AIError>
}
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
export const Service = ImageClientService
export type Service = ImageClientService
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
+1 -2
View File
@@ -1,4 +1,3 @@
export { AIClient } from "./ai-client.js"
export { LLMClient } from "./route/client.js"
export { ImageClient } from "./image-client.js"
export { Auth } from "./route/auth.js"
@@ -9,7 +8,7 @@ export type {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
Interface as LLMClientShape,
LLMClientService,
Service as LLMClientService,
} from "./route/client.js"
export * from "./schema/index.js"
export {
+2 -2
View File
@@ -6,7 +6,7 @@ import { ProviderID } from "./schema/ids.js"
import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js"
import { ProviderMetadata } from "./schema/options.js"
import { Service } from "./route/executor-service.js"
import { detectMediaType, fileMediaType } from "./utils/media-type.js"
import { detectMediaType, extensionMediaType } from "./utils/media-type.js"
export { detectMediaType } from "./utils/media-type.js"
@@ -309,7 +309,7 @@ export const file = (path: string, options?: AssetOptions): Effect.Effect<Asset,
const data = yield* fs
.readFile(path)
.pipe(Effect.mapError((cause) => invalid(`Failed to read media file ${path}`, cause)))
return bytes(data, fileMediaType(data, path), options)
return bytes(data, detectMediaType(data) ?? extensionMediaType(path), options)
})
/** Materialize an asset and write its bytes through `FileSystem`. */
+30 -64
View File
@@ -1,14 +1,14 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import { AIClient } from "./ai-client.js"
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
import { ImageClient } from "./image-client.js"
import { LLM } from "./index.js"
import { Media } from "./media.js"
import { tryRequest } from "./media-model.js"
import { LLMClient } from "./route/client.js"
import { RequestExecutor } from "./route/executor.js"
import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js"
import { LanguageModel, LLMRequest } from "./schema/index.js"
import type { RequestInput } from "./llm.js"
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
import { SpeechClient } from "./speech-client.js"
import {
Transcription,
TranscriptionModel,
@@ -16,16 +16,17 @@ import {
type TranscriptionOptions,
type TranscriptionRequestInput,
} from "./transcription.js"
import { fileMediaType } from "./utils/media-type.js"
import { TranscriptionClient } from "./transcription-client.js"
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
import { VideoClient } from "./video-client.js"
/**
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
* and transcription clients over a request executor; every method runs the corresponding Effect API and rethrows
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
* `AIError` unchanged.
*/
export interface Options {
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
readonly layer?: Layer.Layer<RequestExecutor.Service>
}
@@ -33,20 +34,19 @@ export interface RunOptions {
readonly signal?: AbortSignal
}
export type Services = AIClient.Services
export type Services =
| Layer.Success<typeof LLMClient.layer>
| Layer.Success<typeof ImageClient.layer>
| Layer.Success<typeof VideoClient.layer>
| Layer.Success<typeof SpeechClient.layer>
| Layer.Success<typeof TranscriptionClient.layer>
| RequestExecutor.Service
/**
* Promise view of a `Generation`. Its fields are a snapshot taken when the handle was created; `refresh()` resolves to a
* new handle rather than updating this one.
*/
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
export type GenerationHandle<Response> = Snapshot & {
/** Serializable JSON; pass it back to `resume` from another process. */
readonly token: unknown
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
/** Status observations until the first terminal one, polling like `await`; abort ends iteration without throwing. */
readonly events: (options?: AwaitOptions & RunOptions) => AsyncIterable<Event>
/** The result without polling; fails when the generation has not completed. */
readonly result: (options?: RunOptions) => Promise<Response>
readonly refresh: (options?: RunOptions) => Promise<GenerationHandle<Response>>
readonly cancel: (options?: RunOptions) => Promise<void>
}
@@ -65,9 +65,17 @@ const abortEffect = (signal: AbortSignal | undefined) =>
})
export const make = (options: Options = {}) => {
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LLMClient.layer,
ImageClient.layer,
VideoClient.layer,
SpeechClient.layer,
TranscriptionClient.layer,
).pipe(Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer)),
)
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
/** Run any package Effect (for example `asset.bytes()`) inside this runtime. */
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
runtime.runPromise(effect, { signal: options?.signal })
@@ -87,15 +95,12 @@ export const make = (options: Options = {}) => {
...generation.snapshot,
token: generation.token,
await: (options) => run(generation.await({ poll: options?.poll }), options),
events: (options) => iterate(generation.events({ poll: options?.poll }), options),
result: (options) => run(generation.result(), options),
refresh: (options) => run(generation.refresh(), options).then(handle),
cancel: (options) => run(generation.cancel(), options),
})
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
const llmRequest = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
const llmRequest = (input: RequestInput | LLMRequest) => (input instanceof LLMRequest ? input : LLM.request(input))
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
input instanceof ImageRequest ? input : Image.request(input)
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
@@ -107,48 +112,12 @@ export const make = (options: Options = {}) => {
return {
run,
/** Decoded asset bytes, downloading `url` sources through the executor. */
bytes: (asset: Media.Asset, options?: RunOptions) => run(asset.bytes(), options),
base64: (asset: Media.Asset, options?: RunOptions) => run(asset.base64(), options),
/** Pull a `url` asset into owned bytes before the provider URL expires. */
materialize: (asset: Media.Asset, options?: RunOptions) => run(asset.materialize(), options),
/** Read a file into an asset like `Media.file`: sniffed media type, then the extension's. */
file: async (path: string, options?: Media.AssetOptions & RunOptions) => {
const { readFile } = await import("node:fs/promises")
return run(
Effect.tryPromise({
try: (signal) => readFile(path, { signal }),
catch: (cause) => fileError(`Failed to read media file ${path}`, cause),
}).pipe(
Effect.map((buffer) => {
const data = new Uint8Array(buffer)
return Media.bytes(data, fileMediaType(data, path), options)
}),
),
options,
)
},
/** Write an asset's bytes like `Media.write`, downloading `url` sources through the executor. */
write: async (asset: Media.Asset, path: string, options?: RunOptions) => {
const { writeFile } = await import("node:fs/promises")
return run(
asset.bytes().pipe(
Effect.flatMap((data) =>
Effect.tryPromise({
try: (signal) => writeFile(path, data, { signal }),
catch: (cause) => fileError(`Failed to write media file ${path}`, cause),
}),
),
),
options,
)
},
llm: {
request: LLM.request,
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
run(Effect.flatMap(llmRequest(input), LLM.generate), options),
run(LLM.generate(llmRequest(input)), options),
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options),
iterate(LLM.stream(llmRequest(input)), options),
},
image: {
request: Image.request,
@@ -217,9 +186,6 @@ export const make = (options: Options = {}) => {
export type Client = ReturnType<typeof make>
const fileError = (message: string, cause: unknown) =>
new AIError({ reason: new InvalidRequestError({ message, cause }) })
/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */
export const ai = make()
+1 -5
View File
@@ -18,11 +18,7 @@ const PCM_SAMPLE_RATE = 24000
// 1. Public model input
// ---------------------------------------------------------------------------
/** `voice`, `instructions`, `speed`, and `format` are common request fields; other native body fields pass through. */
export type OpenAISpeechOptions = {
/** Defaults to `"sse"` in `stream` mode on models that support it; the merged value selects the response framing. */
readonly stream_format?: "sse" | "audio"
} & Record<string, unknown>
export type OpenAISpeechOptions = Record<string, unknown>
export type Request = SpeechRequestFor<OpenAISpeechOptions>
+1 -2
View File
@@ -211,8 +211,7 @@ export const mediaReference = (
if (provider !== undefined && asset.source.type === "ref" && asset.source.provider === provider)
return Effect.succeed({ type: "ref", value: asset.source.id })
const accepted = provider === undefined ? "" : `, and ${provider} references`
const got = asset.source.type === "ref" ? `; got ${asset.source.provider}:${asset.source.id}` : ""
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}${got}`))
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
}
/**
+14 -19
View File
@@ -1,15 +1,12 @@
import { Config, Effect, Option, Redacted } from "effect"
import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationError, AIError, type HttpOptions } from "../schema/index.js"
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
constructor(
readonly source: string,
message = `Missing auth credential: ${source}`,
) {
super(message)
constructor(readonly source: string) {
super(`Missing auth credential: ${source}`)
}
}
@@ -92,14 +89,7 @@ export const optional = (secret: Secret | undefined, source = "optional value")
? credential(Effect.fail(new MissingCredentialError(source)))
: credentialFromSecret(secret, source)
export const config = (name: string) =>
credential(
Effect.gen(function* () {
const secret = yield* Config.option(Config.redacted(name))
if (Option.isSome(secret) && Redacted.value(secret.value) !== "") return secret.value
return yield* Effect.fail(new MissingCredentialError(name, `${name} is not set`))
}),
)
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
@@ -155,10 +145,15 @@ export function scheme(name: string, source?: Secret | Credential) {
}
const toAIError = (error: AuthError): AIError => {
if (error instanceof AIError) return error
const message =
error instanceof MissingCredentialError ? error.message : `Failed to resolve auth config: ${error.message}`
return new AIError({ reason: new AuthenticationError({ message, cause: error }) })
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new AIError({
reason:
error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
})
}
return error
}
export const toEffect =
+1 -3
View File
@@ -261,9 +261,7 @@ const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefine
})
}
export class LLMClientService extends Context.Service<LLMClientService, Interface>()("@opencode/LLMClient") {}
export const Service = LLMClientService
export type Service = LLMClientService
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => {
const messages = normalizeToolHistory(request.messages)
+1 -5
View File
@@ -19,8 +19,4 @@ export type HttpMiddleware = (
handler: HttpHandler,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export class RequestExecutorService extends Context.Service<RequestExecutorService, Interface>()(
"@opencode/AI/RequestExecutor",
) {}
export const Service = RequestExecutorService
export type Service = RequestExecutorService
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
-16
View File
@@ -255,20 +255,4 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
/** Run `fn` on every request: it sees the raw response before status classification, inside middleware already on `executor`, and outside per-call middleware. */
export const middleware = (fn: HttpMiddleware, executor: Layer.Layer<Service> = fetchLayer): Layer.Layer<Service> =>
Layer.effect(
Service,
Effect.gen(function* () {
const inner = yield* Service
return Service.of({
execute: (request, next) =>
inner.execute(
request,
next === undefined ? fn : (input, handler) => fn(input, (forwarded) => next(forwarded, handler)),
),
})
}),
).pipe(Layer.provide(executor))
export * as RequestExecutor from "./executor.js"
+1 -1
View File
@@ -7,7 +7,7 @@ export type {
RouteDefaultsInput,
AnyRoute,
Interface as LLMClientShape,
LLMClientService,
Service as LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
+1 -3
View File
@@ -12,9 +12,7 @@ export interface Interface {
) => Stream.Stream<SpeechEvent, AIError>
}
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
export const Service = SpeechClientService
export type Service = SpeechClientService
export class Service extends Context.Service<Service, Interface>()("@opencode/SpeechClient") {}
export const generate = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
+1 -5
View File
@@ -30,11 +30,7 @@ export interface Interface {
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
}
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
"@opencode/TranscriptionClient",
) {}
export const Service = TranscriptionClientService
export type Service = TranscriptionClientService
export class Service extends Context.Service<Service, Interface>()("@opencode/TranscriptionClient") {}
export const generate = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
+1 -4
View File
@@ -48,12 +48,9 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
csv: "text/csv",
}
const extensionMediaType = (path: string): string | undefined =>
export const extensionMediaType = (path: string): string | undefined =>
EXTENSIONS[path.slice(path.lastIndexOf(".") + 1).toLowerCase()]
/** Media type of a file's contents: sniffed magic bytes, then the path's extension. */
export const fileMediaType = (bytes: Uint8Array, path: string) => detectMediaType(bytes) ?? extensionMediaType(path)
const EXTENSION_ALIASES: Readonly<Record<string, string>> = {
"audio/mp3": "mp3",
"audio/m4a": "m4a",
+1 -3
View File
@@ -29,9 +29,7 @@ export interface Interface {
) => Stream.Stream<VideoEvent, AIError>
}
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
export const Service = VideoClientService
export type Service = VideoClientService
export class Service extends Context.Service<Service, Interface>()("@opencode/VideoClient") {}
export const start = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
-9
View File
@@ -92,15 +92,6 @@ describe("Auth", () => {
}),
)
it.effect("reports a missing config credential as Authentication naming the variable", () =>
Effect.gen(function* () {
const error = yield* Auth.toEffect(Auth.config("OPENAI_API_KEY").bearer())(input).pipe(withEnv({}), Effect.flip)
expect(error.reason._tag).toBe("Authentication")
expect(error.message).toContain("OPENAI_API_KEY is not set")
}),
)
it.effect("can intentionally leave auth untouched", () =>
Effect.gen(function* () {
const headers = yield* Auth.none.apply(input)
+2 -22
View File
@@ -1,11 +1,11 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, handlerLayer, systemError } from "./lib/http.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js"
@@ -195,26 +195,6 @@ describe("RequestExecutor", () => {
),
)
it.effect("runs shared middleware outside per-call middleware", () => {
const calls: Array<string> = []
const record = (name: string) => Effect.sync(() => calls.push(name))
const base = RequestExecutor.layer.pipe(
Layer.provide(handlerLayer((input) => record("handler").pipe(Effect.as(input.respond("ok"))))),
)
return Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
yield* executor.execute(request, (input, next) => record("per-call").pipe(Effect.andThen(next(input))))
expect(calls).toEqual(["outer", "per-call", "handler"])
calls.length = 0
yield* executor.execute(request)
expect(calls).toEqual(["outer", "handler"])
}).pipe(
Effect.provide(
RequestExecutor.middleware((input, next) => record("outer").pipe(Effect.andThen(next(input))), base),
),
)
})
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+1 -27
View File
@@ -1,11 +1,8 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import {
AIClient,
AIError,
Generation,
Image,
ImageClient,
LanguageModel,
LLM,
LLMClient,
@@ -14,11 +11,10 @@ import {
Speech,
SpeechClient,
SpeechEvent,
TranscriptionClient,
Video,
VideoClient,
} from "@opencode/ai"
import { Route, Protocol, RequestExecutor, WebSocketTransport } from "@opencode/ai/route"
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
import {
AssemblyAI,
@@ -80,28 +76,6 @@ describe("public exports", () => {
expect(EvaluationClient.fetchLayer).toBeDefined()
})
test("AIClient.layerWith shares one executor across every client", async () => {
let built = 0
const counting = Layer.effect(
RequestExecutor.Service,
Effect.sync(() => {
built++
return RequestExecutor.Service.of({ execute: () => Effect.die("unexpected request") })
}),
)
await Effect.runPromise(
Effect.gen(function* () {
yield* LLMClient.Service
yield* ImageClient.Service
yield* VideoClient.Service
yield* SpeechClient.Service
yield* TranscriptionClient.Service
yield* RequestExecutor.Service
}).pipe(Effect.provide(AIClient.layerWith(counting))),
)
expect(built).toBe(1)
})
test("route barrel exposes route-authoring APIs", () => {
expect(Route.make).toBeFunction()
expect(Protocol.make).toBeFunction()
-39
View File
@@ -1,7 +1,4 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/index.js"
@@ -185,36 +182,6 @@ describe("AI promise client", () => {
await ai.dispose()
})
test("observes a started generation's events and fetches its result", async () => {
const ai = AI.make({ layer: executor([]) })
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
const generation = await ai.video.start({ model, prompt: "A kite" })
const events: Array<string> = []
for await (const event of generation.events({ poll: { interval: 10 } })) events.push(event.type)
expect(events).toEqual(["generation-progress", "generation-finished"])
expect(generation.status).toBe("queued")
expect((await generation.result()).video.source).toMatchObject({ url: "https://runway.test/out.mp4" })
await ai.dispose()
})
test("reads, writes, and decodes assets without leaving promises", async () => {
const ai = AI.make({ layer: executor([]) })
const dir = await mkdtemp(join(tmpdir(), "ai-promise-"))
try {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
await Bun.write(join(dir, "source.bin"), png)
const asset = await ai.file(join(dir, "source.bin"))
expect(asset.mediaType).toBe("image/png")
await ai.write(asset, join(dir, "copy.png"))
expect(await ai.bytes(await ai.file(join(dir, "copy.png")))).toEqual(png)
} finally {
await rm(dir, { recursive: true })
await ai.dispose()
}
})
test("generates, streams, and starts transcriptions over the same runtime", async () => {
const ai = AI.make({ layer: executor([]) })
const audio = Media.url("https://audio.test/hello.mp3")
@@ -266,12 +233,6 @@ describe("AI promise client", () => {
expect(failure).toBeInstanceOf(AIError)
expect(failure instanceof AIError && failure.reason.http?.status).toBe(404)
const invalid = await ai.llm
// @ts-expect-error Invalid input must reject with AIError, not throw synchronously.
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
.catch((error: unknown) => error)
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
const controller = new AbortController()
controller.abort()
const aborted = await ai.llm
-1
View File
@@ -533,7 +533,6 @@ describe("Video / fal", () => {
"InvalidRequest",
])
expect(errors[1].message).toContain("end_image_url")
expect(errors[4].message).toContain("; got fal:handle")
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
)
})
+4 -5
View File
@@ -94,10 +94,9 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
With no configured servers, or when the only server rejects the saved credentials, the app shows a full-screen
connection form. Enter a server address and password, paste a link from `opencode pair`, or choose
**Scan QR code** to read its QR code. Pairing links are single-use; the app exchanges them for a session token
and immediately attempts to connect. Failed connections leave the details available
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
@@ -107,7 +106,7 @@ When the service is exposed through an HTTPS reverse proxy, advertise its extern
opencode pair --url https://opencode.example.com
```
This replaces the addresses in the printed links and QR code.
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
@@ -214,23 +214,6 @@ for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
})
}
story.describe("touch attachments", () => {
story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } })
story("keeps attachment remove buttons visible without hover", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--mixed-attachments")
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
const remove = component.getByRole("button", { name: "Remove attachment", exact: true })
await expect(remove).not.toHaveCount(0)
for (const button of await remove.all()) await expect(button).toHaveCSS("opacity", "1")
const count = await remove.count()
await remove.first().tap()
await expect(remove).toHaveCount(count - 1)
})
})
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
await page.addInitScript(() => {
const replace = Element.prototype.replaceChildren
@@ -0,0 +1,54 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -14,12 +14,7 @@ type InboxRow = {
sessionID: string
time: { created: number }
type: "user"
payload: {
text: string
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
payload: { text: string; metadata?: Record<string, unknown> }
delivery: "steer" | "queue"
}
@@ -34,7 +29,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
@@ -239,113 +234,6 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.log[0]).toBe("prompt:queue")
})
test("Move Back cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const row = view.rows.filter({ hasText: "second queued prompt" })
const actions = row.locator('[data-slot="session-queue-actions"] button')
await expect(actions).toHaveCount(3)
expect(
await actions.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
),
).toEqual(["Steer", "Move Back", "Remove"])
const moveBack = row.getByRole("button", { name: "Move Back" })
await expect(moveBack).toHaveText("")
await expect(moveBack.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-undo-down")
await moveBack.hover()
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
await moveBack.click()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"third queued prompt",
])
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
expect(mock.prompts).toEqual([])
})
test("Move Back preserves an existing draft and restores inline attachments", async ({ page }) => {
const mock = createQueueMock(["queued with image"])
mock.rows[0].payload.files = [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
mime: "image/png",
source: { type: "inline" },
name: "shot.png",
},
]
const view = await openSession(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.input).toHaveText("my draft")
expect(mock.changes).toEqual([])
await view.input.fill("")
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("queued with image")
await expect(view.input).toBeFocused()
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Move Back stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(7)
const row = view.rows.filter({ hasText: text })
await row.getByRole("button", { name: "Move Back" }).hover()
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
await page.screenshot({ path: testInfo.outputPath("move-back-narrow-queue.png") })
await row.getByRole("button", { name: "Move Back" }).click()
await expect(view.rows).toHaveCount(6)
await expect(view.input).toHaveText(text)
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Move Back preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
]
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
test("Move Back does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
@@ -8,13 +8,8 @@ const directory = "C:/OpenCode/SubagentNavigation"
const projectID = "proj_subagent_navigation"
const parentID = "ses_subagent_parent"
const childID = "ses_subagent_child"
const grandchildID = "ses_subagent_grandchild"
const greatGrandchildID = "ses_subagent_great_grandchild"
const parentTitle = "Parent session"
const childTitle = "Subagent child session"
const grandchildTitle = "Nested subagent session"
const greatGrandchildTitle =
"Deep research subagent session investigating a very long chain of agent registry failures and navigation breadcrumbs"
// Child session pages derive their heading from the task part that spawned them.
const taskDescription = "Inspect child navigation"
@@ -30,60 +25,6 @@ test("navigates to a subagent child session missing from the session list", asyn
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible()
})
test("opens a directly linked nested subagent in its root session tab", async ({ page }) => {
await setup(page, undefined, 1)
await page.goto(sessionHref(grandchildID), { waitUntil: "domcontentloaded" })
await expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(childTitle)
await expect(page.locator('[data-slot="session-title-child"]')).toHaveText(grandchildTitle)
const tabs = page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-slot]')
await expect(tabs.locator(`a[href="${sessionHref(grandchildID)}"]`)).toHaveCount(1)
await expect(tabs).toHaveCount(1)
await expect(tabs.locator('[data-slot="tab-title"]')).toHaveText(parentTitle)
})
test("shows the full ancestor path and navigates to an earlier subagent", async ({ page }) => {
await setup(page, undefined, 2)
await page.goto(sessionHref(greatGrandchildID), { waitUntil: "domcontentloaded" })
await expect(page.locator("[data-timeline-virtual-content]")).toBeAttached()
const header = page.locator("[data-session-title]")
await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle])
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(grandchildTitle)
await expect(header.getByRole("heading", { name: greatGrandchildTitle })).toBeVisible()
await header.getByRole("button", { name: childTitle, exact: true }).click()
await expect(page).toHaveURL(sessionHref(childID))
await expect(page.locator("[data-session-title]").getByRole("heading", { name: childTitle })).toBeVisible()
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-slot]')).toHaveCount(1)
})
test("keeps the active nested session visible in a narrow desktop header", async ({ page }) => {
await page.setViewportSize({ width: 820, height: 720 })
await setup(page, undefined, 2)
await page.goto(sessionHref(greatGrandchildID), { waitUntil: "domcontentloaded" })
const header = page.locator("[data-session-title]")
const heading = header.getByRole("heading", { name: greatGrandchildTitle })
const separator = header.locator('[data-slot="session-title-ancestors"] + [data-slot="session-title-separator"]')
await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle])
await expect(heading).toBeInViewport()
await expect(separator).toBeInViewport()
const bounds = await heading.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(820)
await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl"))
await expect(heading).toBeInViewport()
await expect(separator).toBeInViewport()
const root = header.locator(`[data-slot="session-title-ancestor"][data-session-id="${parentID}"]`)
await root.focus()
await expect(root).toBeFocused()
await root.press("Enter")
await expect(page).toHaveURL(sessionHref(parentID))
})
test("returns to the parent session with Escape", async ({ page }) => {
await setup(page)
await openChildFromParent(page)
@@ -203,7 +144,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
})
async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0 | 1 | 2 = 0) {
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -227,14 +168,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
session(parentID, parentTitle, 1700000000000),
childSession(),
...(nestedDepth >= 1 ? [session(grandchildID, grandchildTitle, 1700000002000, { parentID: childID })] : []),
...(nestedDepth >= 2
? [session(greatGrandchildID, greatGrandchildTitle, 1700000003000, { parentID: grandchildID })]
: []),
],
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
events,
eventRetry: events ? 16 : undefined,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.16",
"version": "2.0.15",
"description": "",
"type": "module",
"exports": {
+24 -16
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,21 +107,29 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
return (
<ServersProvider
-1
View File
@@ -47,7 +47,6 @@ export type ComposerDelivery = "steer" | "queue"
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
movingBack: Accessor<boolean>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
@@ -168,7 +168,6 @@ function ComposerStory(props: {
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
movingBack: () => false,
cancelEdit() {},
editFirst: () => false,
}
-2
View File
@@ -16,7 +16,6 @@ export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
readOnly?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
@@ -28,7 +27,6 @@ export function Composer(props: {
<ComposerEditor
controller={props.model}
borderUnderlay={props.borderUnderlay}
readOnly={props.readOnly}
class={props.class}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
+3 -3
View File
@@ -542,7 +542,7 @@ export function ComposerAttachments(props: {
<button
type="button"
onClick={() => props.onCommentRemove?.(comment)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={props.removeLabel}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
@@ -589,7 +589,7 @@ export function ComposerAttachments(props: {
<button
type="button"
onClick={() => props.onAttachmentRemove(attachment)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={props.removeLabel}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
@@ -611,7 +611,7 @@ export function ComposerAttachments(props: {
<button
type="button"
onClick={() => props.onUploadCancel?.(upload)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={i18n.t("ui.promptInput.cancelUpload")}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
-1
View File
@@ -371,7 +371,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
if (queue?.movingBack()) return
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
-4
View File
@@ -496,7 +496,6 @@ export const dict = {
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
@@ -874,9 +873,6 @@ export const dict = {
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.moveBack": "Move Back",
"session.queue.moveBackDraft": "Clear your draft before moving a prompt back",
"session.queue.moveBackUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
@@ -24,6 +24,8 @@ type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
@@ -139,8 +141,6 @@ type PlatformBase = {
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
/** Single-use code for an `/auth/connect/:code` link. */
code(): Promise<string>
}
}
@@ -29,17 +29,6 @@ describe("checkServerHealth", () => {
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
})
test("reports rejected credentials without retrying", async () => {
let calls = 0
const fetch = (async () => {
calls++
return Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: false, unauthorized: true })
expect(calls).toBe(1)
})
test("returns healthy response with version", async () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
+3 -9
View File
@@ -1,17 +1,11 @@
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { authTokenFromCredentials } from "./api"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client"
import { ClientError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = {
healthy: boolean
version?: string
incompatible?: boolean
checking?: boolean
unauthorized?: boolean
}
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -106,7 +100,7 @@ export async function checkServerHealth(
.catch((error) => ({ error }))
if ("data" in current) return current.data
if (signal?.aborted) return { healthy: false }
if (isUnauthorizedError(current.error)) return { healthy: false, unauthorized: true }
return next(count, current.error)
}
return attempt(0).finally(() => timeout?.clear?.())
+1 -4
View File
@@ -48,18 +48,15 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
return serverCtx
}
// A server that rejects our credentials would retry its event stream every second with the same
// credentials, so its controller waits until health recovers and then starts with the current ones.
createMemo(() => {
for (const conn of server.list) {
if (serverHealth[ServerConnection.key(conn)]?.unauthorized) continue
ensureServerCtx(conn)
}
})
createEffect(() => {
for (const [key] of serverCtxs) {
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
serverCtxs.delete(key)
+13 -3
View File
@@ -29,7 +29,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import type { Pairing } from "./pairing"
import { decodePairingCode } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -117,10 +117,16 @@ export const DialogServer: Component<{
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
list="dialog-server-addresses"
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<datalist id="dialog-server-addresses">
{form.state.urls().map((url) => (
<option value={url} />
))}
</datalist>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
@@ -207,6 +213,7 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", password: "" },
urls: [] as string[],
scanning: false,
error: "",
status: undefined as boolean | undefined,
@@ -220,6 +227,7 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
@@ -325,10 +333,11 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
setStore("error", "")
request.mutate()
}
const pair = (pairing: Pairing) => {
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
healthPreview.cancel()
setStore({
values: { ...store.values, url: pairing.url, password: pairing.password },
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
urls: pairing.urls,
scanning: false,
error: "",
})
@@ -350,6 +359,7 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
value: () => store.values.url,
name: () => store.values.name,
password: () => store.values.password,
urls: () => store.urls,
scanning: () => store.scanning,
error: () => store.error,
status: () => store.status,
@@ -1,19 +1,95 @@
import { describe, expect, test } from "bun:test"
import { pairingLink } from "./pairing"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
url: "http://192.168.1.2:49374",
code: "abc_DEF-123",
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://opencode.example.com:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(url.search).toBe("")
expect(url.hash).not.toBe("")
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("keeps accepting query pairing data", () => {
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
urls: info.urls,
password: info.password,
})
})
test("rejects other URLs", () => {
expect(pairingLink("http://192.168.1.2:49374/auth/connect/")).toBeUndefined()
expect(pairingLink("http://192.168.1.2:49374/auth/connect/abc/extra")).toBeUndefined()
expect(pairingLink("http://192.168.1.2:49374/connect#abc")).toBeUndefined()
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
test("decodes the raw JSON code", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
})
test("decodes a direct /connect URL", () => {
expect(
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
).toEqual({
urls: ["http://192.168.1.2:49374"],
password: info.password,
})
})
test("keeps accepting /connect URLs with query data", () => {
expect(
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
).toEqual({
urls: info.urls,
password: info.password,
})
})
test("falls back to the URL origin when the payload omits server URLs", () => {
const origin = "https://opencode.example.com:49709"
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
urls: [origin],
password: "secret",
})
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
+45 -17
View File
@@ -1,6 +1,15 @@
import { OpenCode } from "@opencode/client/promise"
import { Option, Schema } from "effect"
import { base64Encode } from "@opencode/util/encode"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
const normalized = normalizeServerUrl(value)
@@ -11,23 +20,42 @@ export function serverAddress(value: string) {
return normalized
}
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
export function pairingLink(value: string) {
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
}
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
export function decodePairingScan(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
const address = serverAddress(url.origin)
if (!code || !address) return
return { url: address, code }
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
}
export type Pairing = { readonly url: string; readonly password: string }
export function redeemPairingLink(link: { url: string; code: string }) {
return OpenCode.make({ baseUrl: link.url })
.server.connect({ code: link.code })
.then(
(session): Pairing => ({ url: link.url, password: session.token }),
() => undefined,
)
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
}
+8 -8
View File
@@ -3,10 +3,13 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import { decodePairingScan } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
@@ -19,16 +22,13 @@ export function PairingScanner(props: { onScan: (value: Pairing) => void; onCanc
const scanner = new QrScanner(
video,
(result) => {
const link = pairingLink(result.data)
if (!link) {
const pairing = decodePairingScan(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
})
props.onScan(pairing)
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
+26 -14
View File
@@ -8,20 +8,29 @@ import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
import { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen(props: { url?: string } = {}) {
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -30,16 +39,6 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
)
const request = useMutation(() => ({
mutationFn: async () => {
const link = pairingLink(state.url)
if (link) {
const redeemed = await redeemPairingLink(link)
if (!redeemed) {
setState("error", language.t("server.connect.link.expired"))
return
}
// Keep the token in the form so a failed connection check can retry without the spent code.
setState({ url: link.url, password: redeemed.password })
}
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
@@ -52,6 +51,7 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -76,7 +76,13 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
void camera.refetch()
}}
onScan={(pairing) => {
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
request.mutate()
}}
/>
@@ -104,12 +110,18 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
spellcheck={false}
required
appearance="large"
list="server-connect-addresses"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
<datalist id="server-connect-addresses">
{state.urls.map((url) => (
<option value={url} />
))}
</datalist>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
@@ -175,18 +175,6 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
<Tooltip placement="top" value={language.t("session.queue.moveBack")}>
<IconButton
data-action="session-queue-move-back"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="arrow-undo-down" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.moveBack")}
onClick={() => props.queue.moveBack(props.id)}
/>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { queuedPromptAttachments, queuedPromptMoveBackDraft, queuedPromptRows } from "./queue"
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
const queued = [
{
@@ -104,43 +104,3 @@ describe("queuedPromptAttachments", () => {
expect(queuedPromptAttachments(item)).toEqual([])
})
})
describe("queuedPromptMoveBackDraft", () => {
test("keeps full text, structured mentions, and inline images", () => {
const item = {
...queued[0],
payload: {
text: "inspect @main.ts with @build",
files: [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
],
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptMoveBackDraft(item)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
{ type: "text", content: " with " },
{ type: "agent", content: "@build", name: "build" },
{ type: "image", filename: "shot.png" },
])
})
test("does not drop hidden file context", () => {
const item = {
...queued[0],
payload: {
text: "inspect this",
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptMoveBackDraft(item)).toBeUndefined()
})
})
+2 -115
View File
@@ -3,7 +3,6 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
@@ -43,7 +42,6 @@ export function createSessionQueue(input: {
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "move-back"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -56,13 +54,6 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "move-back") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
input.draft.mode.set("normal")
input.draft.set(change.prompt, promptLength(change.prompt))
input.restoreFocus(promptLength(change.prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -148,25 +139,6 @@ export function createSessionQueue(input: {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const moveBack = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (
input.draft.current().some((part) => ("content" in part ? !!part.content.length : true)) ||
input.draft.mode.current() !== "normal" ||
input.draft.retry.current()
) {
showToast({ title: language.t("session.queue.moveBackDraft") })
return
}
const prompt = queuedPromptMoveBackDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.moveBackUnavailable") })
return
}
mutation.mutate({ type: "move-back", item, prompt })
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
@@ -254,11 +226,9 @@ export function createSessionQueue(input: {
editFirst,
rows,
busy: () => mutation.isPending,
movingBack: () => mutation.isPending && mutation.variables?.type === "move-back",
working: input.working,
steer,
remove,
moveBack,
edit,
reorder,
}
@@ -269,7 +239,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "moveBack" | "edit" | "reorder"
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
@@ -279,8 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
@@ -318,88 +287,6 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
export function queuedPromptMoveBackDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return !file.mention && file.source.type === "inline"
}
+1 -6
View File
@@ -224,12 +224,7 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.movingBack()}
suggestionBoundary={props.suggestionBoundary}
/>
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
</div>
}
@@ -6,7 +6,7 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { Tooltip } from "@opencode/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useNavigate } from "@solidjs/router"
import { createMemo, For, Show, type ParentProps } from "solid-js"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
@@ -191,96 +191,12 @@ export function SessionProjectMenu(props: {
)
}
export function SessionAncestorTrail(props: {
sessionID: string
parentID: string
parentTitle?: string
trailing: boolean
}) {
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
const language = useLanguage()
const ancestors = createMemo(() => {
const path: { id: string; title: string; direct: boolean }[] = []
const seen = new Set([props.sessionID])
let id: string | undefined = props.parentID
while (id && !seen.has(id)) {
seen.add(id)
const info = server.ctx.data.session.get(id)
path.unshift({
id,
title:
sessionTitle(info?.title ?? (id === props.parentID ? props.parentTitle : undefined)) ??
language.t("session.tab.session"),
direct: id === props.parentID,
})
id = info?.parentID
}
return path
})
const open = (id: string) => {
const tab = tabs.store.find(
(item) =>
item.type === "session" &&
item.server === server.key &&
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
)
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<div class="flex min-w-0 max-w-full items-center">
<div
data-slot="session-title-ancestors"
class="flex min-w-0 items-center overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
<For each={ancestors()}>
{(ancestor, index) => (
<>
<button
type="button"
data-slot={ancestor.direct ? "session-title-parent" : "session-title-ancestor"}
data-session-id={ancestor.id}
title={ancestor.title}
dir="auto"
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={() => open(ancestor.id)}
>
{ancestor.title}
</button>
<Show when={index() < ancestors().length - 1}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
</>
)}
</For>
</div>
<Show when={props.trailing}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
</div>
)
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
const navigate = useNavigate()
const tab = createMemo(() =>
tabs.store.find(
(item) =>
@@ -334,30 +250,53 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
)
})
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
const navigateParent = () => {
const id = parentID()
const current = tab()
if (!id || current?.type !== "session") return
tabs.rememberSessionRoute(current, id, parent()?.parentID)
navigate(sessionHref(server.key, id))
}
return (
<Show when={title() || parentTitle()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
<SessionProjectMenu project={project()} directory={directory()} workspace={workspaceSession()} />
<Show when={parentID()}>
{(id) => (
<SessionAncestorTrail
sessionID={props.sessionID}
parentID={id()}
parentTitle={parentTitle()}
trailing={!!title()}
/>
<SessionProjectMenu
project={project()}
directory={directory()}
workspace={workspaceSession()}
/>
<Show when={parentTitle()}>
{(value) => (
<button
type="button"
data-slot="session-title-parent"
dir="auto"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{value()}
</button>
)}
</Show>
<Show when={parentTitle() && title()}>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={title()}>
{(value) => (
<h1
data-slot={parentID() ? "session-title-child" : undefined}
dir="auto"
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
>
{value()}
</h1>
@@ -34,7 +34,7 @@ import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
import { projectForSession } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import type { BackgroundTask } from "../summary/background"
@@ -413,14 +413,21 @@ function MessageTimelineView(
workspace={workspaceSession()}
/>
<Show when={parentID()}>
{(id) => (
<SessionAncestorTrail
sessionID={sessionID() ?? ""}
parentID={id()}
parentTitle={parentTitle()}
trailing={!!(childTitle() || title.editing)}
/>
)}
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={props.action.navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
</span>
</Show>
<Show when={childTitle() || title.editing}>
<Show
@@ -429,7 +436,6 @@ function MessageTimelineView(
<h1
data-slot="session-title-child"
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
onClick={openTitleEditor}
>
{childTitle()}
@@ -445,7 +451,6 @@ function MessageTimelineView(
value={title.draft}
disabled={props.pending.rename()}
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
style={{
"--inline-input-shadow": "none",
"text-align": "start",
@@ -394,7 +394,7 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<Show when={mobile()}>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
+13 -19
View File
@@ -8,7 +8,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -73,8 +74,8 @@ export function SettingsPairing() {
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()!}
code={pair.code}
/>
))
}
@@ -116,19 +117,17 @@ export function SettingsPairing() {
)
}
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
const language = useLanguage()
const platform = usePlatform()
// Codes are single-use, so keep replacing the link while the dialog is open.
const code = useQuery(() => ({
queryKey: ["pairing", "code"],
queryFn: props.code,
gcTime: 0,
refetchInterval: 60_000,
}))
const url = createMemo(() => {
if (!code.isSuccess) return
return new URL(`/auth/connect/${code.data}`, props.host).href
if (!props.info) return
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
@@ -154,7 +153,7 @@ function DialogPairing(props: { title: string; host: string; code: () => Promise
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={url()}>
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
@@ -177,17 +176,12 @@ function DialogPairing(props: { title: string; host: string; code: () => Promise
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{new URL(props.host).origin}
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={code.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
+2 -2
View File
@@ -12,7 +12,7 @@ type Entry<Tab> = {
description?: Label
section?: Label
subtab?: "mcps" | "plugins" | "skills" | "lsps"
available?: "desktop" | "browser" | "mobile"
available?: "desktop" | "browser" | "dev" | "mobile-dev"
}
export const clientSettings: Entry<SettingsRootTab>[] = [
@@ -83,7 +83,7 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
tab: "general",
label: "settings.general.row.mobileTitlebarBottom.title",
target: "settings-mobile-titlebar-bottom",
available: "mobile",
available: "mobile-dev",
},
{
tab: "general",
+3 -1
View File
@@ -17,6 +17,7 @@ export function settingsSearchIndex(input: {
servers: readonly SettingsSearchServer[]
desktop: boolean
browser: boolean
dev: boolean
mobile: boolean
translate: ReturnType<typeof useLanguage>["t"]
}) {
@@ -56,7 +57,8 @@ export function settingsSearchIndex(input: {
clientSettings.forEach((entry) => {
if (entry.available === "desktop" && !input.desktop) return
if (entry.available === "browser" && !input.browser) return
if (entry.available === "mobile" && !input.mobile) return
if ((entry.available === "dev" || entry.available === "mobile-dev") && !input.dev) return
if (entry.available === "mobile-dev" && !input.mobile) return
add(entry, { type: "root", tab: entry.tab, target: entry.target }, "")
})
input.servers.forEach((server) => {
@@ -18,6 +18,7 @@ function index(input: Partial<Parameters<typeof settingsSearchIndex>[0]> = {}) {
servers,
desktop: false,
browser: false,
dev: false,
mobile: false,
translate: (key) => strings[key],
...input,
@@ -48,7 +49,7 @@ describe("settings search index", () => {
expect(rankSettings("font", items, root)).toHaveLength(3)
})
test("only advertises settings supported by this platform", () => {
test("only advertises settings supported by this platform and channel", () => {
const targets = (input: Parameters<typeof index>[0]) => index(input).map((item) => item.view.target)
expect(targets({})).not.toContain("settings-pinch-zoom")
expect(targets({})).not.toContain("settings-experimental-browser")
@@ -56,9 +57,9 @@ describe("settings search index", () => {
expect(targets({})).toContain("settings-tab-layout")
expect(targets({ desktop: true })).toContain("settings-pinch-zoom")
expect(targets({ browser: true })).not.toContain("settings-experimental-browser")
expect(targets({})).not.toContain("settings-show-project-icon")
expect(targets({})).not.toContain("settings-mobile-titlebar-bottom")
expect(targets({ mobile: true })).toContain("settings-mobile-titlebar-bottom")
expect(targets({ dev: true })).not.toContain("settings-show-project-icon")
expect(targets({ dev: true })).not.toContain("settings-mobile-titlebar-bottom")
expect(targets({ dev: true, mobile: true })).toContain("settings-mobile-titlebar-bottom")
})
test("uses section labels and stable identities independent of translated text", () => {
+1
View File
@@ -75,6 +75,7 @@ export function SettingsSearch() {
servers: inventory(),
desktop: platform.platform === "desktop",
browser: !!platform.browserPane,
dev: import.meta.env.VITE_OPENCODE_CHANNEL !== "prod",
mobile: mobile(),
translate: language.t,
}),
+47 -33
View File
@@ -1,5 +1,5 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,6 +10,7 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
@@ -27,6 +28,7 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -34,29 +36,48 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
onMount(() => {
if (!pairing) return
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
navigate("/", { replace: true })
})
return (
<Show when={!pairing}>
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
</Show>
)
}
@@ -76,15 +97,8 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
const servers = useServers()
const global = useGlobal()
// A lone server that rejects our credentials (e.g. the web app before pairing) has nothing else to show.
const signedOut = () => {
const only = servers.list.length === 1 ? servers.list[0] : undefined
if (only?.type !== "http") return
return global.servers.health[ServerConnection.key(only)]?.unauthorized ? only : undefined
}
return (
<Show when={servers.list.length > 0 && !signedOut()} fallback={<ConnectServerScreen url={signedOut()?.http.url} />}>
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
+6 -26
View File
@@ -33,7 +33,6 @@ import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
import { updaterAction } from "@/shell/updates/action"
import type { UpdaterState } from "@/shell/updates/types"
import { rootSession } from "@/shell/routes/session"
import devIcon from "../../../../desktop/icons/dev/64x64.png"
import betaIcon from "../../../../desktop/icons/beta/64x64.png"
@@ -186,7 +185,7 @@ export function Titlebar(props: {
const route = layout.route()
return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId)
})
const [resolvedSession] = createResource(
const [loadedSession] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
@@ -194,23 +193,7 @@ export function Titlebar(props: {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
async ({ route, ctx }) => {
const info = await ctx.sdk.api.session
.get({ sessionID: route.sessionId })
.catch(() => ctx.data.session.get(route.sessionId))
if (!info) return
ctx.data.session.remember(info)
const rootID = await rootSession(info, async (id) => {
const cached = ctx.data.session.get(id)
if (cached) return cached
const ancestor = await ctx.sdk.api.session.get({ sessionID: id })
ctx.data.session.remember(ancestor)
return ancestor
})
.then((root) => root.id)
.catch(() => ctx.data.session.root(info.id))
return { info, rootID }
},
({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
)
const session = createMemo(() => {
const route = layout.route()
@@ -219,8 +202,8 @@ export function Titlebar(props: {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
if (cached) return cached
const resolved = resolvedSession()
return resolved?.info.id === route.sessionId ? resolved.info : undefined
const loaded = loadedSession()
return loaded?.id === route.sessionId ? loaded : undefined
})
const matchRoute = (route: LayoutRoute) => {
@@ -238,8 +221,7 @@ export function Titlebar(props: {
if (main) return main
const s = session()
if (s?.parentID) {
const resolved = resolvedSession()
const parentID = resolved?.info.id === s.id ? resolved.rootID : s.parentID
const parentID = s.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
@@ -274,9 +256,7 @@ export function Titlebar(props: {
}
const s = session()
if (!s) return
const resolved = resolvedSession()
if (s.parentID && resolved?.info.id !== s.id) return
const sessionId = resolved?.info.id === s.id ? resolved.rootID : s.id
const sessionId = s.parentID ?? s.id
const next = { server: route.server, sessionId }
tabsStoreActions.addSessionTab(next)
}
+1 -2
View File
@@ -18,8 +18,7 @@ export function serviceWorker(directory: string) {
skipWaiting: false,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
// Pairing links must reach the server so it can set the session cookie.
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
globPatterns: ["**/*"],
globIgnores: ["**/*.map", "_headers", "_redirects"],
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.16",
"version": "2.0.15",
"type": "module",
"license": "MIT",
"bin": {
+2 -2
View File
@@ -495,10 +495,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
},
}),
Spec.make("pair", {
description: "Print one-time links to connect a browser or app",
description: "Show server pairing information",
params: {
url: Flag.string("url").pipe(
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
Flag.mapTryCatch(
(value) => {
const url = new URL(value)
+22 -21
View File
@@ -2,6 +2,7 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -11,25 +12,34 @@ export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const password = yield* ServiceConfig.password()
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() => client.server.info())).urls
const pairing = yield* Effect.tryPromise(() => client.server.pair())
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
const link = info.urls[0]
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
: undefined
process.stdout.write(
[
"",
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
"",
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
...(links[0]
` URLs ${info.urls[0] ?? "(none)"}`,
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
...(link
? [
"",
renderUnicodeCompact(links[0], { border: 2 })
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
]
: []),
"",
@@ -37,17 +47,8 @@ export default Runtime.handler(
)
if (Option.isSome(input.url)) return
const url = new URL(endpoint.url)
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
process.stderr.write(
[
` Over SSH? Forward the port, then open the link on your machine:`,
` ssh -L ${url.port}:${url.hostname}:${url.port} <host>`,
` If port ${url.port} is busy locally, forward another port and use it in the link.`,
"",
" To connect from other devices, run `opencode service set hostname 0.0.0.0`.",
"",
].join(EOL) + EOL,
)
const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
}),
)
+2 -7
View File
@@ -13,13 +13,8 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so a signed-out browser gets the app's sign-in screen.
if (
url.pathname === "/api" ||
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/auth/") ||
url.pathname === "/openapi.json"
)
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
+1 -19
View File
@@ -35,7 +35,7 @@ describe("web UI", () => {
yield* Effect.forEach(
[
"/",
"/settings",
"/connect?data=%7B%7D",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
@@ -68,24 +68,6 @@ describe("web UI", () => {
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
const pairing = yield* Effect.promise(() =>
fetch(new URL("/api/pair", origin), {
method: "POST",
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}).then((response) => response.json() as Promise<{ code: string }>),
)
const redirect = yield* Effect.promise(() =>
fetch(new URL(`/auth/connect/${pairing.code}`, origin), {
redirect: "manual",
headers: { accept: "text/html" },
}),
)
expect(redirect.status).toBe(302)
const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0]
const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } }))
expect(authorized.status).toBe(200)
yield* Effect.promise(() => authorized.arrayBuffer())
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.16",
"version": "2.0.15",
"type": "module",
"license": "MIT",
"repository": {
-9
View File
@@ -47,17 +47,8 @@ export type ServerInfoOutput = {
}
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export type ServerPairOutput = { readonly code: string; readonly expires_in: number }
export type ServerPairOperation<E = never> = () => Effect.Effect<ServerPairOutput, E>
export type ServerConnectInput = { readonly code: string }
export type ServerConnectOutput = { readonly token: string }
export type ServerConnectOperation<E = never> = (input: ServerConnectInput) => Effect.Effect<ServerConnectOutput, E>
export interface ServerApi<E = never> {
readonly info: ServerInfoOperation<E>
readonly pair: ServerPairOperation<E>
readonly connect: ServerConnectOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
+1 -16
View File
@@ -6,9 +6,6 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -284,19 +281,7 @@ const preserveStream =
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerPair = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerPairOutput>()(raw["server.pair"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: ServerConnectInput) =>
preserveEffect<ServerConnectOutput>()(
raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupServer = (raw: RawClient["server.server"]) => ({
info: EndpointServerInfo(raw),
pair: EndpointServerPair(raw),
connect: EndpointServerConnect(raw),
})
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
@@ -1,8 +1,5 @@
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -419,22 +416,6 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
pair: (requestOptions?: RequestOptions) =>
request<ServerPairOutput>(
{ method: "POST", path: `/api/pair`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
connect: (input: ServerConnectInput, requestOptions?: RequestOptions) =>
request<ServerConnectOutput>(
{
method: "GET",
path: `/auth/connect/${encodeURIComponent(input.code)}`,
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
@@ -2,10 +2,6 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
export type PairingCode = { code: string; expires_in: number }
export type PairingSession = { token: string }
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
export type LocationPublicRef = { directory: string }
@@ -2700,12 +2696,6 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
export type ServerInfoOutput = ServerInfo
export type ServerPairOutput = PairingCode
export type ServerConnectInput = { readonly code: { readonly code: string }["code"] }
export type ServerConnectOutput = PairingSession
export type LocationGetInput = {
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
+21 -8
View File
@@ -139,11 +139,24 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
arrow function.
- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are
not automatically awaited.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
- [x] `this` in non-arrow functions is the call's receiver: `obj.m()` and `obj["m"]()` see `obj`, a bare or detached
call (`f()`, `const m = obj.m; m()`, `(0, obj.m)()`) sees `undefined`, as in strict JS. Arrows read the enclosing
function's `this`. Program code has no receiver, so top-level `this` is `undefined`, as in a module.
- [x] `arguments` in non-arrow functions: an unmapped ordinary object with the call's arguments as indexed
properties and a hidden `length`; iterable, so spread, `for...of`, and `Array.from` work. It is not an Array
(`JSON.stringify` gives `{"0":1}`, `String` gives `[object Arguments]`). A parameter named `arguments` shadows
it; arrows read the enclosing function's; it is only created for functions whose body mentions it. `callee`
and `caller` are absent rather than poisoned.
- [ ] Array methods on `arguments` and other array-likes (`Array.prototype.slice.call(arguments, 1)`); use
`[...arguments]` or a rest parameter meanwhile.
- [x] `Function.prototype.call`, `apply`, and `bind` on program functions and built-ins:
`Array.prototype.push.call(arr, 1)`, `Math.max.apply(null, values)`, `fn.bind(obj, first)`. `apply` accepts an
array, an array-like object, or `null`/`undefined`. A bound function is named `bound f`, has its remaining
`length`, and is not constructible.
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
- [ ] The optional `thisArg` of iteration methods (`map`, `forEach`, `Map.prototype.forEach`, `Array.from`, …) is
accepted but not yet passed as `this`; callbacks run with `this` undefined.
- [ ] User-defined constructor calls.
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
- [ ] Classes and private fields.
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
and `length`. Names follow JavaScript's NamedEvaluation: declarations, named expressions, bindings,
@@ -178,7 +191,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite
deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions,
proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests
proxies, prototype inspection or mutation, classes, or arbitrary symbols. It also skips tests
asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These
are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged.
@@ -269,7 +282,7 @@ reject }` object.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
and a JavaScript `this` receiver remain outside the supported object/function model.
remain outside the supported object model.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last tool supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
@@ -419,9 +432,9 @@ reject }` object.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
Revivers receive `(key, value)` with the holder as `this`.
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
the root, with the holder as `this`. Array replacers preserve requested property order, deduplicate names, coerce
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. An Error prints as
`Error.prototype.toString` would show it (`Error: boom`), wherever it appears in the logged value.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.16",
"version": "2.0.15",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -24,7 +24,7 @@ import { typeofValue } from "./interpreter/references.js"
export type Json = Schema.Json
type Replacer<R> = (args: Array<Value>) => Effect.Effect<Value, unknown, R>
type Replacer<R> = (args: Array<Value>, holder: Obj) => Effect.Effect<Value, unknown, R>
/**
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
@@ -60,7 +60,7 @@ const walk = <R>(
const settled = raw instanceof PromiseObj ? yield* ctx.await(raw) : raw
const toJSON = settled instanceof Obj ? get(settled, "toJSON") : undefined
const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, settled, [key]) : settled
const value = replacer === undefined ? own : yield* replacer([key, own])
const value = replacer === undefined ? own : yield* replacer([key, own], holder)
if (value === undefined || typeofValue(value) === "function") return undefined
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
@@ -78,7 +78,7 @@ export const applyCollectionCallback = <R>(
ctx: Interpreter<R>,
callback: Value,
name: string,
): ((args: Array<Value>) => Effect.Effect<Value, unknown, R>) => {
): ((args: Array<Value>, thisValue?: Value) => Effect.Effect<Value, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw typeError(
@@ -87,5 +87,5 @@ export const applyCollectionCallback = <R>(
}
throw typeError(`${name} expects a function callback.`)
}
return (callbackArgs) => ctx.call(callback, undefined, callbackArgs)
return (callbackArgs, thisValue) => ctx.call(callback, thisValue, callbackArgs)
}
+28 -2
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import type { Value } from "./objects.js"
import { Arr, Callable, coerceToInteger, coerceToString, get, Obj, type Value } from "./objects.js"
import { arrayGlobal } from "../stdlib/array.js"
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
@@ -19,7 +19,7 @@ import { base64Global, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web
import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
import { constants, constructor, native } from "./native.js"
import { constants, constructor, methods, native, receiver } from "./native.js"
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
import { generatorGlobals } from "./generators.js"
import { promiseGlobal } from "./promises.js"
@@ -31,6 +31,24 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
Effect.sync(() => {
throw typeError("The Function constructor is not supported; write the function inline.")
})
const target = (thisValue: Value, method: string) => receiver(Callable, thisValue, `Function.prototype.${method}`)
methods(ctx.builtins, ctx.builtins.Function, [
["call", 1, (thisValue, args) => ctx.call(target(thisValue, "call"), args[0], args.slice(1))],
["apply", 2, (thisValue, args) => ctx.call(target(thisValue, "apply"), args[0], listFromArrayLike(args[1]))],
[
"bind",
1,
(thisValue, args) => {
const fn = target(thisValue, "bind")
const bound = args.slice(1)
return native<R>(ctx.builtins, {
name: `bound ${coerceToString(get(fn, "name"))}`,
length: Math.max(0, fn.length - bound.length),
call: (_, rest) => ctx.call(fn, args[0], [...bound, ...rest]),
})
},
],
])
return constructor<R>(ctx.builtins, ctx.builtins.Function, {
name: "Function",
length: 1,
@@ -39,6 +57,14 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
})
}
// CreateListFromArrayLike: `apply` reads `length` and the indexed properties of any object.
const listFromArrayLike = (value: Value): Array<Value> => {
if (value === undefined || value === null) return []
if (value instanceof Arr) return [...value.items]
if (!(value instanceof Obj)) throw typeError("Function.prototype.apply expects an array-like argument list.")
return Array.from({ length: coerceToInteger(get(value, "length")) }, (_, index) => get(value, String(index)))
}
const symbolGlobal = <R>(ctx: Interpreter<R>) => {
const symbol = native<R>(ctx.builtins, {
name: "Symbol",
@@ -1,4 +1,5 @@
import type {
AnyNode,
ArrayExpression,
ArrayPattern,
AssignmentPattern,
@@ -81,6 +82,7 @@ import {
keys,
Native,
parseArrayIndex,
Arguments,
Arr,
Fn,
GeneratorObj,
@@ -178,6 +180,24 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
return out
}
// Whether a function body (or a parameter default) reads `arguments`, looking through arrows but not nested
// functions, which own theirs. Memoized so the object is only built for calls that can observe it.
const argumentsUse = new WeakMap<Fn["body"], boolean>()
const usesArguments = (fn: Fn): boolean => {
const cached = argumentsUse.get(fn.body)
if (cached !== undefined) return cached
const found = [...fn.parameters, fn.body].some(function visit(node: AnyNode | null): boolean {
if (node === null || typeof node !== "object") return false
if (node.type === "Identifier") return node.name === "arguments"
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return false
return Object.values(node).some((child) =>
Array.isArray(child) ? child.some((item) => visit(item)) : visit(child as AnyNode | null),
)
})
argumentsUse.set(fn.body, found)
return found
}
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
// Memoized per body: a function's var names never change, and hoisting runs on every call.
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
@@ -287,7 +307,8 @@ export class Interpreter<R> {
this.pending = options.pending
this.builtins = options.builtins
this.logs = options.logs ?? []
const globalScope = new Map<string, Binding>()
// Program code has no receiver: top-level `this` is undefined, as in a module.
const globalScope = new Map<string, Binding>([["this", { mutable: false, value: undefined }]])
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
this.root = new Frame(this, new ScopeStack([globalScope]))
for (const [name, value] of [...globals(this), ...(options.globals?.(this) ?? [])]) {
@@ -468,6 +489,7 @@ class Frame<R> {
this.scopes.capture(),
node.async,
node.generator,
node.type === "ArrowFunctionExpression",
)
// Each generator function gets its own prototype, so `g() instanceof g` holds as in JS.
if (node.generator)
@@ -1257,6 +1279,8 @@ class Frame<R> {
}
case "Identifier":
return Effect.sync(() => this.scopes.get(node.name, node))
case "ThisExpression":
return Effect.sync(() => this.scopes.get("this", node))
case "BinaryExpression":
return this.evaluateBinaryExpression(node)
case "LogicalExpression":
@@ -1622,7 +1646,7 @@ class Frame<R> {
}
return yield* self.createToolCallPromise(callable.path, args)
}
if (callable instanceof Fn) return yield* self.invokeFunction(callable, args, node)
if (callable instanceof Fn) return yield* self.invokeFunction(callable, thisValue, args, node)
if (callable instanceof Native) {
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
}
@@ -1664,14 +1688,24 @@ class Frame<R> {
}
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
invokeFunction(fn: Fn, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
invokeFunction(fn: Fn, thisValue: Value, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.flatMap(CallSite, (site) => {
const depth = Math.max(self.depth, site.depth) + 1
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
const invocation = new Frame(this.ctx, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
// `this` and `arguments` are scope bindings so arrows resolve them lexically; a parameter named
// `arguments` shadows the object, as in JS.
if (!fn.arrow) paramScope.set("this", { mutable: false, value: thisValue, initialized: true })
if (!fn.arrow && usesArguments(fn)) {
paramScope.set("arguments", {
mutable: true,
value: new Arguments(self.ctx.builtins.Object, args),
initialized: true,
})
}
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
+1 -1
View File
@@ -72,7 +72,7 @@ export const uriError = failure("URIError")
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
new PendingThrow(
+22 -1
View File
@@ -149,7 +149,11 @@ export abstract class Opaque extends Obj {
export abstract class Callable extends Opaque {
override readonly tag = "Function"
constructor(proto: Obj, name: string, length: number) {
constructor(
proto: Obj,
name: string,
readonly length: number,
) {
super(proto)
define(this, "length", length, readonly)
define(this, "name", name, readonly)
@@ -168,12 +172,29 @@ export class Fn extends Callable {
readonly capturedScopes: Array<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
/** Arrows have no `this` or `arguments` of their own; they read the enclosing function's. */
readonly arrow: boolean,
) {
const optional = parameters.findIndex((p) => p.type === "AssignmentPattern" || p.type === "RestElement")
super(proto, name, optional === -1 ? parameters.length : optional)
}
}
/** The strict `arguments` object: an ordinary object with indexed own properties and a hidden `length`. */
export class Arguments extends Obj {
override readonly tag = "Arguments"
constructor(proto: Obj, args: Array<Value>) {
super(proto)
args.forEach((arg, index) => define(this, String(index), arg))
define(this, "length", args.length, hidden)
}
override iterator() {
return keys(this)
.map((key) => get(this, key))
.values()
}
}
export type NativeCall<R> = (thisValue: Value, args: Array<Value>) => Effect.Effect<Value, unknown, R>
export type NativeConstruct<R> = (args: Array<Value>, newTarget: Callable) => Effect.Effect<Value, unknown, R>
+1 -1
View File
@@ -40,7 +40,7 @@ const parse = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value,
else set(value, name, revived)
}
}
return yield* apply([key, value])
return yield* apply([key, value], holder)
})
return visit(record(ctx.builtins.Object, { "": parsed }), "")
}
@@ -210,11 +210,14 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
})
describe("CodeMode JSON callback boundaries", () => {
test("this remains unsupported rather than exposing callback holders", async () => {
const result = await Effect.runPromise(
CodeMode.execute({ code: `return JSON.parse("1", function (key, item) { return this })`, tools: {} }),
)
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
test("revivers and replacers see the holder as this", async () => {
expect(
await value(`
const revived = JSON.parse('{"a":{"b":1}}', function (key, item) { return key === "b" ? this.b + 1 : item })
const text = JSON.stringify({ a: 1, b: 2 }, function (key, item) { return key === "a" ? this.b : item })
return [revived, text]
`),
).toEqual([{ a: { b: 2 } }, '{"a":2,"b":2}'])
})
test("prototype-named keys parse as own data and reach the reviver", async () => {
@@ -77,6 +77,6 @@ describe("new on a non-constructible callee", () => {
expect(failure.message).toStartWith(
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain("Unsupported: classes, this, getters/setters, BigInt, and custom Symbols.")
expect(failure.message).toContain("Unsupported: classes, getters/setters, BigInt, and custom Symbols.")
})
})
+88
View File
@@ -1461,3 +1461,91 @@ describe("structuredClone", () => {
expect((await error(`structuredClone()`)).message).toContain("structuredClone requires 1 argument")
})
})
describe("this, arguments, and Function.prototype.call/apply/bind", () => {
test("this is the call receiver for non-arrow functions and lexical for arrows", async () => {
expect(
await value(`
const o = { n: 1, m() { return this.n }, a() { return (() => this.n)() }, bare() { return this } }
const detached = o.bare
function f() { return this }
return [o.m(), o["m"](), o?.m(), (o.m)(), o.a(), o.bare() === o, detached(), f(), (0, o.bare)(), this, (() => this)()]
`),
).toEqual([1, 1, 1, 1, 1, true, null, null, null, null, null])
})
test("this reaches generator and async methods and plain-function callbacks", async () => {
expect(
await value(`
const o = { n: 2, *g() { yield this.n }, async m() { return this.n }, xs: [1, 2], go() { return this.xs.map(function (x) { return [x, this] }) } }
return [[...o.g()], await o.m(), o.go()]
`),
).toEqual([
[2],
2,
[
[1, null],
[2, null],
],
])
})
test("arguments is an unmapped array-like that arrows and parameters interact with as in JS", async () => {
expect(
await value(`
function f(a) { arguments[0] = 9; return [arguments.length, arguments[1], a, [...arguments], Array.isArray(arguments), JSON.stringify(arguments), typeof arguments.map, (() => arguments[1])()] }
function shadow(arguments) { return arguments }
function hoisted() { var arguments; return arguments.length }
let outer
try { outer = arguments } catch (error) { outer = error.name }
return [f(1, 2), shadow(7), hoisted(1, 2, 3), outer]
`),
).toEqual([[2, 2, 1, [9, 2], false, '{"0":9,"1":2}', "undefined", 2], 7, 3, "ReferenceError"])
})
test("call, apply, and bind set this and arguments on program functions and built-ins", async () => {
expect(
await value(`
function f(a, b, c) { return [this, a, b, c] }
const g = f.bind({ k: 1 }, "A")
const arr = [1]
Array.prototype.push.call(arr, 2, 3)
return [
f.call("t", 1, 2),
f.apply({ k: 2 }, [1, 2]),
f.apply(null, { length: 2, 0: "x", 1: "y" }),
f.apply(null).length,
g("B", "C"), g.name, g.length,
f.bind(1).bind(2)()[0],
arr,
Math.max.apply(null, [1, 5, 3]),
Math.max.bind(null, 10)(3),
[1, 2].map(f.bind(null, 0)).map((r) => r[1]),
]
`),
).toEqual([
["t", 1, 2, null],
[{ k: 2 }, 1, 2, null],
[null, "x", "y", null],
4,
[{ k: 1 }, "A", "B", "C"],
"bound f",
2,
1,
[1, 2, 3],
5,
10,
[0, 0],
])
})
test("call and apply reject non-callable receivers and non-array-like argument lists", async () => {
expect((await error(`Function.prototype.call.call(1)`)).message).toContain(
"Function.prototype.call called on incompatible receiver",
)
expect((await error(`(() => 1).apply(null, 5)`)).message).toContain("expects an array-like argument list")
expect((await error(`function f(n) { return f.call(null, n + 1) } f(0)`)).message).toContain(
"Maximum call stack size exceeded",
)
})
})
+3 -1
View File
@@ -1416,7 +1416,9 @@ describe("built-in iterators", () => {
const logged = await run(`console.log([1].keys()); return null`)
expect(logged.logs?.[0]).toBe("[opaque reference]")
expect((await error(`return [1].keys() + ""`)).message).toContain("Binary operators require data values")
expect((await error(`return [1].keys().next.call({})`)).message).toContain("is not a function")
expect((await error(`return [1].keys().next.call({})`)).message).toContain(
"Iterator.prototype.next called on incompatible receiver a data object",
)
expect((await error(`const it = [1].keys(); const next = it.next; return next()`)).message).toContain(
"Iterator.prototype.next called on incompatible receiver undefined",
)
+3 -3
View File
@@ -24,9 +24,9 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
`script/sync-test262.ts` skips a file when its frontmatter declares a `flags`, `features`, or `includes` value the
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
checkout is at the pinned revision, so every machine runs the same 4595 files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, `this`, `arguments`, prototype objects,
property descriptors, accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
checkout is at the pinned revision, so every machine runs the same files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
## Commands
+5 -3
View File
@@ -2,6 +2,9 @@
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
"directories": [
"built-ins/Array/prototype",
"built-ins/Function/prototype/apply",
"built-ins/Function/prototype/bind",
"built-ins/Function/prototype/call",
"built-ins/Iterator",
"built-ins/Object/freeze",
"built-ins/Object/getPrototypeOf",
@@ -11,18 +14,17 @@
"built-ins/Object/isSealed",
"built-ins/Object/preventExtensions",
"built-ins/String/raw",
"language/arguments-object",
"language/expressions/does-not-equals",
"language/expressions/equals",
"language/expressions/tagged-template",
"language/expressions/this",
"language/statements"
],
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
"flags": ["module", "raw", "noStrict"],
"boundaries": {
"class": "\\bclass\\s*[A-Za-z_${]",
"this": "\\bthis\\b",
"arguments": "\\barguments\\b",
"call/apply/bind": "\\.(call|apply|bind)\\s*\\(",
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.16",
"version": "2.0.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.16",
"version": "2.0.15",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.16",
"version": "2.0.15",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.16",
"version": "2.0.15",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.16",
"version": "2.0.15",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.16",
"version": "2.0.15",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.16",
"version": "2.0.15",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

Some files were not shown because too many files have changed in this diff Show More