mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-24 17:47:34 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9a4baf9e | ||
|
|
9810d98bc2 | ||
|
|
2caba90a63 | ||
|
|
eccf0b3b7b | ||
|
|
808588e9b9 | ||
|
|
c35c211460 | ||
|
|
efb8e23dcc | ||
|
|
ad53e39d2e | ||
|
|
3ddfc15df5 | ||
|
|
ac2426e103 | ||
|
|
0bc8b8dbeb | ||
|
|
dca73ba9e3 |
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -7,7 +7,7 @@ export type {
|
||||
RouteDefaultsInput,
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+16
-24
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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?.())
|
||||
|
||||
@@ -48,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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 }))),
|
||||
)
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -139,24 +139,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
arrow function.
|
||||
- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are
|
||||
not automatically awaited.
|
||||
- [x] `this` in non-arrow functions is the call's receiver: `obj.m()` and `obj["m"]()` see `obj`, a bare or detached
|
||||
call (`f()`, `const m = obj.m; m()`, `(0, obj.m)()`) sees `undefined`, as in strict JS. Arrows read the enclosing
|
||||
function's `this`. Program code has no receiver, so top-level `this` is `undefined`, as in a module.
|
||||
- [x] `arguments` in non-arrow functions: an unmapped ordinary object with the call's arguments as indexed
|
||||
properties and a hidden `length`; iterable, so spread, `for...of`, and `Array.from` work. It is not an Array
|
||||
(`JSON.stringify` gives `{"0":1}`, `String` gives `[object Arguments]`). A parameter named `arguments` shadows
|
||||
it; arrows read the enclosing function's; it is only created for functions whose body mentions it. `callee`
|
||||
and `caller` are absent rather than poisoned.
|
||||
- [ ] Array methods on `arguments` and other array-likes (`Array.prototype.slice.call(arguments, 1)`); use
|
||||
`[...arguments]` or a rest parameter meanwhile.
|
||||
- [x] `Function.prototype.call`, `apply`, and `bind` on program functions and built-ins:
|
||||
`Array.prototype.push.call(arr, 1)`, `Math.max.apply(null, values)`, `fn.bind(obj, first)`. `apply` accepts an
|
||||
array, an array-like object, or `null`/`undefined`. A bound function is named `bound f`, has its remaining
|
||||
`length`, and is not constructible.
|
||||
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
|
||||
- [ ] The optional `thisArg` of iteration methods (`map`, `forEach`, `Map.prototype.forEach`, `Array.from`, …) is
|
||||
accepted but not yet passed as `this`; callbacks run with `this` undefined.
|
||||
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
|
||||
ignoring it matches JS arrow-function semantics exactly.
|
||||
- [ ] `this` in non-arrow CodeMode functions and callbacks.
|
||||
- [ ] User-defined constructor calls.
|
||||
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
|
||||
- [ ] Classes and private fields.
|
||||
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
|
||||
and `length`. Names follow JavaScript's NamedEvaluation: declarations, named expressions, bindings,
|
||||
@@ -191,7 +178,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
|
||||
and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite
|
||||
deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions,
|
||||
proxies, prototype inspection or mutation, classes, or arbitrary symbols. It also skips tests
|
||||
proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests
|
||||
asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These
|
||||
are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged.
|
||||
|
||||
@@ -282,7 +269,7 @@ reject }` object.
|
||||
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
|
||||
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
|
||||
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
|
||||
remain outside the supported object model.
|
||||
and a JavaScript `this` receiver remain outside the supported object/function model.
|
||||
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
|
||||
last tool supplied for a canonical path wins.
|
||||
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
|
||||
@@ -432,9 +419,9 @@ reject }` object.
|
||||
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
|
||||
- [x] Numeric/string indentation for `JSON.stringify`.
|
||||
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
|
||||
Revivers receive `(key, value)` with the holder as `this`.
|
||||
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
|
||||
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
|
||||
the root, with the holder as `this`. Array replacers preserve requested property order, deduplicate names, coerce
|
||||
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
|
||||
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
|
||||
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. An Error prints as
|
||||
`Error.prototype.toString` would show it (`Error: boom`), wherever it appears in the logged value.
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -24,7 +24,7 @@ import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
export type Json = Schema.Json
|
||||
|
||||
type Replacer<R> = (args: Array<Value>, holder: Obj) => Effect.Effect<Value, unknown, R>
|
||||
type Replacer<R> = (args: Array<Value>) => Effect.Effect<Value, unknown, R>
|
||||
|
||||
/**
|
||||
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
|
||||
@@ -60,7 +60,7 @@ const walk = <R>(
|
||||
const settled = raw instanceof PromiseObj ? yield* ctx.await(raw) : raw
|
||||
const toJSON = settled instanceof Obj ? get(settled, "toJSON") : undefined
|
||||
const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, settled, [key]) : settled
|
||||
const value = replacer === undefined ? own : yield* replacer([key, own], holder)
|
||||
const value = replacer === undefined ? own : yield* replacer([key, own])
|
||||
if (value === undefined || typeofValue(value) === "function") return undefined
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
|
||||
@@ -78,7 +78,7 @@ export const applyCollectionCallback = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
callback: Value,
|
||||
name: string,
|
||||
): ((args: Array<Value>, thisValue?: Value) => Effect.Effect<Value, unknown, R>) => {
|
||||
): ((args: Array<Value>) => Effect.Effect<Value, unknown, R>) => {
|
||||
if (!isSupportedCallback(callback)) {
|
||||
if (typeofValue(callback) === "function") {
|
||||
throw typeError(
|
||||
@@ -87,5 +87,5 @@ export const applyCollectionCallback = <R>(
|
||||
}
|
||||
throw typeError(`${name} expects a function callback.`)
|
||||
}
|
||||
return (callbackArgs, thisValue) => ctx.call(callback, thisValue, callbackArgs)
|
||||
return (callbackArgs) => ctx.call(callback, undefined, callbackArgs)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { Arr, Callable, coerceToInteger, coerceToString, get, Obj, type Value } from "./objects.js"
|
||||
import type { Value } from "./objects.js"
|
||||
import { arrayGlobal } from "../stdlib/array.js"
|
||||
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
|
||||
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
|
||||
@@ -19,7 +19,7 @@ import { base64Global, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { errorGlobal } from "./errors.js"
|
||||
import { errorTypes } from "./intrinsics.js"
|
||||
import { constants, constructor, methods, native, receiver } from "./native.js"
|
||||
import { constants, constructor, native } from "./native.js"
|
||||
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
|
||||
import { generatorGlobals } from "./generators.js"
|
||||
import { promiseGlobal } from "./promises.js"
|
||||
@@ -31,24 +31,6 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
Effect.sync(() => {
|
||||
throw typeError("The Function constructor is not supported; write the function inline.")
|
||||
})
|
||||
const target = (thisValue: Value, method: string) => receiver(Callable, thisValue, `Function.prototype.${method}`)
|
||||
methods(ctx.builtins, ctx.builtins.Function, [
|
||||
["call", 1, (thisValue, args) => ctx.call(target(thisValue, "call"), args[0], args.slice(1))],
|
||||
["apply", 2, (thisValue, args) => ctx.call(target(thisValue, "apply"), args[0], listFromArrayLike(args[1]))],
|
||||
[
|
||||
"bind",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const fn = target(thisValue, "bind")
|
||||
const bound = args.slice(1)
|
||||
return native<R>(ctx.builtins, {
|
||||
name: `bound ${coerceToString(get(fn, "name"))}`,
|
||||
length: Math.max(0, fn.length - bound.length),
|
||||
call: (_, rest) => ctx.call(fn, args[0], [...bound, ...rest]),
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return constructor<R>(ctx.builtins, ctx.builtins.Function, {
|
||||
name: "Function",
|
||||
length: 1,
|
||||
@@ -57,14 +39,6 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateListFromArrayLike: `apply` reads `length` and the indexed properties of any object.
|
||||
const listFromArrayLike = (value: Value): Array<Value> => {
|
||||
if (value === undefined || value === null) return []
|
||||
if (value instanceof Arr) return [...value.items]
|
||||
if (!(value instanceof Obj)) throw typeError("Function.prototype.apply expects an array-like argument list.")
|
||||
return Array.from({ length: coerceToInteger(get(value, "length")) }, (_, index) => get(value, String(index)))
|
||||
}
|
||||
|
||||
const symbolGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const symbol = native<R>(ctx.builtins, {
|
||||
name: "Symbol",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AnyNode,
|
||||
ArrayExpression,
|
||||
ArrayPattern,
|
||||
AssignmentPattern,
|
||||
@@ -82,7 +81,6 @@ import {
|
||||
keys,
|
||||
Native,
|
||||
parseArrayIndex,
|
||||
Arguments,
|
||||
Arr,
|
||||
Fn,
|
||||
GeneratorObj,
|
||||
@@ -180,24 +178,6 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
|
||||
return out
|
||||
}
|
||||
|
||||
// Whether a function body (or a parameter default) reads `arguments`, looking through arrows but not nested
|
||||
// functions, which own theirs. Memoized so the object is only built for calls that can observe it.
|
||||
const argumentsUse = new WeakMap<Fn["body"], boolean>()
|
||||
const usesArguments = (fn: Fn): boolean => {
|
||||
const cached = argumentsUse.get(fn.body)
|
||||
if (cached !== undefined) return cached
|
||||
const found = [...fn.parameters, fn.body].some(function visit(node: AnyNode | null): boolean {
|
||||
if (node === null || typeof node !== "object") return false
|
||||
if (node.type === "Identifier") return node.name === "arguments"
|
||||
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return false
|
||||
return Object.values(node).some((child) =>
|
||||
Array.isArray(child) ? child.some((item) => visit(item)) : visit(child as AnyNode | null),
|
||||
)
|
||||
})
|
||||
argumentsUse.set(fn.body, found)
|
||||
return found
|
||||
}
|
||||
|
||||
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
|
||||
// Memoized per body: a function's var names never change, and hoisting runs on every call.
|
||||
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
|
||||
@@ -307,8 +287,7 @@ export class Interpreter<R> {
|
||||
this.pending = options.pending
|
||||
this.builtins = options.builtins
|
||||
this.logs = options.logs ?? []
|
||||
// Program code has no receiver: top-level `this` is undefined, as in a module.
|
||||
const globalScope = new Map<string, Binding>([["this", { mutable: false, value: undefined }]])
|
||||
const globalScope = new Map<string, Binding>()
|
||||
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
|
||||
this.root = new Frame(this, new ScopeStack([globalScope]))
|
||||
for (const [name, value] of [...globals(this), ...(options.globals?.(this) ?? [])]) {
|
||||
@@ -489,7 +468,6 @@ class Frame<R> {
|
||||
this.scopes.capture(),
|
||||
node.async,
|
||||
node.generator,
|
||||
node.type === "ArrowFunctionExpression",
|
||||
)
|
||||
// Each generator function gets its own prototype, so `g() instanceof g` holds as in JS.
|
||||
if (node.generator)
|
||||
@@ -1279,8 +1257,6 @@ class Frame<R> {
|
||||
}
|
||||
case "Identifier":
|
||||
return Effect.sync(() => this.scopes.get(node.name, node))
|
||||
case "ThisExpression":
|
||||
return Effect.sync(() => this.scopes.get("this", node))
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinaryExpression(node)
|
||||
case "LogicalExpression":
|
||||
@@ -1646,7 +1622,7 @@ class Frame<R> {
|
||||
}
|
||||
return yield* self.createToolCallPromise(callable.path, args)
|
||||
}
|
||||
if (callable instanceof Fn) return yield* self.invokeFunction(callable, thisValue, args, node)
|
||||
if (callable instanceof Fn) return yield* self.invokeFunction(callable, args, node)
|
||||
if (callable instanceof Native) {
|
||||
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
|
||||
}
|
||||
@@ -1688,24 +1664,14 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
|
||||
invokeFunction(fn: Fn, thisValue: Value, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
invokeFunction(fn: Fn, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.flatMap(CallSite, (site) => {
|
||||
const depth = Math.max(self.depth, site.depth) + 1
|
||||
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
|
||||
const invocation = new Frame(this.ctx, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
|
||||
const paramScope = invocation.scopes.current()
|
||||
// `this` and `arguments` are scope bindings so arrows resolve them lexically; a parameter named
|
||||
// `arguments` shadows the object, as in JS.
|
||||
if (!fn.arrow) paramScope.set("this", { mutable: false, value: thisValue, initialized: true })
|
||||
if (!fn.arrow && usesArguments(fn)) {
|
||||
paramScope.set("arguments", {
|
||||
mutable: true,
|
||||
value: new Arguments(self.ctx.builtins.Object, args),
|
||||
initialized: true,
|
||||
})
|
||||
}
|
||||
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
||||
const paramScope = invocation.scopes.current()
|
||||
for (const parameter of fn.parameters) {
|
||||
for (const name of collectPatternNames(parameter)) {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
|
||||
@@ -72,7 +72,7 @@ export const uriError = failure("URIError")
|
||||
|
||||
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
|
||||
export const supportedSyntaxMessage =
|
||||
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
|
||||
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
|
||||
|
||||
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
|
||||
new PendingThrow(
|
||||
|
||||
@@ -149,11 +149,7 @@ export abstract class Opaque extends Obj {
|
||||
|
||||
export abstract class Callable extends Opaque {
|
||||
override readonly tag = "Function"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
name: string,
|
||||
readonly length: number,
|
||||
) {
|
||||
constructor(proto: Obj, name: string, length: number) {
|
||||
super(proto)
|
||||
define(this, "length", length, readonly)
|
||||
define(this, "name", name, readonly)
|
||||
@@ -172,29 +168,12 @@ export class Fn extends Callable {
|
||||
readonly capturedScopes: Array<Map<string, Binding>>,
|
||||
readonly async: boolean,
|
||||
readonly generator: boolean,
|
||||
/** Arrows have no `this` or `arguments` of their own; they read the enclosing function's. */
|
||||
readonly arrow: boolean,
|
||||
) {
|
||||
const optional = parameters.findIndex((p) => p.type === "AssignmentPattern" || p.type === "RestElement")
|
||||
super(proto, name, optional === -1 ? parameters.length : optional)
|
||||
}
|
||||
}
|
||||
|
||||
/** The strict `arguments` object: an ordinary object with indexed own properties and a hidden `length`. */
|
||||
export class Arguments extends Obj {
|
||||
override readonly tag = "Arguments"
|
||||
constructor(proto: Obj, args: Array<Value>) {
|
||||
super(proto)
|
||||
args.forEach((arg, index) => define(this, String(index), arg))
|
||||
define(this, "length", args.length, hidden)
|
||||
}
|
||||
override iterator() {
|
||||
return keys(this)
|
||||
.map((key) => get(this, key))
|
||||
.values()
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeCall<R> = (thisValue: Value, args: Array<Value>) => Effect.Effect<Value, unknown, R>
|
||||
export type NativeConstruct<R> = (args: Array<Value>, newTarget: Callable) => Effect.Effect<Value, unknown, R>
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const parse = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value,
|
||||
else set(value, name, revived)
|
||||
}
|
||||
}
|
||||
return yield* apply([key, value], holder)
|
||||
return yield* apply([key, value])
|
||||
})
|
||||
return visit(record(ctx.builtins.Object, { "": parsed }), "")
|
||||
}
|
||||
|
||||
@@ -210,14 +210,11 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
|
||||
})
|
||||
|
||||
describe("CodeMode JSON callback boundaries", () => {
|
||||
test("revivers and replacers see the holder as this", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const revived = JSON.parse('{"a":{"b":1}}', function (key, item) { return key === "b" ? this.b + 1 : item })
|
||||
const text = JSON.stringify({ a: 1, b: 2 }, function (key, item) { return key === "a" ? this.b : item })
|
||||
return [revived, text]
|
||||
`),
|
||||
).toEqual([{ a: { b: 2 } }, '{"a":2,"b":2}'])
|
||||
test("this remains unsupported rather than exposing callback holders", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.execute({ code: `return JSON.parse("1", function (key, item) { return this })`, tools: {} }),
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
|
||||
})
|
||||
|
||||
test("prototype-named keys parse as own data and reach the reviver", async () => {
|
||||
|
||||
@@ -77,6 +77,6 @@ describe("new on a non-constructible callee", () => {
|
||||
expect(failure.message).toStartWith(
|
||||
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
|
||||
)
|
||||
expect(failure.message).toContain("Unsupported: classes, getters/setters, BigInt, and custom Symbols.")
|
||||
expect(failure.message).toContain("Unsupported: classes, this, getters/setters, BigInt, and custom Symbols.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1461,91 +1461,3 @@ describe("structuredClone", () => {
|
||||
expect((await error(`structuredClone()`)).message).toContain("structuredClone requires 1 argument")
|
||||
})
|
||||
})
|
||||
|
||||
describe("this, arguments, and Function.prototype.call/apply/bind", () => {
|
||||
test("this is the call receiver for non-arrow functions and lexical for arrows", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { n: 1, m() { return this.n }, a() { return (() => this.n)() }, bare() { return this } }
|
||||
const detached = o.bare
|
||||
function f() { return this }
|
||||
return [o.m(), o["m"](), o?.m(), (o.m)(), o.a(), o.bare() === o, detached(), f(), (0, o.bare)(), this, (() => this)()]
|
||||
`),
|
||||
).toEqual([1, 1, 1, 1, 1, true, null, null, null, null, null])
|
||||
})
|
||||
|
||||
test("this reaches generator and async methods and plain-function callbacks", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { n: 2, *g() { yield this.n }, async m() { return this.n }, xs: [1, 2], go() { return this.xs.map(function (x) { return [x, this] }) } }
|
||||
return [[...o.g()], await o.m(), o.go()]
|
||||
`),
|
||||
).toEqual([
|
||||
[2],
|
||||
2,
|
||||
[
|
||||
[1, null],
|
||||
[2, null],
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("arguments is an unmapped array-like that arrows and parameters interact with as in JS", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function f(a) { arguments[0] = 9; return [arguments.length, arguments[1], a, [...arguments], Array.isArray(arguments), JSON.stringify(arguments), typeof arguments.map, (() => arguments[1])()] }
|
||||
function shadow(arguments) { return arguments }
|
||||
function hoisted() { var arguments; return arguments.length }
|
||||
let outer
|
||||
try { outer = arguments } catch (error) { outer = error.name }
|
||||
return [f(1, 2), shadow(7), hoisted(1, 2, 3), outer]
|
||||
`),
|
||||
).toEqual([[2, 2, 1, [9, 2], false, '{"0":9,"1":2}', "undefined", 2], 7, 3, "ReferenceError"])
|
||||
})
|
||||
|
||||
test("call, apply, and bind set this and arguments on program functions and built-ins", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function f(a, b, c) { return [this, a, b, c] }
|
||||
const g = f.bind({ k: 1 }, "A")
|
||||
const arr = [1]
|
||||
Array.prototype.push.call(arr, 2, 3)
|
||||
return [
|
||||
f.call("t", 1, 2),
|
||||
f.apply({ k: 2 }, [1, 2]),
|
||||
f.apply(null, { length: 2, 0: "x", 1: "y" }),
|
||||
f.apply(null).length,
|
||||
g("B", "C"), g.name, g.length,
|
||||
f.bind(1).bind(2)()[0],
|
||||
arr,
|
||||
Math.max.apply(null, [1, 5, 3]),
|
||||
Math.max.bind(null, 10)(3),
|
||||
[1, 2].map(f.bind(null, 0)).map((r) => r[1]),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
["t", 1, 2, null],
|
||||
[{ k: 2 }, 1, 2, null],
|
||||
[null, "x", "y", null],
|
||||
4,
|
||||
[{ k: 1 }, "A", "B", "C"],
|
||||
"bound f",
|
||||
2,
|
||||
1,
|
||||
[1, 2, 3],
|
||||
5,
|
||||
10,
|
||||
[0, 0],
|
||||
])
|
||||
})
|
||||
|
||||
test("call and apply reject non-callable receivers and non-array-like argument lists", async () => {
|
||||
expect((await error(`Function.prototype.call.call(1)`)).message).toContain(
|
||||
"Function.prototype.call called on incompatible receiver",
|
||||
)
|
||||
expect((await error(`(() => 1).apply(null, 5)`)).message).toContain("expects an array-like argument list")
|
||||
expect((await error(`function f(n) { return f.call(null, n + 1) } f(0)`)).message).toContain(
|
||||
"Maximum call stack size exceeded",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1416,9 +1416,7 @@ describe("built-in iterators", () => {
|
||||
const logged = await run(`console.log([1].keys()); return null`)
|
||||
expect(logged.logs?.[0]).toBe("[opaque reference]")
|
||||
expect((await error(`return [1].keys() + ""`)).message).toContain("Binary operators require data values")
|
||||
expect((await error(`return [1].keys().next.call({})`)).message).toContain(
|
||||
"Iterator.prototype.next called on incompatible receiver a data object",
|
||||
)
|
||||
expect((await error(`return [1].keys().next.call({})`)).message).toContain("is not a function")
|
||||
expect((await error(`const it = [1].keys(); const next = it.next; return next()`)).message).toContain(
|
||||
"Iterator.prototype.next called on incompatible receiver undefined",
|
||||
)
|
||||
|
||||
@@ -24,9 +24,9 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
|
||||
|
||||
`script/sync-test262.ts` skips a file when its frontmatter declares a `flags`, `features`, or `includes` value the
|
||||
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
|
||||
checkout is at the pinned revision, so every machine runs the same files. Boundaries are
|
||||
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
|
||||
accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
|
||||
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.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
|
||||
"directories": [
|
||||
"built-ins/Array/prototype",
|
||||
"built-ins/Function/prototype/apply",
|
||||
"built-ins/Function/prototype/bind",
|
||||
"built-ins/Function/prototype/call",
|
||||
"built-ins/Iterator",
|
||||
"built-ins/Object/freeze",
|
||||
"built-ins/Object/getPrototypeOf",
|
||||
@@ -14,17 +11,18 @@
|
||||
"built-ins/Object/isSealed",
|
||||
"built-ins/Object/preventExtensions",
|
||||
"built-ins/String/raw",
|
||||
"language/arguments-object",
|
||||
"language/expressions/does-not-equals",
|
||||
"language/expressions/equals",
|
||||
"language/expressions/tagged-template",
|
||||
"language/expressions/this",
|
||||
"language/statements"
|
||||
],
|
||||
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
|
||||
"flags": ["module", "raw", "noStrict"],
|
||||
"boundaries": {
|
||||
"class": "\\bclass\\s*[A-Za-z_${]",
|
||||
"this": "\\bthis\\b",
|
||||
"arguments": "\\barguments\\b",
|
||||
"call/apply/bind": "\\.(call|apply|bind)\\s*\\(",
|
||||
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
|
||||
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
|
||||
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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)),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export type ElectronAPI = {
|
||||
recordFatalRendererError(error: FatalRendererError): Promise<void>
|
||||
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
|
||||
pairInfo(): Promise<typeof PairingInfo.Type>
|
||||
pairCode(): Promise<string>
|
||||
getKeepScreenActive(): Promise<boolean>
|
||||
setKeepScreenActive(enabled: boolean): Promise<void>
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ export const api: ElectronAPI = {
|
||||
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
|
||||
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
|
||||
pairInfo: () => invoke("AppPairInfo").then(mutable),
|
||||
pairCode: () => invoke("AppPairCode"),
|
||||
getKeepScreenActive: () => invoke("AppGetKeepScreenActive"),
|
||||
setKeepScreenActive: (enabled) => invoke("AppSetKeepScreenActive", { enabled }),
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function createDesktopPlatform(
|
||||
},
|
||||
pair: {
|
||||
info: () => api.pairInfo(),
|
||||
code: () => api.pairCode(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ const ServerReadyData = Schema.Struct({
|
||||
|
||||
export const PairingInfo = Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
@@ -60,6 +58,7 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
|
||||
})
|
||||
export const AppRelaunch = Rpc.make("AppRelaunch")
|
||||
export const AppPairInfo = Rpc.make("AppPairInfo", { success: PairingInfo, error: Schema.String })
|
||||
export const AppPairCode = Rpc.make("AppPairCode", { success: Schema.String, error: Schema.String })
|
||||
export const AppGetKeepScreenActive = Rpc.make("AppGetKeepScreenActive", { success: Schema.Boolean })
|
||||
export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
|
||||
payload: { enabled: Schema.Boolean },
|
||||
@@ -82,6 +81,7 @@ export const AppRpcs = RpcGroup.make(
|
||||
AppSetNativeTranslations,
|
||||
AppRelaunch,
|
||||
AppPairInfo,
|
||||
AppPairCode,
|
||||
AppGetKeepScreenActive,
|
||||
AppSetKeepScreenActive,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user