Compare commits

..
Author SHA1 Message Date
vimtor 7d5f2c978c refactor(app): use downward undo icon for move back 2026-09-24 15:11:09 +00:00
vimtor 2d8968d7e8 refactor(prompt): compact move back controls 2026-09-24 15:03:34 +00:00
vimtor 9ddd2e1a4c feat(prompt): move queued prompts back to input 2026-09-24 13:37:27 +00:00
Shoubhit Dash 9810d98bc2 feat(ai): polish media DX with promise file helpers and one client layer (#50978) 2026-09-24 18:34:14 +05:30
Shoubhit Dash 2caba90a63 feat(app): replace password pairing with one-time links (#50972) 2026-09-24 18:28:48 +05:30
Shoubhit Dash eccf0b3b7b feat(server): pair with one-time connect links (#50970) 2026-09-24 18:28:48 +05:30
opencode-agent[bot] 808588e9b9 chore(core): refresh bundled models.dev snapshot 2026-09-24 12:20:56 +00:00
Victor Navarro c35c211460 feat(tui): expose model variant selection to plugins (#51101) 2026-09-24 14:19:27 +02:00
opencode-agent[bot]andvimtor efb8e23dcc fix(tui): move session when recovering missing directory (#51096)
Co-authored-by: vimtor <36263538+vimtor@users.noreply.github.com>
2026-09-24 13:47:23 +02:00
opencode-agent[bot]andBrendonovich ad53e39d2e fix(app): group nested tabs and show full session path (#51064)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-24 08:10:15 +00:00
opencode-agent[bot] 3ddfc15df5 feat(app): show bottom navigation setting on mobile production (#51067) 2026-09-24 16:09:44 +08:00
opencode ac2426e103 sync release versions for v2.0.16 2026-09-24 06:34:17 +00:00
opencode-agent[bot] 0bc8b8dbeb feat(session-ui): show GitHub mark on Markdown links (#51000) 2026-09-24 14:08:32 +08:00
Luke Parker dca73ba9e3 fix(app): show composer attachment remove buttons on touch devices (#51047) 2026-09-24 15:58:30 +10:00
Aiden Cline 318a8c1aba feat(codemode): add Object.freeze/seal/create/getPrototypeOf and structuredClone (#50834) 2026-09-24 00:50:54 -05:00
162 changed files with 2909 additions and 1117 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.15",
"version": "2.0.16",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -216,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -340,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -494,7 +494,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -510,7 +510,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -529,7 +529,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -542,7 +542,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -556,7 +556,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -571,7 +571,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -610,7 +610,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -640,7 +640,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -655,7 +655,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -679,7 +679,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -700,7 +700,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -722,7 +722,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -757,7 +757,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -777,7 +777,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -811,7 +811,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -876,7 +876,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -890,7 +890,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -925,7 +925,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -960,7 +960,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.15",
"version": "2.0.16",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"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)
const response = yield * LLMClient.generate(request) // inside Effect.gen
```
`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`. `Generation.AwaitOptions` (`{ 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`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ 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.
+171 -114
View File
@@ -1,11 +1,10 @@
# @opencode/ai
Schema-first language model and image-generation APIs built with Effect.
Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
```ts
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { Effect } from "effect"
import { AIClient, LLM } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
@@ -18,23 +17,23 @@ const request = LLM.request({
})
const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
const response = yield* LLM.generate(request)
console.log(response.text)
})
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
// Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
```
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.
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.
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:
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:
```ts
import { NodeFileSystem } from "@effect/platform-node"
import { Image, ImageClient, Media } from "@opencode/ai"
import { Image, Media } from "@opencode/ai"
const image = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -46,13 +45,28 @@ const image = Effect.gen(function* () {
yield* Media.write(response.image, "./garden.png")
})
// `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)))
// `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)))
```
Prefer promises? `@opencode/ai/promise` exposes the same LLM and image APIs over one managed runtime:
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:
```ts
import { AI } from "@opencode/ai/promise"
@@ -60,6 +74,7 @@ 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
}
@@ -322,10 +337,9 @@ 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, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { Effect } from "effect"
import { AIClient, LLM } 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({
@@ -335,8 +349,7 @@ const request = LLM.request({
generation: { maxTokens: 1536 },
})
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
console.log(response.text)
```
@@ -405,14 +418,14 @@ Use `Image.generate` for one-off generation or editing:
import { Image, Media } from "@opencode/ai"
const generation = Image.generate({
model: meta("muse-image-1.0"),
model: meta.image("muse-image-1.0"),
prompt: "A flat black square on a white background.",
n: 1,
providerOptions: { reasoningStrength: "low" },
})
const edit = Image.generate({
model: meta("muse-image-1.0"),
model: meta.image("muse-image-1.0"),
prompt: "Make the square purple.",
images: [Media.bytes(imageBytes, "image/webp")],
format: "png",
@@ -459,6 +472,25 @@ 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`
@@ -468,9 +500,8 @@ 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 response =
yield *
Image.generate({
const composed = Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "Combine these product photos into one studio scene",
images: [
@@ -481,23 +512,25 @@ const response =
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. 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
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
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
yield *
Image.generate({
model: openai.image("gpt-image-2"),
prompt,
images: [Media.bytes(sourceBytes, "image/png")],
mask: Media.bytes(maskBytes, "image/png"),
})
const inpainted = 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[]`,
@@ -508,31 +541,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
yield *
Image.generate({
model: openai.image("gpt-image-2"),
prompt,
providerOptions: { quality: "medium" },
http,
})
const medium = 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
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,
})
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,
})
```
Google's current Gemini image models use the same direct API:
@@ -542,7 +575,7 @@ import { Google } from "@opencode/ai/providers"
const googleProgram = Effect.gen(function* () {
const response = yield* Image.generate({
model: Google.configure({ apiKey })("any-model-id"),
model: Google.configure({ apiKey }).image("any-model-id"),
prompt: "A robot tending a rooftop garden",
aspectRatio: "16:9",
seed: 42,
@@ -567,17 +600,18 @@ 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
yield *
Image.generate({
model: ZAI.configure({ apiKey })("any-model-id"),
prompt,
providerOptions: {
quality: "hd",
userID: "user-123",
future_option: true,
},
http,
})
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,
})
```
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
@@ -591,12 +625,14 @@ and emits `image-partial` events before each final `image`; `Image.generate` kee
`dall-e-*` models do not stream and fail typed:
```ts
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)))
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)))
```
The provider may send fewer previews than requested when the final image is ready first.
@@ -612,13 +648,20 @@ import { BlackForestLabs, Stability } from "@opencode/ai/providers"
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
persist(generation.token)
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 resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
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" } })
})
```
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).
@@ -628,12 +671,13 @@ const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
```ts
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
const upscaled =
yield *
Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
const upscaled = Effect.gen(function* () {
const small = yield* Media.file("./small.png")
return yield* Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [small] },
{ poll: { interval: "5 seconds" } },
)
})
```
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
@@ -666,8 +710,8 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`
under `providerOptions`, inferred from the selected model.
```ts
import { Video, VideoClient } from "@opencode/ai"
import { Google } from "@opencode/ai/providers"
import { Video } from "@opencode/ai"
import { Google, Runway } from "@opencode/ai/providers"
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
@@ -696,6 +740,7 @@ 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))
@@ -706,8 +751,8 @@ const controlled = Effect.gen(function* () {
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
```
`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
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
`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
@@ -726,15 +771,18 @@ 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`, `refresh`,
`cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
API.
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.
```ts
import { ai } from "@opencode/ai/promise"
const generation = await ai.video.start({ model, prompt })
const video = await generation.await({ poll: { interval: 10_000 }, signal })
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")
```
## Speech generation
@@ -786,7 +834,6 @@ 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:
@@ -814,7 +861,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 Bun.write("hello.mp3", await ai.run(response.audio.bytes()))
await ai.write(response.audio, "hello.mp3")
for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
if (event.type === "audio-delta") player.write(event.chunk)
@@ -831,6 +878,7 @@ 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"
@@ -857,7 +905,7 @@ const program = Effect.gen(function* () {
Stream.runDrain,
)
// Queued: persist the token, resume from another process, and await.
// Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, 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)))
@@ -866,7 +914,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. `TranscriptionClient.layer` needs `RequestExecutor.Service`.
`generation-progress` before it.
Provider notes:
@@ -878,6 +926,7 @@ 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 })
@@ -897,7 +946,8 @@ 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.
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs.
- **`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.
## Testing
@@ -960,11 +1010,13 @@ 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 result = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
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 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.
@@ -980,16 +1032,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 result =
yield *
LLMClient.compact(request, {
const checkpoint = Effect.gen(function* () {
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.
@@ -1003,9 +1055,11 @@ 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
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
const narrowed = Effect.gen(function* () {
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.
@@ -1014,9 +1068,10 @@ 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.
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
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"
@@ -1027,9 +1082,11 @@ const request = LLM.request({
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
},
})
const response = yield * LLMClient.generate(request)
const next = LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
const continued = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
return LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
})
```
@@ -1257,7 +1314,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 `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.
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.
## See also
+82 -65
View File
@@ -1,6 +1,6 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–3 implemented (Speech and Transcription); phases 4–5 proposal.
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 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`). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
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.
### `Media` — the asset type
@@ -86,10 +86,18 @@ class Media.Asset {
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
Media.url(url, options?) Media.ref(provider, id)
Media.file(path) // Bun/Node: reads + sniffs; Effect FileSystem variant for layers
Media.write(asset, path) // convenience, uses FileSystem
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
```
`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
@@ -104,28 +112,32 @@ import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
#### Image
```ts
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
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 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 }
```
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
portability matrix.
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`.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
@@ -135,40 +147,43 @@ 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
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" },
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 }
})
// 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
@@ -330,12 +345,12 @@ class Generation<Response> {
readonly expiresAt?: number
refresh(): Effect<Generation<Response>, AIError>
result(): Effect<Response, AIError>
await(options?: AwaitOptions): Effect<Response, AIError>
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
cancel(): Effect<void, AIError>
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
}
AwaitOptions = { poll?: Poll }
GenerationAwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
```
@@ -365,15 +380,17 @@ 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 image.image.bytes()
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
const reference = await ai.file("./ref.png")
for await (const event of ai.speech.stream({ model, text, voice })) { … }
const generation = await ai.video.start({ model, prompt })
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 video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = ai.video.resume(model, JSON.parse(saved))
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const text = await ai.llm.generate({ model, prompt }) // closes today's gap: LLM has no promise API either
const text = await ai.llm.generate({ model, prompt })
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
@@ -401,9 +418,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. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
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.
`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.
`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.
### Routes and protocols
+7 -20
View File
@@ -1,18 +1,7 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { Config, Effect, Formatter, Schema, Stream } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
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 { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
import { Route, Auth, Endpoint, Framing, Protocol } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"
/**
@@ -243,12 +232,10 @@ const generateImage = Effect.gen(function* () {
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
})
// 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
// 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
// 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
@@ -257,6 +244,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(Layer.mergeAll(requestExecutorLayer, llmClientLayer, imageClientLayer)))
}).pipe(Effect.provide(AIClient.layer))
Effect.runPromise(program)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.15",
"version": "2.0.16",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+24
View File
@@ -0,0 +1,24 @@
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.provide(RequestExecutor.fetchLayer))
export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
export const EvaluationClient = {
Service,
+3 -1
View File
@@ -30,7 +30,9 @@ export interface Interface {
) => Effect.Effect<Generation<ImageResponse>, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
export const Service = ImageClientService
export type Service = ImageClientService
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
+2 -1
View File
@@ -1,3 +1,4 @@
export { AIClient } from "./ai-client.js"
export { LLMClient } from "./route/client.js"
export { ImageClient } from "./image-client.js"
export { Auth } from "./route/auth.js"
@@ -8,7 +9,7 @@ export type {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
Interface as LLMClientShape,
Service as LLMClientService,
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, extensionMediaType } from "./utils/media-type.js"
import { detectMediaType, fileMediaType } 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, detectMediaType(data) ?? extensionMediaType(path), options)
return bytes(data, fileMediaType(data, path), options)
})
/** Materialize an asset and write its bytes through `FileSystem`. */
+64 -30
View File
@@ -1,14 +1,14 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
import { AIClient } from "./ai-client.js"
import type { AwaitOptions, Event, 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 { LLMClient } from "./route/client.js"
import { Media } from "./media.js"
import { tryRequest } from "./media-model.js"
import { RequestExecutor } from "./route/executor.js"
import { LanguageModel, LLMRequest } from "./schema/index.js"
import { AIError, InvalidRequestError, 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,17 +16,16 @@ import {
type TranscriptionOptions,
type TranscriptionRequestInput,
} from "./transcription.js"
import { TranscriptionClient } from "./transcription-client.js"
import { fileMediaType } from "./utils/media-type.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.
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
*/
export interface Options {
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
readonly layer?: Layer.Layer<RequestExecutor.Service>
}
@@ -34,19 +33,20 @@ export interface RunOptions {
readonly signal?: AbortSignal
}
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
export type Services = AIClient.Services
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
/**
* 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.
*/
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,17 +65,9 @@ const abortEffect = (signal: AbortSignal | undefined) =>
})
export const make = (options: Options = {}) => {
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LLMClient.layer,
ImageClient.layer,
VideoClient.layer,
SpeechClient.layer,
TranscriptionClient.layer,
).pipe(Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer)),
)
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
/** Run any package Effect (for example `asset.bytes()`) inside this runtime. */
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
runtime.runPromise(effect, { signal: options?.signal })
@@ -95,12 +87,15 @@ 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 ? input : LLM.request(input))
const llmRequest = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
input instanceof ImageRequest ? input : Image.request(input)
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
@@ -112,12 +107,48 @@ 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(LLM.generate(llmRequest(input)), options),
run(Effect.flatMap(llmRequest(input), LLM.generate), options),
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
iterate(LLM.stream(llmRequest(input)), options),
iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options),
},
image: {
request: Image.request,
@@ -186,6 +217,9 @@ 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()
+5 -1
View File
@@ -18,7 +18,11 @@ const PCM_SAMPLE_RATE = 24000
// 1. Public model input
// ---------------------------------------------------------------------------
export type OpenAISpeechOptions = Record<string, unknown>
/** `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 Request = SpeechRequestFor<OpenAISpeechOptions>
+2 -1
View File
@@ -211,7 +211,8 @@ 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`
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
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}`))
}
/**
+19 -14
View File
@@ -1,12 +1,15 @@
import { Config, Effect, Redacted } from "effect"
import { Config, Effect, Option, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
import { AuthenticationError, AIError, type HttpOptions } from "../schema/index.js"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
constructor(readonly source: string) {
super(`Missing auth credential: ${source}`)
constructor(
readonly source: string,
message = `Missing auth credential: ${source}`,
) {
super(message)
}
}
@@ -89,7 +92,14 @@ export const optional = (secret: Secret | undefined, source = "optional value")
? credential(Effect.fail(new MissingCredentialError(source)))
: credentialFromSecret(secret, source)
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
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 effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
@@ -145,15 +155,10 @@ export function scheme(name: string, source?: Secret | Credential) {
}
const toAIError = (error: AuthError): AIError => {
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
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 }) })
}
export const toEffect =
+3 -1
View File
@@ -261,7 +261,9 @@ const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefine
})
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
export class LLMClientService extends Context.Service<LLMClientService, Interface>()("@opencode/LLMClient") {}
export const Service = LLMClientService
export type Service = LLMClientService
const resolveRequestOptions = (request: LLMRequest) => {
const messages = normalizeToolHistory(request.messages)
+5 -1
View File
@@ -19,4 +19,8 @@ export type HttpMiddleware = (
handler: HttpHandler,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
export class RequestExecutorService extends Context.Service<RequestExecutorService, Interface>()(
"@opencode/AI/RequestExecutor",
) {}
export const Service = RequestExecutorService
export type Service = RequestExecutorService
+16
View File
@@ -255,4 +255,20 @@ 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,
Service as LLMClientService,
LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
+3 -1
View File
@@ -12,7 +12,9 @@ export interface Interface {
) => Stream.Stream<SpeechEvent, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SpeechClient") {}
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
export const Service = SpeechClientService
export type Service = SpeechClientService
export const generate = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
+5 -1
View File
@@ -30,7 +30,11 @@ export interface Interface {
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TranscriptionClient") {}
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
"@opencode/TranscriptionClient",
) {}
export const Service = TranscriptionClientService
export type Service = TranscriptionClientService
export const generate = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
+4 -1
View File
@@ -48,9 +48,12 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
csv: "text/csv",
}
export const extensionMediaType = (path: string): string | undefined =>
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",
+3 -1
View File
@@ -29,7 +29,9 @@ export interface Interface {
) => Stream.Stream<VideoEvent, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/VideoClient") {}
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
export const Service = VideoClientService
export type Service = VideoClientService
export const start = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
+9
View File
@@ -92,6 +92,15 @@ 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)
+22 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, 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, systemError } from "./lib/http.js"
import { dynamicResponse, fixedResponse, handlerLayer, 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,6 +195,26 @@ 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
+27 -1
View File
@@ -1,8 +1,11 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import {
AIClient,
AIError,
Generation,
Image,
ImageClient,
LanguageModel,
LLM,
LLMClient,
@@ -11,10 +14,11 @@ import {
Speech,
SpeechClient,
SpeechEvent,
TranscriptionClient,
Video,
VideoClient,
} from "@opencode/ai"
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
import { Route, Protocol, RequestExecutor, WebSocketTransport } from "@opencode/ai/route"
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
import {
AssemblyAI,
@@ -76,6 +80,28 @@ 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,4 +1,7 @@
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"
@@ -182,6 +185,36 @@ 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")
@@ -233,6 +266,12 @@ 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,6 +533,7 @@ 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")))),
)
})
+5 -4
View File
@@ -94,9 +94,10 @@ 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, 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
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
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.
@@ -106,7 +107,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 printed and encoded in the QR code while retaining the local service password.
This replaces the addresses in the printed links and QR code.
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,6 +214,23 @@ 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
@@ -105,6 +105,4 @@ test("renames, exports, and deletes a home session from its context menu", async
await dialog.getByRole("button", { name: "Delete session" }).click()
await removed
await expect(renamedRow).toBeHidden()
await expect(page.getByText("Session deleted", { exact: true })).toBeVisible()
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href$="/session/${fixture.targetID}"]`)).toHaveCount(0)
})
@@ -1,54 +0,0 @@
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,113 +0,0 @@
import { expect, test } from "@playwright/test"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { fixture } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
test("external deletion leaves an open tab on the missing-session screen, toasts, and removes the Home row", async ({
page,
}) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
await expect(row).toBeVisible()
await row.locator('[data-component="home-session-row"]').click()
await expect(page.getByRole("button", { name: "More options" })).toBeVisible()
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href$="/session/${fixture.targetID}"]`)
await expect(tab).toBeVisible()
sessions.splice(
sessions.findIndex((session) => session.id === fixture.targetID),
1,
)
events.push({
id: "evt_target_deleted",
created: 1700000003000,
type: "session.deleted",
durable: { aggregateID: fixture.targetID, seq: 1, version: 2 },
location: { directory: fixture.directory },
data: { sessionID: fixture.targetID },
})
await expect(page.getByText("This session cannot be found")).toBeVisible()
await expect(tab).toBeVisible()
await expect(page.getByText("Session deleted", { exact: true })).toBeVisible()
await page.getByRole("button", { name: "Home" }).click()
await expect(
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.sourceID}"]`),
).toBeVisible()
await expect(row).toHaveCount(0)
})
test("deletion event for a session without an open tab does not toast", async ({ page }) => {
const sessions = fixture.sessions.map((session) => ({ ...session }))
const events: OpenCodeEvent[] = []
await mockOpenCodeServer(page, {
sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: () => ({ items: [] }),
events: () => events.splice(0),
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
}, fixture.directory)
await page.goto("/")
const row = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
await expect(row).toBeVisible()
const refill = Promise.withResolvers<void>()
await page.route(/\/api\/session(?:\?|$)/, async (route) => {
if (route.request().method() !== "GET") return route.fallback()
await refill.promise
return route.fallback()
})
sessions.splice(
sessions.findIndex((session) => session.id === fixture.targetID),
1,
)
const refetch = page.waitForRequest(
(request) => request.method() === "GET" && /\/api\/session(?:\?|$)/.test(request.url()),
)
events.push({
id: "evt_unopened_deleted",
created: 1700000003000,
type: "session.deleted",
durable: { aggregateID: fixture.targetID, seq: 1, version: 2 },
location: { directory: fixture.directory },
data: { sessionID: fixture.targetID },
})
try {
await refetch
await expect(row).toHaveCount(0)
await expect(page.getByText("Session deleted", { exact: true })).toHaveCount(0)
} finally {
refill.resolve()
}
})
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -14,7 +14,12 @@ type InboxRow = {
sessionID: string
time: { created: number }
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
payload: {
text: string
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
delivery: "steer" | "queue"
}
@@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
@@ -234,6 +239,113 @@ 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,8 +8,13 @@ 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"
@@ -25,6 +30,60 @@ 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)
@@ -144,7 +203,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[]) {
async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0 | 1 | 2 = 0) {
await mockOpenCodeServer(page, {
directory,
project: {
@@ -168,7 +227,14 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
sessions: [
session(parentID, parentTitle, 1700000000000),
childSession(),
...(nestedDepth >= 1 ? [session(grandchildID, grandchildTitle, 1700000002000, { parentID: childID })] : []),
...(nestedDepth >= 2
? [session(greatGrandchildID, greatGrandchildTitle, 1700000003000, { parentID: grandchildID })]
: []),
],
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.15",
"version": "2.0.16",
"description": "",
"type": "module",
"exports": {
+16 -24
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, useLocation } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,29 +107,21 @@ 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) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
return (
<ServersProvider
+1
View File
@@ -47,6 +47,7 @@ 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,6 +168,7 @@ function ComposerStory(props: {
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
movingBack: () => false,
cancelEdit() {},
editFirst: () => false,
}
+2
View File
@@ -16,6 +16,7 @@ export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
readOnly?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
@@ -27,6 +28,7 @@ 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 opacity-0 group-hover:opacity-100 transition-opacity"
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"
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 opacity-0 group-hover:opacity-100 transition-opacity"
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"
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 opacity-0 group-hover:opacity-100 transition-opacity"
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"
aria-label={i18n.t("ui.promptInput.cancelUpload")}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
+1
View File
@@ -371,6 +371,7 @@ 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 -1
View File
@@ -496,6 +496,7 @@ 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":
@@ -873,6 +874,9 @@ 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",
@@ -1497,7 +1501,6 @@ export const dict = {
"settings.permissions.tool.doom_loop.description": "Detect repeated tool calls with identical input",
"session.delete.failed.title": "Failed to delete session",
"toast.session.deleted": "Session deleted",
"session.delete.title": "Delete session",
"session.delete.confirm": 'Delete session "{{name}}"?',
"session.delete.button": "Delete session",
@@ -24,8 +24,6 @@ type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
@@ -141,6 +139,8 @@ 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,6 +29,17 @@ 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) => {
+9 -3
View File
@@ -1,11 +1,17 @@
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { authTokenFromCredentials } from "./api"
import { ClientError, OpenCode } from "@opencode/client"
import { ClientError, isUnauthorizedError, 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 }
export type ServerHealth = {
healthy: boolean
version?: string
incompatible?: boolean
checking?: boolean
unauthorized?: boolean
}
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -100,7 +106,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?.())
+6 -43
View File
@@ -1,7 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { useQueryClient } from "@tanstack/solid-query"
import type { SessionInfo } from "@opencode/client/promise"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -20,7 +18,6 @@ import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "./errors"
import { useSettings } from "@/settings/model"
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
import { useTabs } from "@/shell/tabs/tabs"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -51,15 +48,18 @@ 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 (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) {
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
serverCtxs.delete(key)
@@ -131,35 +131,8 @@ function createServerController(
) {
const language = useLanguage()
const settings = useSettings()
const tabs = useTabs()
const queryClient = useQueryClient()
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const notifiedDeletionIDs = new Set<string>()
const forgetDeletedSession = (sessionID: string) => {
let removed = false
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) => {
if (!current?.some((session) => session.id === sessionID)) return current
removed = true
return current.filter((session) => session.id !== sessionID)
})
if (removed) void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
}
const notifyOpenSessionDeleted = (sessionID: string) => {
if (notifiedDeletionIDs.has(sessionID)) return
if (
!tabs.store.some(
(tab) =>
tab.type === "session" &&
tab.server === connKey &&
(tab.sessionId === sessionID || tab.routeSessionId === sessionID),
)
)
return
if (notifiedDeletionIDs.size >= 128) notifiedDeletionIDs.delete(notifiedDeletionIDs.values().next().value!)
notifiedDeletionIDs.add(sessionID)
showToast({ variant: "success", title: language.t("toast.session.deleted") })
}
const source = createData({
api: () => sdk.api,
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
@@ -179,18 +152,8 @@ function createServerController(
})
const data = createDesktopData({
data: source,
remove: async (sessionID) => {
await sdk.api.session.remove({ sessionID })
forgetDeletedSession(sessionID)
notifyOpenSessionDeleted(sessionID)
},
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
})
onCleanup(
sdk.event.on("session.deleted", (event) => {
forgetDeletedSession(event.data.sessionID)
notifyOpenSessionDeleted(event.data.sessionID)
}),
)
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
+3 -13
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 { decodePairingCode } from "./pairing"
import type { Pairing } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -117,16 +117,10 @@ 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()}
@@ -213,7 +207,6 @@ 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,
@@ -227,7 +220,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
@@ -333,11 +325,10 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
setStore("error", "")
request.mutate()
}
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
const pair = (pairing: Pairing) => {
healthPreview.cancel()
setStore({
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
urls: pairing.urls,
values: { ...store.values, url: pairing.url, password: pairing.password },
scanning: false,
error: "",
})
@@ -359,7 +350,6 @@ 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,95 +1,19 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
import { pairingLink } from "./pairing"
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,
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",
})
})
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()
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()
})
})
+17 -45
View File
@@ -1,15 +1,6 @@
import { Option, Schema } from "effect"
import { base64Encode } from "@opencode/util/encode"
import { OpenCode } from "@opencode/client/promise"
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)
@@ -20,42 +11,23 @@ export function serverAddress(value: string) {
return normalized
}
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) {
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
export function pairingLink(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
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 }
}
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)
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,
)
}
+8 -8
View File
@@ -3,13 +3,10 @@ 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 { decodePairingScan } from "./pairing"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
@@ -22,13 +19,16 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingScan(result.data)
if (!pairing) {
const link = pairingLink(result.data)
if (!link) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
props.onScan(pairing)
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
})
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
+14 -26
View File
@@ -8,29 +8,20 @@ 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 { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { pairingLink, redeemPairingLink, serverAddress } 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: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
export function ConnectServerScreen(props: { url?: string } = {}) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -39,6 +30,16 @@ export function ConnectServerScreen(
)
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"))
@@ -51,7 +52,6 @@ export function ConnectServerScreen(
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -76,13 +76,7 @@ export function ConnectServerScreen(
void camera.refetch()
}}
onScan={(pairing) => {
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
request.mutate()
}}
/>
@@ -110,18 +104,12 @@ export function ConnectServerScreen(
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,6 +175,18 @@ 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, queuedPromptRows } from "./queue"
import { queuedPromptAttachments, queuedPromptMoveBackDraft, queuedPromptRows } from "./queue"
const queued = [
{
@@ -104,3 +104,43 @@ 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()
})
})
+115 -2
View File
@@ -3,6 +3,7 @@ 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"
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "move-back"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -54,6 +56,13 @@ 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,
@@ -139,6 +148,25 @@ 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)
@@ -226,9 +254,11 @@ 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,
}
@@ -239,7 +269,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" | "edit" | "reorder"
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "moveBack" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
@@ -249,7 +279,8 @@ 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),
}))
}
@@ -287,6 +318,88 @@ 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"
}
+6 -1
View File
@@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.movingBack()}
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, Show, type ParentProps } from "solid-js"
import { createMemo, For, 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,12 +191,96 @@ 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) =>
@@ -250,53 +334,30 @@ 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={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>
<SessionProjectMenu project={project()} directory={directory()} workspace={workspaceSession()} />
<Show when={parentID()}>
{(id) => (
<SessionAncestorTrail
sessionID={props.sessionID}
parentID={id()}
parentTitle={parentTitle()}
trailing={!!title()}
/>
)}
</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 { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
import type { BackgroundTask } from "../summary/background"
@@ -413,21 +413,14 @@ function MessageTimelineView(
workspace={workspaceSession()}
/>
<Show when={parentID()}>
<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>
{(id) => (
<SessionAncestorTrail
sessionID={sessionID() ?? ""}
parentID={id()}
parentTitle={parentTitle()}
trailing={!!(childTitle() || title.editing)}
/>
)}
</Show>
<Show when={childTitle() || title.editing}>
<Show
@@ -436,6 +429,7 @@ 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()}
@@ -451,6 +445,7 @@ 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() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<Show when={mobile()}>
<SettingsRow
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
+19 -13
View File
@@ -8,8 +8,7 @@ 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, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -74,8 +73,8 @@ export function SettingsPairing() {
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()!}
code={pair.code}
/>
))
}
@@ -117,17 +116,19 @@ export function SettingsPairing() {
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
function DialogPairing(props: { title: string; host: string; code: () => Promise<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 (!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
if (!code.isSuccess) return
return new URL(`/auth/connect/${code.data}`, props.host).href
})
const copy = useMutation(() => ({
mutationFn: async () => {
@@ -153,7 +154,7 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<Show when={url()}>
<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"
@@ -176,12 +177,17 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
{new URL(props.host).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" | "dev" | "mobile-dev"
available?: "desktop" | "browser" | "mobile"
}
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-dev",
available: "mobile",
},
{
tab: "general",
+1 -3
View File
@@ -17,7 +17,6 @@ export function settingsSearchIndex(input: {
servers: readonly SettingsSearchServer[]
desktop: boolean
browser: boolean
dev: boolean
mobile: boolean
translate: ReturnType<typeof useLanguage>["t"]
}) {
@@ -57,8 +56,7 @@ export function settingsSearchIndex(input: {
clientSettings.forEach((entry) => {
if (entry.available === "desktop" && !input.desktop) return
if (entry.available === "browser" && !input.browser) return
if ((entry.available === "dev" || entry.available === "mobile-dev") && !input.dev) return
if (entry.available === "mobile-dev" && !input.mobile) return
if (entry.available === "mobile" && !input.mobile) return
add(entry, { type: "root", tab: entry.tab, target: entry.target }, "")
})
input.servers.forEach((server) => {
@@ -18,7 +18,6 @@ function index(input: Partial<Parameters<typeof settingsSearchIndex>[0]> = {}) {
servers,
desktop: false,
browser: false,
dev: false,
mobile: false,
translate: (key) => strings[key],
...input,
@@ -49,7 +48,7 @@ describe("settings search index", () => {
expect(rankSettings("font", items, root)).toHaveLength(3)
})
test("only advertises settings supported by this platform and channel", () => {
test("only advertises settings supported by this platform", () => {
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")
@@ -57,9 +56,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({ 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")
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")
})
test("uses section labels and stable identities independent of translated text", () => {
-1
View File
@@ -75,7 +75,6 @@ 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,
}),
+33 -47
View File
@@ -1,5 +1,5 @@
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, 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,7 +10,6 @@ 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 })))
@@ -28,7 +27,6 @@ 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()
@@ -36,48 +34,29 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<>
<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>
<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>
)
}
@@ -97,8 +76,15 @@ 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} fallback={<ConnectServerScreen />}>
<Show when={servers.list.length > 0 && !signedOut()} fallback={<ConnectServerScreen url={signedOut()?.http.url} />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
+26 -6
View File
@@ -33,6 +33,7 @@ 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"
@@ -185,7 +186,7 @@ export function Titlebar(props: {
const route = layout.route()
return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId)
})
const [loadedSession] = createResource(
const [resolvedSession] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
@@ -193,7 +194,23 @@ export function Titlebar(props: {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
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 }
},
)
const session = createMemo(() => {
const route = layout.route()
@@ -202,8 +219,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 loaded = loadedSession()
return loaded?.id === route.sessionId ? loaded : undefined
const resolved = resolvedSession()
return resolved?.info.id === route.sessionId ? resolved.info : undefined
})
const matchRoute = (route: LayoutRoute) => {
@@ -221,7 +238,8 @@ export function Titlebar(props: {
if (main) return main
const s = session()
if (s?.parentID) {
const parentID = s.parentID
const resolved = resolvedSession()
const parentID = resolved?.info.id === s.id ? resolved.rootID : s.parentID
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
@@ -256,7 +274,9 @@ export function Titlebar(props: {
}
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
const resolved = resolvedSession()
if (s.parentID && resolved?.info.id !== s.id) return
const sessionId = resolved?.info.id === s.id ? resolved.rootID : s.id
const next = { server: route.server, sessionId }
tabsStoreActions.addSessionTab(next)
}
+2 -1
View File
@@ -18,7 +18,8 @@ export function serviceWorker(directory: string) {
skipWaiting: false,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Pairing links must reach the server so it can set the session cookie.
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_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.15",
"version": "2.0.16",
"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: "Show server pairing information",
description: "Print one-time links to connect a browser or app",
params: {
url: Flag.string("url").pipe(
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
Flag.mapTryCatch(
(value) => {
const url = new URL(value)
+21 -22
View File
@@ -2,7 +2,6 @@ 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"
@@ -12,34 +11,25 @@ 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 password = yield* ServiceConfig.password()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = Option.isSome(input.url)
? [input.url.value]
: (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
: (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)
process.stdout.write(
[
"",
` URLs ${info.urls[0] ?? "(none)"}`,
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
...(link
` 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]
? [
"",
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
renderUnicodeCompact(links[0], { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
]
: []),
"",
@@ -47,8 +37,17 @@ export default Runtime.handler(
)
if (Option.isSome(input.url)) return
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}`)
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,
)
}),
)
+7 -2
View File
@@ -13,8 +13,13 @@ 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 /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
// 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"
)
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
+19 -1
View File
@@ -35,7 +35,7 @@ describe("web UI", () => {
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/settings",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
@@ -68,6 +68,24 @@ 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.15",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"repository": {
+9
View File
@@ -47,8 +47,17 @@ 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 }
+16 -1
View File
@@ -6,6 +6,9 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -281,7 +284,19 @@ const preserveStream =
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
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 EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
preserveEffect<LocationGetOutput>()(
@@ -1,5 +1,8 @@
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -416,6 +419,22 @@ 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,6 +2,10 @@ 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 }
@@ -2696,6 +2700,12 @@ 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"]
}
+34 -5
View File
@@ -102,6 +102,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -154,6 +155,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
- [ ] `GeneratorFunction.prototype`: every function, generator or not, inherits directly from `Function.prototype`,
and a generator whose `prototype` was replaced by a non-object still creates from the shared generator prototype.
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -295,9 +298,27 @@ reject }` object.
- [x] Every value has a real prototype chain built fresh for each run: `Object.prototype`, `Array.prototype`,
`String.prototype`, `Error.prototype` → `TypeError.prototype`, and so on hold the built-in methods as
non-enumerable properties, and each constructor's `prototype` points at it (`[].constructor === Array`,
`Object.getPrototypeOf` is not exposed). Programs may read and even overwrite these prototypes; the change is
confined to that run. `__proto__` is an ordinary own data key, so `o.__proto__ = x` never changes the chain, and
`Object.groupBy` results have no prototype at all, as in JS.
`Object.getPrototypeOf([]) === Array.prototype`). Programs may read and even overwrite these prototypes; the
change is confined to that run. `__proto__` is an ordinary own data key, so `o.__proto__ = x` never changes the
chain, and `Object.groupBy` results have no prototype at all, as in JS.
- [x] `Object.getPrototypeOf`: the prototype of an object, or the built-in prototype for a string, number, or boolean
(`Object.getPrototypeOf("a") === String.prototype`); `null`, `undefined`, and the two confined symbols throw a
`TypeError`.
- [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError`
(`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only
`Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties`
argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no
`Object.defineProperty` either).
- [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`.
Each returns its argument and passes primitives through (`Object.freeze(1) === 1`, `Object.isFrozen(1)`).
Programs run as strict code, so violations throw `TypeError`: `Cannot assign to read only property 'a'`,
`Cannot add property b, object is not extensible`, and `Cannot delete property 'a'`. Arrays enforce the same
rules for index writes, `length`, and the mutating methods (`Object.freeze([1]).push(2)` throws; a sealed array's
`pop()` throws; a non-extensible array's `pop()`, `sort()`, and `splice(0, 1, x)` work). The mutating methods are
checked once up front, so a `fill` or `sort` that would land on a hole of a non-extensible array succeeds here
where JS throws. Maps, Sets, and Dates freeze their properties only, not their contents, as in JS.
`Object.freeze(new Uint8Array(1))` and `seal` throw `TypeError` like JS (`preventExtensions` and empty typed
arrays are fine). Functions are objects and freeze like any other value.
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
@@ -325,8 +346,8 @@ reject }` object.
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
`Object.getPrototypeOf` is not exposed.
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
difference.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
@@ -518,6 +539,12 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws a
`TypeError`, since there is no `DOMException`.
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `structuredClone(value)` deep-copies primitives, plain objects (own enumerable string keys onto a plain object;
the prototype is not kept), arrays (holes and extra keys kept), Map, Set, Date, RegExp (`lastIndex` reset to 0),
Uint8Array, and errors (standard `name`, `message`, `cause`, and `stack`; other names become `Error`, extra
fields drop). Shared references stay shared in the copy, and the copy is extensible even when the source was
frozen. Functions, symbols, promises, iterators, URL, Headers, and tool references cannot be cloned; without a
`DOMException`, the failure is a `TypeError` whose message starts with `DataCloneError:`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
@@ -569,6 +596,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [ ] The derived error constructors inheriting from `Error`: `Object.getPrototypeOf(TypeError)` is
`Function.prototype` here, while `TypeError.prototype` does inherit from `Error.prototype`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.15",
"version": "2.0.16",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+2 -1
View File
@@ -15,7 +15,7 @@ import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { iteratorGlobals } from "../stdlib/iterator.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { base64Global, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
@@ -95,6 +95,7 @@ const table: Record<string, Factory> = {
atob: (ctx) => base64Global(ctx, "atob"),
btoa: (ctx) => base64Global(ctx, "btoa"),
crypto: (ctx) => cryptoGlobal(ctx),
structuredClone: (ctx) => structuredCloneGlobal(ctx),
...Object.fromEntries(errorTypes.map((type) => [type, <R>(ctx: Interpreter<R>) => errorGlobal(type, ctx)])),
}
@@ -2218,7 +2218,6 @@ class Frame<R> {
}
})()
if (written) return
if (target instanceof Arr && key === "length") throw rangeError("Invalid array length", node)
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
+30 -6
View File
@@ -9,6 +9,8 @@ import {
type GeneratorRequestKind,
IteratorSymbol,
type PendingThrow,
rangeError,
typeError,
} from "./model.js"
/** Property attributes, as in a JS property descriptor. */
@@ -41,6 +43,8 @@ export const frozen: Attributes = { writable: false, enumerable: false, configur
*/
export class Obj {
readonly props = new Map<string | symbol, Slot>()
/** [[Extensible]]: cleared by `Object.preventExtensions`, `seal`, and `freeze`. */
extensible = true
constructor(public proto: Obj | null) {}
/** The class name `Object.prototype.toString` reports: `[object Map]`. */
@@ -91,6 +95,8 @@ export class Obj {
export class Arr extends Obj {
override readonly tag = "Array"
/** The attributes every live element shares; `seal` and `freeze` narrow them since elements have no slots. */
elements: Attributes = data
constructor(
proto: Obj,
readonly items: Array<Value> = [],
@@ -460,10 +466,11 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => {
const at = index(target, name)
if (at !== undefined) {
const items = elements(target)
return at in items ? { value: items[at], ...data } : undefined
if (!(at in items)) return undefined
return { value: items[at], ...(target instanceof Arr ? target.elements : data) }
}
if (target instanceof Arr && name === "length") {
return { value: target.items.length, writable: true, enumerable: false, configurable: false }
return { value: target.items.length, writable: target.elements.writable, enumerable: false, configurable: false }
}
}
return target.props.get(name)
@@ -505,13 +512,19 @@ export const hasPrototype = (value: Value, proto: Obj): boolean => {
const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => {
const at = index(target, name)
if (at !== undefined) {
if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value)
else target.items[at] = value
if (target instanceof Bytes) {
target.bytes[at] = typeof value === "number" ? value : Number(value)
return true
}
if (!(at in target.items)) rejectAddition(target, at)
target.items[at] = value
return true
}
if (!(target instanceof Arr) || name !== "length") return undefined
const length = typeof value === "number" ? value : Number(value)
if (!Number.isInteger(length) || length < 0) return false
if (!Number.isInteger(length) || length < 0) throw rangeError("Invalid array length")
// Shrinking deletes elements, which a sealed array forbids.
if (length < target.items.length && !target.elements.configurable) return false
checkArrayLength(length)
target.items.length = length
return true
@@ -541,10 +554,17 @@ export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
const written = writeElement(target, name, value)
if (written !== undefined) return written
}
rejectAddition(target, name)
target.props.set(name, { value, ...data })
return true
}
/** Creating a property on a non-extensible object is the one [[Set]] failure with its own message. */
export const rejectAddition = (target: Obj, key: string | symbol | number): void => {
if (target.extensible) return
throw typeError(`Cannot add property ${String(key)}, object is not extensible.`)
}
/** [[DefineOwnProperty]] for a data property, ignoring the chain. */
export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => {
const name = canonical(key)
@@ -560,7 +580,11 @@ export const remove = (target: Obj, key: PropertyKey): boolean => {
const name = canonical(key)
if (isIndexed(target)) {
const at = index(target, name)
if (at !== undefined) return target instanceof Bytes ? !(at in target.bytes) : delete target.items[at]
if (at !== undefined) {
if (target instanceof Bytes) return !(at in target.bytes)
if (at in target.items && !target.elements.configurable) return false
return delete target.items[at]
}
if (target instanceof Arr && name === "length") return false
}
const slot = target.props.get(name)
+30 -11
View File
@@ -14,6 +14,7 @@ import {
coerceToInteger,
coerceToNumber,
coerceToString,
rejectAddition,
type Value,
} from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
@@ -124,6 +125,20 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
])
const self = (thisValue: Value, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
// A mutating method fails where its first element write, delete, or `length` write would on a frozen, sealed, or
// non-extensible array. `growth` is given by the methods that always write `length`, even when it is 0; holes a
// method would fill on a non-extensible array are not checked.
const mutable = (target: Arr, growth?: number): Arr => {
const length = target.items.length
if ((growth ?? 0) > 0) rejectAddition(target, length)
if ((growth ?? 0) < 0 && length > 0 && !target.elements.configurable) {
throw typeError(`Cannot delete property '${length - 1}'.`)
}
if (!target.elements.writable && (length > 0 || growth !== undefined)) {
throw typeError(`Cannot assign to read only property '${length > 0 ? 0 : "length"}'.`)
}
return target
}
const optNumber = (value: Value): number | undefined => (value === undefined ? undefined : coerceToInteger(value))
methods(builtins, proto, [
@@ -196,6 +211,8 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
0,
(thisValue) => {
const target = self(thisValue, "reverse")
// Fewer than two elements means no writes at all, so a frozen one-element array reverses fine.
if (target.items.length > 1) mutable(target)
target.items.reverse()
return target
},
@@ -204,7 +221,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"sort",
1,
(thisValue, args) => {
const target = self(thisValue, "sort")
const target = mutable(self(thisValue, "sort"))
const items = target.items
const length = items.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(items, index)).filter((o) => !o).length
@@ -244,7 +261,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"push",
1,
(thisValue, args) => {
const target = self(thisValue, "push")
const target = mutable(self(thisValue, "push"), args.length)
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
return target.items.push(...args)
@@ -254,25 +271,27 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"unshift",
1,
(thisValue, args) => {
const target = self(thisValue, "unshift")
const target = mutable(self(thisValue, "unshift"), args.length)
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
return target.items.unshift(...args)
},
],
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
["pop", 0, (thisValue) => mutable(self(thisValue, "pop"), -1).items.pop()],
["shift", 0, (thisValue) => mutable(self(thisValue, "shift"), -1).items.shift()],
[
"splice",
2,
(thisValue, args) => {
const target = self(thisValue, "splice")
if (args.length === 0) return wrap(target.items.splice(0, 0))
const length = target.items.length
const start = optNumber(args[0]) ?? 0
if (args.length === 1) return wrap(target.items.splice(start))
const deleteCount = optNumber(args[1]) ?? 0
const from = start < 0 ? Math.max(length + start, 0) : Math.min(start, length)
const deleteCount =
args.length === 1 ? length - from : Math.min(Math.max(optNumber(args[1]) ?? 0, 0), length - from)
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
return wrap(target.items.splice(start, deleteCount, ...inserted))
mutable(target, inserted.length - deleteCount)
return wrap(target.items.splice(from, deleteCount, ...inserted))
},
],
[
@@ -291,7 +310,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"fill",
1,
(thisValue, args) => {
const target = self(thisValue, "fill")
const target = mutable(self(thisValue, "fill"))
rejectCircularInsertion(target, args[0], "Array.fill result")
target.items.fill(args[0], optNumber(args[1]), optNumber(args[2]))
return target
@@ -301,7 +320,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"copyWithin",
2,
(thisValue, args) => {
const target = self(thisValue, "copyWithin")
const target = mutable(self(thisValue, "copyWithin"))
target.items.copyWithin(optNumber(args[0]) ?? 0, optNumber(args[1]) ?? 0, optNumber(args[2]))
return target
},
+65 -9
View File
@@ -1,13 +1,6 @@
import { Effect } from "effect"
import { constructor, methods, receiver } from "../interpreter/native.js"
import {
type AstNode,
AsyncIteratorSymbol,
invalidData,
IteratorSymbol,
rangeError,
typeError,
} from "../interpreter/model.js"
import { type AstNode, AsyncIteratorSymbol, invalidData, IteratorSymbol, typeError } from "../interpreter/model.js"
import {
define,
entries,
@@ -18,13 +11,16 @@ import {
hidden,
keys,
own,
ownKeys,
Arr,
Bytes,
Obj,
PromiseObj,
set,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { primitivePrototype } from "../interpreter/intrinsics.js"
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -63,7 +59,6 @@ export const objectAssign = <R>(ctx: Interpreter<R>, args: Array<Value>): Value
for (const key of enumerableKeys(from)) {
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
if (!set(target, key, getOwn(from, key))) {
if (target instanceof Arr && key === "length") throw rangeError("Invalid array length")
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
}
}
@@ -107,6 +102,34 @@ const classTag = (value: Value): string => {
const propertyKey = (value: Value): PropertyKey =>
value === AsyncIteratorSymbol || value === IteratorSymbol ? value : coerceToString(value)
// SetIntegrityLevel: primitives pass through. A typed array's bytes cannot carry attributes, so JS throws after
// already making it non-extensible.
const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
if (!(value instanceof Obj)) return value
value.extensible = false
if (level === "preventExtensions") return value
if (value instanceof Bytes && value.bytes.length > 0) {
throw typeError(`Cannot ${level} array buffer views with elements.`)
}
for (const slot of value.props.values()) {
slot.configurable = false
if (level === "freeze" && "value" in slot) slot.writable = false
}
if (value instanceof Arr) value.elements = { writable: level === "seal", enumerable: true, configurable: false }
return value
}
// TestIntegrityLevel: array elements and `length` answer through `own`, so a non-extensible empty array is sealed
// but not frozen, as in JS.
const integrity = (value: Value, frozen: boolean): boolean => {
if (!(value instanceof Obj)) return true
if (value.extensible) return false
return ownKeys(value).every((key) => {
const slot = own(value, key)
return slot !== undefined && !slot.configurable && !(frozen && "value" in slot && slot.writable)
})
}
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
export const objectGlobal = <R>(ctx: Interpreter<R>) => {
@@ -157,6 +180,39 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
["is", 2, (_, args) => Object.is(args[0], args[1])],
["assign", 2, (_, args) => objectAssign(ctx, args)],
["fromEntries", 1, (_, args) => objectFromEntries(ctx, args[0])],
["freeze", 1, (_, args) => restrict("freeze", args[0])],
["seal", 1, (_, args) => restrict("seal", args[0])],
["preventExtensions", 1, (_, args) => restrict("preventExtensions", args[0])],
["isFrozen", 1, (_, args) => integrity(args[0], true)],
["isSealed", 1, (_, args) => integrity(args[0], false)],
["isExtensible", 1, (_, args) => args[0] instanceof Obj && args[0].extensible],
[
"getPrototypeOf",
1,
(_, args) => {
if (args[0] instanceof Obj) return args[0].proto
const proto = primitivePrototype(builtins, args[0])
if (proto === undefined) {
throw typeError(`Object.getPrototypeOf cannot convert ${describeValue(args[0])} to an object.`)
}
return proto
},
],
[
"create",
2,
(_, args) => {
if (args[0] !== null && !(args[0] instanceof Obj)) {
throw typeError("Object prototype may only be an Object or null.")
}
if (args[1] !== undefined) {
throw typeError(
"Object.create property descriptors are not supported; assign the fields after creating the object.",
)
}
return new Obj(args[0])
},
],
])
define(object, "groupBy", groupBy(ctx, "Object"), hidden)
methods(builtins, builtins.Object, [
+78 -1
View File
@@ -1,8 +1,28 @@
import { fn, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { Bytes, Obj, coerceToString } from "../interpreter/objects.js"
import {
define,
entries,
get,
getOwn,
hasOwn,
hidden,
isRuntimeReference,
Arr,
Bytes,
DateObj,
ErrorObj,
MapObj,
Obj,
RegExpObj,
SetObj,
coerceToString,
type Value,
} from "../interpreter/objects.js"
import { createErrorValue, isErrorType } from "../interpreter/intrinsics.js"
import { describeValue } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { ToolReference } from "../tool-runtime.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
@@ -17,6 +37,63 @@ export const base64Global = <R>(ctx: Interpreter<R>, name: "atob" | "btoa") =>
}
})
// HTML structured clone over CodeMode's data kinds. Shared references survive through the memo; prototypes and
// frozen state do not, as in JS. Without a DOMException, DataCloneError is a TypeError carrying that prefix.
export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
fn<R>(ctx.builtins, "structuredClone", 1, (_, args) => {
if (args.length === 0) throw typeError("structuredClone requires 1 argument")
const builtins = ctx.builtins
const memo = new Map<Obj, Obj>()
const clone = (value: Value): Value => {
if (typeof value === "symbol" || value instanceof ToolReference) {
throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
}
if (!(value instanceof Obj)) return value
const seen = memo.get(value)
if (seen !== undefined) return seen
const remember = <T extends Obj>(copy: T): T => {
memo.set(value, copy)
return copy
}
if (value instanceof MapObj) {
const copy = remember(new MapObj(builtins.Map))
for (const [key, item] of value.map) copy.map.set(clone(key), clone(item))
return copy
}
if (value instanceof SetObj) {
const copy = remember(new SetObj(builtins.Set))
for (const item of value.set) copy.set.add(clone(item))
return copy
}
if (value instanceof DateObj) return remember(new DateObj(builtins.Date, value.time))
if (value instanceof RegExpObj) {
return remember(new RegExpObj(builtins.RegExp, value.regex.source, value.regex.flags))
}
if (value instanceof Bytes) return remember(new Bytes(builtins.Uint8Array, new Uint8Array(value.bytes)))
if (value instanceof ErrorObj) {
const name = get(value, "name")
const message = get(value, "message")
const copy = remember(
createErrorValue(
builtins[typeof name === "string" && isErrorType(name) ? name : "Error"],
message === undefined ? undefined : coerceToString(message),
),
)
const stack = getOwn(value, "stack")
if (typeof stack === "string") define(copy, "stack", stack, hidden)
if (hasOwn(value, "cause")) define(copy, "cause", clone(getOwn(value, "cause")), hidden)
return copy
}
if (isRuntimeReference(value)) throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
const copy = remember(
value instanceof Arr ? new Arr(builtins.Array, new Array(value.items.length)) : new Obj(builtins.Object),
)
for (const [key, item] of entries(value)) define(copy, key, clone(item))
return copy
}
return clone(args[0])
})
export const cryptoGlobal = <R>(ctx: Interpreter<R>) => {
const object = new Obj(ctx.builtins.Object)
methods(ctx.builtins, object, [
+230
View File
@@ -1231,3 +1231,233 @@ describe("loose equality and operator gates on opaque references", () => {
).toEqual(["1,2", null, "[object Function]", true, true, "function", false])
})
})
describe("Object.freeze, seal, and preventExtensions", () => {
test("a frozen object rejects writes, additions, and deletes with TypeErrors", async () => {
expect(
await value(`
const o = Object.freeze({ a: 1 })
const errors = []
try { o.a = 2 } catch (e) { errors.push(e.name + ": " + e.message) }
try { o.b = 2 } catch (e) { errors.push(e.name + ": " + e.message) }
try { delete o.a } catch (e) { errors.push(e.name + ": " + e.message) }
return [errors, o.a, Object.isFrozen(o), Object.isSealed(o), Object.isExtensible(o)]
`),
).toEqual([
[
"TypeError: Cannot assign to read only property 'a'.",
"TypeError: Cannot add property b, object is not extensible.",
"TypeError: Cannot delete property 'a'.",
],
1,
true,
true,
false,
])
})
test("seal keeps writes, preventExtensions keeps deletes, and each returns its argument", async () => {
expect(
await value(`
const s = { a: 1 }, p = { a: 1 }
const errors = []
Object.seal(s).a = 2
try { delete s.a } catch (e) { errors.push(e.name) }
delete Object.preventExtensions(p).a
try { p.b = 1 } catch (e) { errors.push(e.name) }
return [errors, s.a, Object.keys(p), Object.isSealed(s), Object.isFrozen(s), Object.isSealed(p), Object.isFrozen(p)]
`),
).toEqual([["TypeError", "TypeError"], 2, [], true, false, true, true])
})
test("primitives pass through, and Object.assign honors the flags", async () => {
expect(
await value(`
return [Object.freeze(1) === 1, Object.isFrozen(1), Object.isSealed("a"), Object.isExtensible(null), Object.isFrozen({}), Object.isExtensible({})]
`),
).toEqual([true, true, true, false, false, true])
const failure = await error(`Object.assign(Object.freeze({ a: 1 }), { b: 1 })`)
expect(failure.message).toContain("Cannot add property b, object is not extensible")
})
test("frozen arrays reject element, length, and mutating-method writes like JS", async () => {
expect(
await value(`
const a = Object.freeze([1, 2])
const attempt = (f) => { try { f(); return "ok" } catch (e) { return e.name + ": " + e.message } }
return [
attempt(() => a.push(3)),
attempt(() => { a[0] = 9 }),
attempt(() => { a[5] = 9 }),
attempt(() => { a.length = 0 }),
attempt(() => a.pop()),
attempt(() => a.sort()),
attempt(() => a.reverse()),
attempt(() => a.fill(0)),
attempt(() => a.copyWithin(0, 1)),
attempt(() => a.splice(0, 1)),
attempt(() => a.unshift(0)),
a, a.map((x) => x * 2), Object.isFrozen(a), Object.isFrozen(Object.freeze([])),
]
`),
).toEqual([
"TypeError: Cannot add property 2, object is not extensible.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot add property 5, object is not extensible.",
"TypeError: Cannot assign to read only property 'length'.",
"TypeError: Cannot delete property '1'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot assign to read only property '0'.",
"TypeError: Cannot delete property '1'.",
"TypeError: Cannot add property 2, object is not extensible.",
[1, 2],
[2, 4],
true,
true,
])
})
test("sealed and non-extensible arrays allow exactly the writes JS does", async () => {
expect(
await value(`
const s = Object.seal([1, 2]), p = Object.preventExtensions([1, 2, 3])
const attempt = (f) => { try { f(); return "ok" } catch (e) { return e.name } }
const results = [attempt(() => { s[0] = 5 }), attempt(() => s.pop()), attempt(() => { s.length = 0 }), attempt(() => s.push(1))]
p.pop(); p.sort(); p.reverse(); p.fill(0, 1); p.copyWithin(0, 1); p.length = 5
results.push(attempt(() => p.push(1)), attempt(() => p.splice(0, 1, 7, 8)), p.splice(0, 1, 7), attempt(() => { p[9] = 1 }))
return [results, s, p, Object.isSealed(p), Object.isFrozen(p), Object.isSealed(Object.preventExtensions([])), Object.isFrozen(Object.preventExtensions([]))]
`),
).toEqual([
["ok", "TypeError", "TypeError", "TypeError", "TypeError", "TypeError", [0], "TypeError"],
[5, 2],
[7, 0, null, null, null],
false,
false,
true,
false,
])
})
test("typed arrays with elements cannot be frozen; wrappers freeze their properties only", async () => {
const failure = await error(`Object.freeze(new Uint8Array([1]))`)
expect(failure.message).toContain("Cannot freeze array buffer views with elements")
expect(
await value(`
const empty = Object.freeze(new Uint8Array(0))
const bytes = Object.preventExtensions(new Uint8Array([1]))
const m = Object.freeze(new Map()); m.set(1, 2)
const f = Object.freeze(() => 1)
let named = "ok"
try { f.x = 1 } catch (e) { named = e.name }
return [Object.isFrozen(empty), Object.isFrozen(bytes), Object.isExtensible(bytes), m.size, Object.isFrozen(f), named, f()]
`),
).toEqual([true, false, false, 1, true, "TypeError", 1])
})
})
describe("Object.getPrototypeOf and Object.create", () => {
test("getPrototypeOf returns the built-in prototypes for objects and primitives", async () => {
expect(
await value(`
return [
Object.getPrototypeOf([]) === Array.prototype,
Object.getPrototypeOf({}) === Object.prototype,
Object.getPrototypeOf(Object.prototype),
Object.getPrototypeOf("a") === String.prototype,
Object.getPrototypeOf(1) === Number.prototype,
Object.getPrototypeOf(true) === Boolean.prototype,
Object.getPrototypeOf(new TypeError("x")) === TypeError.prototype,
Object.getPrototypeOf(TypeError.prototype) === Error.prototype,
Object.getPrototypeOf(() => 1) === Function.prototype,
]
`),
).toEqual([true, true, null, true, true, true, true, true, true])
})
test("getPrototypeOf rejects null, undefined, and symbols", async () => {
expect((await error(`Object.getPrototypeOf(null)`)).message).toContain("cannot convert null to an object")
expect((await error(`Object.getPrototypeOf(undefined)`)).message).toContain("cannot convert undefined to an object")
expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol")
})
test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => {
expect(
await value(`
const p = { greet(name) { return "hi " + name }, a: 1 }
const c = Object.create(p)
c.name = "x"
const seen = []
for (const key in c) seen.push(key)
return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen]
`),
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]])
})
test("Object.create rejects non-object prototypes and property descriptors", async () => {
expect((await error(`Object.create(1)`)).message).toContain("Object prototype may only be an Object or null")
expect((await error(`Object.create()`)).message).toContain("Object prototype may only be an Object or null")
expect((await error(`Object.create(null, { a: { value: 1 } })`)).message).toContain(
"Object.create property descriptors are not supported",
)
expect(await value(`return Object.keys(Object.create({}, undefined))`)).toEqual([])
})
})
describe("structuredClone", () => {
test("deep-copies data values and keeps shared references shared", async () => {
expect(
await value(`
const shared = { n: 1 }
const source = { a: shared, b: shared, list: [1, , shared], map: new Map([[1, { a: [1, 2] }]]), set: new Set([shared]), when: new Date(5), bytes: new Uint8Array([1, 2]) }
const c = structuredClone(source)
c.a.n = 2
return [
c !== source, c.a === c.b, c.a !== shared, shared.n, c.list[2] === c.a, 1 in c.list, c.list.length,
c.map.get(1).a, c.map !== source.map, c.set.has(c.a), c.when.getTime(), c.when instanceof Date,
c.bytes instanceof Uint8Array, c.bytes[1], Array.isArray(c.list),
]
`),
).toEqual([true, true, true, 1, true, false, 3, [1, 2], true, true, 5, true, true, 2, true])
})
test("regexps reset lastIndex, errors keep name, message, and cause, and extras are dropped", async () => {
expect(
await value(`
const r = /a/g
r.lastIndex = 3
const e = new TypeError("boom", { cause: { code: 1 } })
e.extra = 1
const custom = new Error("x")
custom.name = "Custom"
const [cr, ce, cc] = [structuredClone(r), structuredClone(e), structuredClone(custom)]
return [cr.source, cr.flags, cr.lastIndex, ce instanceof TypeError, ce.name, ce.message, ce.cause, ce.cause !== e.cause, ce.extra, Object.keys(ce), cc.name]
`),
).toEqual(["a", "g", 0, true, "TypeError", "boom", { code: 1 }, true, null, [], "Error"])
})
test("the clone is a plain extensible object: prototypes, symbols, undefined fields, and frozen state drop", async () => {
expect(
await value(`
const c = structuredClone(Object.freeze(Object.assign(Object.create({ inherited: 1 }), { a: undefined, [Symbol.iterator]: 1, b: 2 })))
return [Object.isFrozen(c), Object.getPrototypeOf(c) === Object.prototype, "a" in c, Symbol.iterator in c, c.b, c.inherited]
`),
).toEqual([false, true, true, false, 2, null])
})
test("functions, symbols, promises, wrappers, and tool references throw a DataCloneError TypeError", async () => {
for (const code of [
`structuredClone(() => 1)`,
`structuredClone({ deep: [Symbol.iterator] })`,
`structuredClone(Promise.resolve(1))`,
`structuredClone(new URL("http://x"))`,
`structuredClone(tools)`,
]) {
const failure = await error(code)
expect(failure.message).toMatch(/^TypeError: DataCloneError: .* could not be cloned\./)
}
expect(await value(`try { structuredClone(() => 1) } catch (e) { return e.name }`)).toBe("TypeError")
expect((await error(`structuredClone()`)).message).toContain("structuredClone requires 1 argument")
})
})
+1 -1
View File
@@ -24,7 +24,7 @@ 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 4478 files. Boundaries are
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
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
+7 -1
View File
@@ -3,7 +3,13 @@
"directories": [
"built-ins/Array/prototype",
"built-ins/Iterator",
"built-ins/Object/freeze",
"built-ins/Object/getPrototypeOf",
"built-ins/Object/is",
"built-ins/Object/isExtensible",
"built-ins/Object/isFrozen",
"built-ins/Object/isSealed",
"built-ins/Object/preventExtensions",
"built-ins/String/raw",
"language/expressions/does-not-equals",
"language/expressions/equals",
@@ -18,7 +24,7 @@
"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|create|getPrototypeOf|setPrototypeOf|freeze|seal|preventExtensions|isFrozen|isSealed|isExtensible)\\b",
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
"sloppy mode": "\\bwith\\s*\\(",
"eval": "\\b(eval|Function)\\b",
@@ -194,6 +194,7 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined
built-ins/Iterator/from/return-method-returns-iterator-result.js # Iterator next must be a function.
built-ins/Iterator/prototype/Symbol.iterator/is-function.js # every built-in collection iterator shares one prototype, so its parent is Object.prototype
built-ins/Iterator/prototype/drop/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
built-ins/Iterator/prototype/drop/limit-tonumber.js # Iterator.prototype.drop expects a non-negative count, received NaN.
built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
@@ -205,6 +206,20 @@ language/expressions/does-not-equals/S11.9.2_A7.9.js # #1: ({valueOf: function(
language/expressions/equals/S11.9.1_A7.8.js # #1: ({valueOf: function() {return 1}} == true) === true
language/expressions/equals/S11.9.1_A7.9.js # #1: (true == {valueOf: function() {return 1}}) === true
language/expressions/equals/S9.1_A1_T3.js # #1: var object = {valueOf: function() {return 1}, toString: function() {return 0}}; object + "" ===
built-ins/Object/freeze/throws-when-false.js # Unknown identifier 'Proxy'.
built-ins/Object/getPrototypeOf/15.2.3.2-2-12.js # Object.getPrototypeOf(EvalError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-13.js # Object.getPrototypeOf(RangeError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-14.js # Object.getPrototypeOf(ReferenceError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-15.js # Object.getPrototypeOf(SyntaxError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-16.js # Object.getPrototypeOf(TypeError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-17.js # Object.getPrototypeOf(URIError) Expected SameValue(«», «Error») to be true
built-ins/Object/getPrototypeOf/15.2.3.2-2-2.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-25.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-26.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/isExtensible/15.2.3.13-2-27.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/preventExtensions/15.2.3.10-3-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
built-ins/Object/preventExtensions/throws-when-false.js # Unknown identifier 'Proxy'.
language/expressions/tagged-template/constructor-invocation.js # The called value cannot be constructed: user-defined constructors and classes are not supported.
language/expressions/tagged-template/template-object-frozen-strict.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/async-generator/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
@@ -221,6 +236,8 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeErr
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
language/statements/for-in/S12.6.4_A7_T2.js # for...in visits keys deleted during the loop
language/statements/for-in/order-enumerable-shadowed.js # Object.create property descriptors are not supported; assign the fields after creating the object.
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
language/statements/for-of/dstr/array-elem-trlg-iter-list-thrw-close-err.js # Expected SameValue(«1», «0») to be true
@@ -258,8 +275,10 @@ language/statements/function/S13.2_A4_T1.js # Unknown identifier '…'.
language/statements/function/S13.2_A4_T2.js # #1: typeof __gunc.prototype === '…'. Actual: typeof __gunc.prototype ===undefined
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/default-proto.js # generator functions have no GeneratorFunction.prototype
language/statements/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/generators/prototype-relation-to-function.js # generator functions have no GeneratorFunction.prototype
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.15",
"version": "2.0.16",
"$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.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.15",
"version": "2.0.16",
"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.15",
"version": "2.0.16",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -76,6 +76,7 @@ export const appHandlers = AppRpcs.toLayer(
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
AppPairInfo: () => pair(pairing.info),
AppPairCode: () => pair(pairing.code),
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
AppSetKeepScreenActive: ({ enabled }) =>
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
+6 -9
View File
@@ -1,21 +1,18 @@
import { SidecarCredentials } from "./sidecar-credentials"
export function createPairing() {
const requireCredentials = () => {
const client = async () => {
const credentials = SidecarCredentials.get()
if (!credentials) throw new Error("The local desktop server is not ready")
return credentials
}
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
const { OpenCode } = await import("@opencode/client/promise")
const info = await OpenCode.make({
return OpenCode.make({
baseUrl: credentials.url,
headers: credentials.password
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
: undefined,
}).server.info()
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
})
}
const info = () => readInfo(requireCredentials())
return { info }
const info = async () => ({ urls: (await (await client()).server.info()).urls })
const code = async () => (await (await client()).server.pair()).code
return { info, code }
}

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