Compare commits

..
109 changed files with 1882 additions and 1197 deletions
+2 -2
View File
@@ -55,7 +55,7 @@ const request = LLM.request({
prompt: "Say hello.",
})
const response = yield * LLMClient.generate(request)
const response = yield * LLMClient.generate(request) // inside Effect.gen
```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
@@ -98,7 +98,7 @@ When a provider supports multiple physical transports, selection remains executi
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `Generation.AwaitOptions` (`{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `MediaProtocol.incomplete`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `MediaProtocol.decodeFrame` and raise stream-time failures with `MediaProtocol.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
+171 -114
View File
@@ -1,11 +1,10 @@
# @opencode/ai
Schema-first language model and image-generation APIs built with Effect.
Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
```ts
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { Effect } from "effect"
import { AIClient, LLM } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
@@ -18,23 +17,23 @@ const request = LLM.request({
})
const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
const response = yield* LLM.generate(request)
console.log(response.text)
})
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
// Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
The same configured facade names image models. `Image.request` resolves the provider's image route from the ref and
returns `Media.Asset`s with lazily decoded bytes:
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
```ts
import { NodeFileSystem } from "@effect/platform-node"
import { Image, ImageClient, Media } from "@opencode/ai"
import { Image, Media } from "@opencode/ai"
const image = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -46,13 +45,28 @@ const image = Effect.gen(function* () {
yield* Media.write(response.image, "./garden.png")
})
// `asset.bytes()` / `Media.write` also need the executor, so merge it into the environment instead of hiding it.
const imageLayer = ImageClient.layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
await Effect.runPromise(image.pipe(Effect.provide(imageLayer), Effect.provide(NodeFileSystem.layer)))
// `Media.file` / `Media.write` use the Effect `FileSystem` service; provide your platform's layer.
await Effect.runPromise(image.pipe(Effect.provide(AIClient.layer), Effect.provide(NodeFileSystem.layer)))
```
Prefer promises? `@opencode/ai/promise` exposes the same LLM and image APIs over one managed runtime:
Advanced: each client also has its own `layer`, which requires `RequestExecutor.Service`. Compose client layers with
`Layer.provideMerge`, not `Layer.provide`: `asset.bytes()`, `Media.write`, and Gemini's `media` output parts need the
executor too, and hiding it fails type-checking with `RequestExecutorService` left in the requirements.
To share a policy such as logging across every client, wrap the executor once with `RequestExecutor.middleware`:
```ts
import { RequestExecutor } from "@opencode/ai/route"
const logged = RequestExecutor.middleware((request, next) =>
Effect.log(`${request.method} ${request.url}`).pipe(Effect.andThen(next(request))),
)
const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
```
Prefer promises? `@opencode/ai/promise` exposes the same LLM and media APIs over one managed runtime, plus asset
helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
```ts
import { AI } from "@opencode/ai/promise"
@@ -60,6 +74,7 @@ import { AI } from "@opencode/ai/promise"
const ai = AI.make()
const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
// LLMEvent
}
@@ -322,10 +337,9 @@ and `moonshot/responses`; each exports `model(modelID, settings)`.
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
```ts
import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode/ai"
import { Effect } from "effect"
import { AIClient, LLM } from "@opencode/ai"
import { MiniMax } from "@opencode/ai/providers"
import { RequestExecutor } from "@opencode/ai/route"
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
const request = LLM.request({
@@ -335,8 +349,7 @@ const request = LLM.request({
generation: { maxTokens: 1536 },
})
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
console.log(response.text)
```
@@ -405,14 +418,14 @@ Use `Image.generate` for one-off generation or editing:
import { Image, Media } from "@opencode/ai"
const generation = Image.generate({
model: meta("muse-image-1.0"),
model: meta.image("muse-image-1.0"),
prompt: "A flat black square on a white background.",
n: 1,
providerOptions: { reasoningStrength: "low" },
})
const edit = Image.generate({
model: meta("muse-image-1.0"),
model: meta.image("muse-image-1.0"),
prompt: "Make the square purple.",
images: [Media.bytes(imageBytes, "image/webp")],
format: "png",
@@ -459,6 +472,25 @@ const program = Effect.gen(function* () {
})
```
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
call rather than being dropped, so check this table before swapping only the `model`:
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
`asset.base64()`, and `asset.dataUrl()` decode or download lazily and cache; `asset.materialize()` pulls a `url`
@@ -468,9 +500,8 @@ asset into owned bytes before the provider URL expires. Construct assets with `M
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
```ts
const response =
yield *
Image.generate({
const composed = Effect.gen(function* () {
const response = yield* Image.generate({
model,
prompt: "Combine these product photos into one studio scene",
images: [
@@ -481,23 +512,25 @@ const response =
providerOptions,
http,
})
return response.images
})
```
`Media.ref(provider, id)` represents provider file handles such as OpenAI file IDs or Gemini Files URIs; routes
only forward refs that belong to their own provider. Raw strings are not accepted as image inputs, avoiding
ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
non-empty array selects the provider's edit behavior without enforcing provider image-count limits locally. OpenAI
only forward refs that belong to their own provider (OpenAI, xAI, and Gemini images accept them). No shipped route
returns a ref yet, and `asset.bytes()` / `materialize()` on a ref fail by design. Raw strings are not accepted as
image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
non-empty array selects the provider's edit behavior (see the table above for routes that limit the count). OpenAI
uses multipart for byte/data-URL edits and its JSON reference body for URL or file-ID edits. The common `mask`
field selects inpainting; routes that cannot honor it fail with `UnsupportedOperation`:
```ts
yield *
Image.generate({
model: openai.image("gpt-image-2"),
prompt,
images: [Media.bytes(sourceBytes, "image/png")],
mask: Media.bytes(maskBytes, "image/png"),
})
const inpainted = Image.generate({
model: openai.image("gpt-image-2"),
prompt,
images: [Media.bytes(sourceBytes, "image/png")],
mask: Media.bytes(maskBytes, "image/png"),
})
```
On multipart requests, `http.body` can override option fields but not structural `model`, `prompt`, `image[]`,
@@ -508,31 +541,31 @@ not accept image inputs. These cases fail with a typed `AIError` before network
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
```ts
yield *
Image.generate({
model: openai.image("gpt-image-2"),
prompt,
providerOptions: { quality: "medium" },
http,
})
const medium = Image.generate({
model: openai.image("gpt-image-2"),
prompt,
providerOptions: { quality: "medium" },
http,
})
```
xAI image models use the same request API with xAI-native controls:
```ts
yield *
Image.generate({
model: XAI.configure({ apiKey })("any-model-id"),
prompt,
n: 2,
aspectRatio: "16:9",
providerOptions: {
resolution: "1k",
responseFormat: "b64_json",
future_option: true,
},
http,
})
import { XAI } from "@opencode/ai/providers"
const xai = Image.generate({
model: XAI.configure({ apiKey }).image("any-model-id"),
prompt,
n: 2,
aspectRatio: "16:9",
providerOptions: {
resolution: "1k",
responseFormat: "b64_json",
future_option: true,
},
http,
})
```
Google's current Gemini image models use the same direct API:
@@ -542,7 +575,7 @@ import { Google } from "@opencode/ai/providers"
const googleProgram = Effect.gen(function* () {
const response = yield* Image.generate({
model: Google.configure({ apiKey })("any-model-id"),
model: Google.configure({ apiKey }).image("any-model-id"),
prompt: "A robot tending a rooftop garden",
aspectRatio: "16:9",
seed: 42,
@@ -567,17 +600,18 @@ their mapped aliases, and `http.body` is the final deep overlay. The selected mo
Z.ai image models infer open Z.ai-native options from the selected model:
```ts
yield *
Image.generate({
model: ZAI.configure({ apiKey })("any-model-id"),
prompt,
providerOptions: {
quality: "hd",
userID: "user-123",
future_option: true,
},
http,
})
import { ZAI } from "@opencode/ai/providers"
const zai = Image.generate({
model: ZAI.configure({ apiKey }).image("any-model-id"),
prompt,
providerOptions: {
quality: "hd",
userID: "user-123",
future_option: true,
},
http,
})
```
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
@@ -591,12 +625,14 @@ and emits `image-partial` events before each final `image`; `Image.generate` kee
`dall-e-*` models do not stream and fail typed:
```ts
yield *
Image.stream({
model: openai.image("gpt-image-2"),
prompt: "A lighthouse at dusk",
providerOptions: { partialImages: 2 },
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
import { Stream } from "effect"
import { ImageEvent } from "@opencode/ai"
const previews = Image.stream({
model: openai.image("gpt-image-2"),
prompt: "A lighthouse at dusk",
providerOptions: { partialImages: 2 },
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
```
The provider may send fewer previews than requested when the final image is ready first.
@@ -612,13 +648,20 @@ import { BlackForestLabs, Stability } from "@opencode/ai/providers"
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
persist(generation.token)
const submit = Effect.gen(function* () {
const generation = yield* Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
persist({ provider: "black-forest-labs", modelID: "flux-2-pro", token: generation.token })
})
const resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
const finish = Effect.gen(function* () {
const saved = load()
const resumed = yield* Image.resume(bfl.image(saved.modelID), saved.token)
return yield* resumed.await({ poll: { interval: "2 seconds" } })
})
```
The token carries no route identity, so persist the provider and model ID alongside it: `resume` needs the model.
- **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
- **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
`providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
@@ -628,12 +671,13 @@ const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
```ts
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
const upscaled =
yield *
Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
const upscaled = Effect.gen(function* () {
const small = yield* Media.file("./small.png")
return yield* Image.generate(
{ model: stability.upscale(), prompt: "A lighthouse", images: [small] },
{ poll: { interval: "5 seconds" } },
)
})
```
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
@@ -666,8 +710,8 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`
under `providerOptions`, inferred from the selected model.
```ts
import { Video, VideoClient } from "@opencode/ai"
import { Google } from "@opencode/ai/providers"
import { Video } from "@opencode/ai"
import { Google, Runway } from "@opencode/ai/providers"
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
@@ -696,6 +740,7 @@ const controlled = Effect.gen(function* () {
generation.id // provider operation / task / request id
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
// The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
const saved = JSON.stringify(generation.token)
const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
@@ -706,8 +751,8 @@ const controlled = Effect.gen(function* () {
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
```
`VideoClient.layer` needs `RequestExecutor.Service`, and status polls, result fetches, cancels, and asset downloads
all run through the same executor with the route's auth. `Generation.await` and `Generation.events` fail with a
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
auth. `Generation.await` and `Generation.events` fail with a
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
@@ -726,15 +771,18 @@ Provider notes:
- **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
`usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `refresh`,
`cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
API.
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
created; `refresh()` resolves to a new handle.
```ts
import { ai } from "@opencode/ai/promise"
const generation = await ai.video.start({ model, prompt })
const video = await generation.await({ poll: { interval: 10_000 }, signal })
for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
const video = await generation.result({ signal })
await ai.write(video.video, "./kite.mp4")
```
## Speech generation
@@ -786,7 +834,6 @@ ElevenLabs and Cartesia. `{ id }` selects an OpenAI custom voice (`{ id: "voice_
plain string elsewhere. There is no cross-provider voice catalog. `format` is the container-level word (`mp3`, `wav`,
`pcm`, `opus`, `aac`, `flac`); sample rates and bitrates live under `providerOptions`, and a value the route cannot
produce fails as `UnsupportedOperation`. Streams buffer every chunk so `finish` can carry the whole clip.
`SpeechClient.layer` needs `RequestExecutor.Service`.
Provider notes:
@@ -814,7 +861,7 @@ The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterab
import { ai } from "@opencode/ai/promise"
const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" })
await Bun.write("hello.mp3", await ai.run(response.audio.bytes()))
await ai.write(response.audio, "hello.mp3")
for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
if (event.type === "audio-delta") player.write(event.chunk)
@@ -831,6 +878,7 @@ facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" |
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
```ts
import { Console, Effect, Stream } from "effect"
import { Media, Transcription, TranscriptionEvent } from "@opencode/ai"
import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers"
@@ -857,7 +905,7 @@ const program = Effect.gen(function* () {
Stream.runDrain,
)
// Queued: persist the token, resume from another process, and await.
// Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, and await.
const model = AssemblyAI.configure({ apiKey }).transcription("universal-3-5-pro")
const generation = yield* Transcription.start({ model, audio })
const resumed = yield* Transcription.resume(model, JSON.parse(JSON.stringify(generation.token)))
@@ -866,7 +914,7 @@ const program = Effect.gen(function* () {
```
Inline routes emit only `finish` from `stream` (no faked deltas); queued routes emit `generation-queued` /
`generation-progress` before it. `TranscriptionClient.layer` needs `RequestExecutor.Service`.
`generation-progress` before it.
Provider notes:
@@ -878,6 +926,7 @@ Provider notes:
The promise client mirrors the Effect API:
```ts
const audio = await ai.file("./call.mp3")
const text = (await ai.transcription.generate({ model, audio })).text
for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta)
const generation = await ai.transcription.start({ model: assemblyai, audio })
@@ -897,7 +946,8 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs.
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
## Testing
@@ -960,11 +1010,13 @@ This is different from prompt caching, server-side history storage, or truncatio
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
```ts
const result = yield * LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
const compacted = Effect.gen(function* () {
const result = yield* LLMClient.compact(request)
const next = LLMRequest.update(request, {
messages: result.replacement,
})
return yield* LLMClient.generate(next)
})
const response = yield * LLMClient.generate(next)
```
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
@@ -980,16 +1032,16 @@ The input must still fit the model's context window. Explicit compaction is not
OpenAI Responses also exposes a separate, explicitly selected mechanism:
```ts
const result =
yield *
LLMClient.compact(request, {
const checkpoint = Effect.gen(function* () {
const result = yield* LLMClient.compact(request, {
mechanism: "trigger",
webSocket, // Optional: without it, the request uses HTTP/SSE.
})
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
result.checkpoint // Successful encrypted CompactionPart.
result.responseID
result.usage
})
```
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
@@ -1003,9 +1055,11 @@ The supplied WebSocket executor can reuse a compatible append baseline for the c
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
```ts
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
}
const narrowed = Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const result = yield* LLMClient.compact(request, { mechanism: "trigger" })
}
})
```
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
@@ -1014,9 +1068,10 @@ This capability describes protocol implementation, **not universal availability
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
Enable OpenAI compaction with typed provider options:
```ts
import { Effect } from "effect"
import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
@@ -1027,9 +1082,11 @@ const request = LLM.request({
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
},
})
const response = yield * LLMClient.generate(request)
const next = LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
const continued = Effect.gen(function* () {
const response = yield* LLMClient.generate(request)
return LLMRequest.update(request, {
messages: [...request.messages, response.message, Message.user("Continue")],
})
})
```
@@ -1257,7 +1314,7 @@ Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. T
## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `AIClient.layer` (or `AIClient.layerWith(executor)`) for every modality, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also
+82 -65
View File
@@ -1,6 +1,6 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–3 implemented (Speech and Transcription); phases 4–5 proposal.
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
## Goal
@@ -54,7 +54,7 @@ Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
```
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
### `Media` — the asset type
@@ -86,10 +86,18 @@ class Media.Asset {
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
Media.url(url, options?) Media.ref(provider, id)
Media.file(path) // Bun/Node: reads + sniffs; Effect FileSystem variant for layers
Media.write(asset, path) // convenience, uses FileSystem
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
```
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
`ai.materialize` run the asset methods in its runtime.
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
### Modality namespaces
@@ -104,28 +112,32 @@ import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
#### Image
```ts
const request = Image.request({
model: openai.image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
images: [Media.file("./ref.png")], // references / edit sources
mask: Media.file("./mask.png"),
n: 2,
size: "1536x1024", // or aspectRatio: "3:2"
seed: 7,
format: "webp",
providerOptions: { quality: "high", background: "transparent" }, // typed per model
Effect.gen(function* () {
const request = Image.request({
model: openai.image("gpt-image-2"),
prompt: "A robot tending a rooftop garden",
images: [yield* Media.file("./ref.png")], // references / edit sources
mask: yield* Media.file("./mask.png"),
n: 2,
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
format: "webp",
providerOptions: { quality: "high", background: "transparent" }, // typed per model
})
const response = yield* Image.generate(request) // ImageResponse
response.image // Media.Asset (first)
response.images // Media.Asset[]
response.usage // Usage union (see below)
response.notices // moderation / partial-result notices
Image.stream(request) // Stream<ImageEvent>
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
})
const response = yield* Image.generate(request) // ImageResponse
response.image // Media.Asset (first)
response.images // Media.Asset[]
response.usage // Usage union (see below)
response.notices // moderation / partial-result notices
yield* Image.stream(request) // Stream<ImageEvent>
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
```
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
portability matrix.
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
@@ -135,40 +147,43 @@ Editing is not a separate function; `images`/`mask` on the request select the ed
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
```ts
const request = Video.request({
model: google.video("veo-3.1-generate-preview"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
frames: { first: Media.file("./start.png"), last: Media.file("./end.png") },
references: [Media.file("./style.png")],
video: Media.bytes(previous, "video/mp4"), // edit / extend source
durationSeconds: 8,
aspectRatio: "16:9",
resolution: "1080p",
audio: true,
n: 1,
seed: 7,
negativePrompt: "text, watermark", // common, not provider-native
providerOptions: { personGeneration: "allow_adult" },
Effect.gen(function* () {
const request = Video.request({
model: google.video("veo-3.1-generate-preview"),
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
references: [yield* Media.file("./style.png")],
video: Media.bytes(previous, "video/mp4"), // edit / extend source
durationSeconds: 8,
aspectRatio: "16:9",
resolution: "1080p",
audio: true,
n: 1,
seed: 7,
negativePrompt: "text, watermark", // common, not provider-native
providerOptions: { personGeneration: "allow_adult" },
})
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
// Explicit generation control.
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
const resumed = yield* Video.resume(model, JSON.parse(saved))
// Progress as a stream.
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
})
// Simple: wait for it.
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
response.usage // credits on Runway; the other three report none
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
yield* response.video.materialize() // pull bytes before the URL expires
// Explicit generation control.
const generation = yield* Video.start(request) // Generation<VideoResponse>
generation.id; generation.status; generation.progress; generation.position; generation.token
yield* generation.await({ poll }) // VideoResponse
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
// Resume from another process. The token is validated against the route's codec and refreshed once.
const resumed = yield* Video.resume(model, JSON.parse(saved))
// Progress as a stream.
yield* Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
```
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
@@ -330,12 +345,12 @@ class Generation<Response> {
readonly expiresAt?: number
refresh(): Effect<Generation<Response>, AIError>
result(): Effect<Response, AIError>
await(options?: AwaitOptions): Effect<Response, AIError>
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
cancel(): Effect<void, AIError>
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
}
AwaitOptions = { poll?: Poll }
GenerationAwaitOptions = { poll?: Poll }
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
```
@@ -365,15 +380,17 @@ const ai = AI.make() // ManagedRuntime over Reque
// AI.make({ layer }) to inject a custom executor / recorder / middleware
const image = await ai.image.generate({ model, prompt })
await image.image.bytes()
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
const reference = await ai.file("./ref.png")
for await (const event of ai.speech.stream({ model, text, voice })) { … }
const generation = await ai.video.start({ model, prompt })
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = ai.video.resume(model, JSON.parse(saved))
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const text = await ai.llm.generate({ model, prompt }) // closes today's gap: LLM has no promise API either
const text = await ai.llm.generate({ model, prompt })
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
@@ -401,9 +418,9 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
| `Runway` | | | ✓ | | | |
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
New facades follow the existing one-file-per-provider rule. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
`ImageModel<Options>` already gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, `TranscriptionModel` follow the same generic. A shared `MediaModel` union is what `Generation` and the promise client key on.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
### Routes and protocols
+7 -20
View File
@@ -1,18 +1,7 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { Config, Effect, Formatter, Schema, Stream } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import {
Image,
ImageClient,
LLM,
LLMClient,
LLMRequest,
Media,
Message,
ProviderID,
Tool,
ToolRuntime,
} from "@opencode/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
import { Route, Auth, Endpoint, Framing, Protocol } from "@opencode/ai/route"
import { OpenAI } from "@opencode/ai/providers"
/**
@@ -243,12 +232,10 @@ const generateImage = Effect.gen(function* () {
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
})
// Provide the LLM runtime and the HTTP request executor once. Keep one path
// enabled at a time so the tutorial can demonstrate generate, stream, or
// Provide every modality client and the HTTP request executor once with
// `AIClient.layer` (`AIClient.layerWith(executor)` swaps the executor). Keep one
// path enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const imageClientLayer = ImageClient.layer.pipe(Layer.provide(requestExecutorLayer))
const program = Effect.gen(function* () {
// yield* generateOnce
@@ -257,6 +244,6 @@ const program = Effect.gen(function* () {
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
// yield* generateImage
yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer, imageClientLayer)))
}).pipe(Effect.provide(AIClient.layer))
Effect.runPromise(program)
+24
View File
@@ -0,0 +1,24 @@
import { Layer } from "effect"
import { ImageClient } from "./image-client.js"
import { LLMClient } from "./route/client.js"
import { RequestExecutor } from "./route/executor.js"
import { SpeechClient } from "./speech-client.js"
import { TranscriptionClient } from "./transcription-client.js"
import { VideoClient } from "./video-client.js"
/** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
export const layerWith = <E, R>(executor: Layer.Layer<RequestExecutor.Service, E, R>) =>
Layer.mergeAll(
LLMClient.layer,
ImageClient.layer,
VideoClient.layer,
SpeechClient.layer,
TranscriptionClient.layer,
).pipe(Layer.provideMerge(executor))
/** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
export const layer = layerWith(RequestExecutor.fetchLayer)
export type Services = Layer.Success<typeof layer>
export * as AIClient from "./ai-client.js"
@@ -86,7 +86,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
})
}),
)
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
export const EvaluationClient = {
Service,
+3 -1
View File
@@ -30,7 +30,9 @@ export interface Interface {
) => Effect.Effect<Generation<ImageResponse>, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
export const Service = ImageClientService
export type Service = ImageClientService
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
+2 -1
View File
@@ -1,3 +1,4 @@
export { AIClient } from "./ai-client.js"
export { LLMClient } from "./route/client.js"
export { ImageClient } from "./image-client.js"
export { Auth } from "./route/auth.js"
@@ -8,7 +9,7 @@ export type {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
Interface as LLMClientShape,
Service as LLMClientService,
LLMClientService,
} from "./route/client.js"
export * from "./schema/index.js"
export {
+2 -2
View File
@@ -6,7 +6,7 @@ import { ProviderID } from "./schema/ids.js"
import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js"
import { ProviderMetadata } from "./schema/options.js"
import { Service } from "./route/executor-service.js"
import { detectMediaType, extensionMediaType } from "./utils/media-type.js"
import { detectMediaType, fileMediaType } from "./utils/media-type.js"
export { detectMediaType } from "./utils/media-type.js"
@@ -309,7 +309,7 @@ export const file = (path: string, options?: AssetOptions): Effect.Effect<Asset,
const data = yield* fs
.readFile(path)
.pipe(Effect.mapError((cause) => invalid(`Failed to read media file ${path}`, cause)))
return bytes(data, detectMediaType(data) ?? extensionMediaType(path), options)
return bytes(data, fileMediaType(data, path), options)
})
/** Materialize an asset and write its bytes through `FileSystem`. */
+64 -30
View File
@@ -1,14 +1,14 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
import { AIClient } from "./ai-client.js"
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
import { ImageClient } from "./image-client.js"
import { LLM } from "./index.js"
import { LLMClient } from "./route/client.js"
import { Media } from "./media.js"
import { tryRequest } from "./media-model.js"
import { RequestExecutor } from "./route/executor.js"
import { LanguageModel, LLMRequest } from "./schema/index.js"
import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js"
import type { RequestInput } from "./llm.js"
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
import { SpeechClient } from "./speech-client.js"
import {
Transcription,
TranscriptionModel,
@@ -16,17 +16,16 @@ import {
type TranscriptionOptions,
type TranscriptionRequestInput,
} from "./transcription.js"
import { TranscriptionClient } from "./transcription-client.js"
import { fileMediaType } from "./utils/media-type.js"
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
import { VideoClient } from "./video-client.js"
/**
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
* and transcription clients over a request executor; every method runs the corresponding Effect API and rethrows
* `AIError` unchanged.
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
*/
export interface Options {
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
readonly layer?: Layer.Layer<RequestExecutor.Service>
}
@@ -34,19 +33,20 @@ export interface RunOptions {
readonly signal?: AbortSignal
}
export type Services =
| Layer.Success<typeof LLMClient.layer>
| Layer.Success<typeof ImageClient.layer>
| Layer.Success<typeof VideoClient.layer>
| Layer.Success<typeof SpeechClient.layer>
| Layer.Success<typeof TranscriptionClient.layer>
| RequestExecutor.Service
export type Services = AIClient.Services
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
/**
* Promise view of a `Generation`. Its fields are a snapshot taken when the handle was created; `refresh()` resolves to a
* new handle rather than updating this one.
*/
export type GenerationHandle<Response> = Snapshot & {
/** Serializable JSON; pass it back to `resume` from another process. */
readonly token: unknown
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
/** Status observations until the first terminal one, polling like `await`; abort ends iteration without throwing. */
readonly events: (options?: AwaitOptions & RunOptions) => AsyncIterable<Event>
/** The result without polling; fails when the generation has not completed. */
readonly result: (options?: RunOptions) => Promise<Response>
readonly refresh: (options?: RunOptions) => Promise<GenerationHandle<Response>>
readonly cancel: (options?: RunOptions) => Promise<void>
}
@@ -65,17 +65,9 @@ const abortEffect = (signal: AbortSignal | undefined) =>
})
export const make = (options: Options = {}) => {
const runtime = ManagedRuntime.make(
Layer.mergeAll(
LLMClient.layer,
ImageClient.layer,
VideoClient.layer,
SpeechClient.layer,
TranscriptionClient.layer,
).pipe(Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer)),
)
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
/** Run any package Effect (for example `asset.bytes()`) inside this runtime. */
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
runtime.runPromise(effect, { signal: options?.signal })
@@ -95,12 +87,15 @@ export const make = (options: Options = {}) => {
...generation.snapshot,
token: generation.token,
await: (options) => run(generation.await({ poll: options?.poll }), options),
events: (options) => iterate(generation.events({ poll: options?.poll }), options),
result: (options) => run(generation.result(), options),
refresh: (options) => run(generation.refresh(), options).then(handle),
cancel: (options) => run(generation.cancel(), options),
})
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
const llmRequest = (input: RequestInput | LLMRequest) => (input instanceof LLMRequest ? input : LLM.request(input))
const llmRequest = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
input instanceof ImageRequest ? input : Image.request(input)
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
@@ -112,12 +107,48 @@ export const make = (options: Options = {}) => {
return {
run,
/** Decoded asset bytes, downloading `url` sources through the executor. */
bytes: (asset: Media.Asset, options?: RunOptions) => run(asset.bytes(), options),
base64: (asset: Media.Asset, options?: RunOptions) => run(asset.base64(), options),
/** Pull a `url` asset into owned bytes before the provider URL expires. */
materialize: (asset: Media.Asset, options?: RunOptions) => run(asset.materialize(), options),
/** Read a file into an asset like `Media.file`: sniffed media type, then the extension's. */
file: async (path: string, options?: Media.AssetOptions & RunOptions) => {
const { readFile } = await import("node:fs/promises")
return run(
Effect.tryPromise({
try: (signal) => readFile(path, { signal }),
catch: (cause) => fileError(`Failed to read media file ${path}`, cause),
}).pipe(
Effect.map((buffer) => {
const data = new Uint8Array(buffer)
return Media.bytes(data, fileMediaType(data, path), options)
}),
),
options,
)
},
/** Write an asset's bytes like `Media.write`, downloading `url` sources through the executor. */
write: async (asset: Media.Asset, path: string, options?: RunOptions) => {
const { writeFile } = await import("node:fs/promises")
return run(
asset.bytes().pipe(
Effect.flatMap((data) =>
Effect.tryPromise({
try: (signal) => writeFile(path, data, { signal }),
catch: (cause) => fileError(`Failed to write media file ${path}`, cause),
}),
),
),
options,
)
},
llm: {
request: LLM.request,
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
run(LLM.generate(llmRequest(input)), options),
run(Effect.flatMap(llmRequest(input), LLM.generate), options),
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
iterate(LLM.stream(llmRequest(input)), options),
iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options),
},
image: {
request: Image.request,
@@ -186,6 +217,9 @@ export const make = (options: Options = {}) => {
export type Client = ReturnType<typeof make>
const fileError = (message: string, cause: unknown) =>
new AIError({ reason: new InvalidRequestError({ message, cause }) })
/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */
export const ai = make()
+5 -1
View File
@@ -18,7 +18,11 @@ const PCM_SAMPLE_RATE = 24000
// 1. Public model input
// ---------------------------------------------------------------------------
export type OpenAISpeechOptions = Record<string, unknown>
/** `voice`, `instructions`, `speed`, and `format` are common request fields; other native body fields pass through. */
export type OpenAISpeechOptions = {
/** Defaults to `"sse"` in `stream` mode on models that support it; the merged value selects the response framing. */
readonly stream_format?: "sse" | "audio"
} & Record<string, unknown>
export type Request = SpeechRequestFor<OpenAISpeechOptions>
+2 -1
View File
@@ -211,7 +211,8 @@ export const mediaReference = (
if (provider !== undefined && asset.source.type === "ref" && asset.source.provider === provider)
return Effect.succeed({ type: "ref", value: asset.source.id })
const accepted = provider === undefined ? "" : `, and ${provider} references`
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
const got = asset.source.type === "ref" ? `; got ${asset.source.provider}:${asset.source.id}` : ""
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}${got}`))
}
/**
+19 -14
View File
@@ -1,12 +1,15 @@
import { Config, Effect, Redacted } from "effect"
import { Config, Effect, Option, Redacted } from "effect"
import { Headers } from "effect/unstable/http"
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
import { AuthenticationError, AIError, type HttpOptions } from "../schema/index.js"
export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError"
constructor(readonly source: string) {
super(`Missing auth credential: ${source}`)
constructor(
readonly source: string,
message = `Missing auth credential: ${source}`,
) {
super(message)
}
}
@@ -89,7 +92,14 @@ export const optional = (secret: Secret | undefined, source = "optional value")
? credential(Effect.fail(new MissingCredentialError(source)))
: credentialFromSecret(secret, source)
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
export const config = (name: string) =>
credential(
Effect.gen(function* () {
const secret = yield* Config.option(Config.redacted(name))
if (Option.isSome(secret) && Redacted.value(secret.value) !== "") return secret.value
return yield* Effect.fail(new MissingCredentialError(name, `${name} is not set`))
}),
)
export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
@@ -145,15 +155,10 @@ export function scheme(name: string, source?: Secret | Credential) {
}
const toAIError = (error: AuthError): AIError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new AIError({
reason:
error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
})
}
return error
if (error instanceof AIError) return error
const message =
error instanceof MissingCredentialError ? error.message : `Failed to resolve auth config: ${error.message}`
return new AIError({ reason: new AuthenticationError({ message, cause: error }) })
}
export const toEffect =
+3 -1
View File
@@ -261,7 +261,9 @@ const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefine
})
}
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
export class LLMClientService extends Context.Service<LLMClientService, Interface>()("@opencode/LLMClient") {}
export const Service = LLMClientService
export type Service = LLMClientService
const resolveRequestOptions = (request: LLMRequest) => {
const messages = normalizeToolHistory(request.messages)
+5 -1
View File
@@ -19,4 +19,8 @@ export type HttpMiddleware = (
handler: HttpHandler,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
export class RequestExecutorService extends Context.Service<RequestExecutorService, Interface>()(
"@opencode/AI/RequestExecutor",
) {}
export const Service = RequestExecutorService
export type Service = RequestExecutorService
+16
View File
@@ -255,4 +255,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
/** Run `fn` on every request: it sees the raw response before status classification, inside middleware already on `executor`, and outside per-call middleware. */
export const middleware = (fn: HttpMiddleware, executor: Layer.Layer<Service> = fetchLayer): Layer.Layer<Service> =>
Layer.effect(
Service,
Effect.gen(function* () {
const inner = yield* Service
return Service.of({
execute: (request, next) =>
inner.execute(
request,
next === undefined ? fn : (input, handler) => fn(input, (forwarded) => next(forwarded, handler)),
),
})
}),
).pipe(Layer.provide(executor))
export * as RequestExecutor from "./executor.js"
+1 -1
View File
@@ -7,7 +7,7 @@ export type {
RouteDefaultsInput,
AnyRoute,
Interface as LLMClientShape,
Service as LLMClientService,
LLMClientService,
StreamOptions,
CompactMethod,
CompactionOperations,
+3 -1
View File
@@ -12,7 +12,9 @@ export interface Interface {
) => Stream.Stream<SpeechEvent, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SpeechClient") {}
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
export const Service = SpeechClientService
export type Service = SpeechClientService
export const generate = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
+5 -1
View File
@@ -30,7 +30,11 @@ export interface Interface {
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/TranscriptionClient") {}
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
"@opencode/TranscriptionClient",
) {}
export const Service = TranscriptionClientService
export type Service = TranscriptionClientService
export const generate = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
+4 -1
View File
@@ -48,9 +48,12 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
csv: "text/csv",
}
export const extensionMediaType = (path: string): string | undefined =>
const extensionMediaType = (path: string): string | undefined =>
EXTENSIONS[path.slice(path.lastIndexOf(".") + 1).toLowerCase()]
/** Media type of a file's contents: sniffed magic bytes, then the path's extension. */
export const fileMediaType = (bytes: Uint8Array, path: string) => detectMediaType(bytes) ?? extensionMediaType(path)
const EXTENSION_ALIASES: Readonly<Record<string, string>> = {
"audio/mp3": "mp3",
"audio/m4a": "m4a",
+3 -1
View File
@@ -29,7 +29,9 @@ export interface Interface {
) => Stream.Stream<VideoEvent, AIError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/VideoClient") {}
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
export const Service = VideoClientService
export type Service = VideoClientService
export const start = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
+9
View File
@@ -92,6 +92,15 @@ describe("Auth", () => {
}),
)
it.effect("reports a missing config credential as Authentication naming the variable", () =>
Effect.gen(function* () {
const error = yield* Auth.toEffect(Auth.config("OPENAI_API_KEY").bearer())(input).pipe(withEnv({}), Effect.flip)
expect(error.reason._tag).toBe("Authentication")
expect(error.message).toContain("OPENAI_API_KEY is not set")
}),
)
it.effect("can intentionally leave auth untouched", () =>
Effect.gen(function* () {
const headers = yield* Auth.none.apply(input)
+22 -2
View File
@@ -1,11 +1,11 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import { route } from "../src/protocols/openai-chat.js"
import { configure } from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { dynamicResponse, fixedResponse, handlerLayer, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js"
@@ -195,6 +195,26 @@ describe("RequestExecutor", () => {
),
)
it.effect("runs shared middleware outside per-call middleware", () => {
const calls: Array<string> = []
const record = (name: string) => Effect.sync(() => calls.push(name))
const base = RequestExecutor.layer.pipe(
Layer.provide(handlerLayer((input) => record("handler").pipe(Effect.as(input.respond("ok"))))),
)
return Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
yield* executor.execute(request, (input, next) => record("per-call").pipe(Effect.andThen(next(input))))
expect(calls).toEqual(["outer", "per-call", "handler"])
calls.length = 0
yield* executor.execute(request)
expect(calls).toEqual(["outer", "handler"])
}).pipe(
Effect.provide(
RequestExecutor.middleware((input, next) => record("outer").pipe(Effect.andThen(next(input))), base),
),
)
})
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+27 -1
View File
@@ -1,8 +1,11 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import {
AIClient,
AIError,
Generation,
Image,
ImageClient,
LanguageModel,
LLM,
LLMClient,
@@ -11,10 +14,11 @@ import {
Speech,
SpeechClient,
SpeechEvent,
TranscriptionClient,
Video,
VideoClient,
} from "@opencode/ai"
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
import { Route, Protocol, RequestExecutor, WebSocketTransport } from "@opencode/ai/route"
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
import {
AssemblyAI,
@@ -76,6 +80,28 @@ describe("public exports", () => {
expect(EvaluationClient.fetchLayer).toBeDefined()
})
test("AIClient.layerWith shares one executor across every client", async () => {
let built = 0
const counting = Layer.effect(
RequestExecutor.Service,
Effect.sync(() => {
built++
return RequestExecutor.Service.of({ execute: () => Effect.die("unexpected request") })
}),
)
await Effect.runPromise(
Effect.gen(function* () {
yield* LLMClient.Service
yield* ImageClient.Service
yield* VideoClient.Service
yield* SpeechClient.Service
yield* TranscriptionClient.Service
yield* RequestExecutor.Service
}).pipe(Effect.provide(AIClient.layerWith(counting))),
)
expect(built).toBe(1)
})
test("route barrel exposes route-authoring APIs", () => {
expect(Route.make).toBeFunction()
expect(Protocol.make).toBeFunction()
+39
View File
@@ -1,4 +1,7 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/index.js"
@@ -182,6 +185,36 @@ describe("AI promise client", () => {
await ai.dispose()
})
test("observes a started generation's events and fetches its result", async () => {
const ai = AI.make({ layer: executor([]) })
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
const generation = await ai.video.start({ model, prompt: "A kite" })
const events: Array<string> = []
for await (const event of generation.events({ poll: { interval: 10 } })) events.push(event.type)
expect(events).toEqual(["generation-progress", "generation-finished"])
expect(generation.status).toBe("queued")
expect((await generation.result()).video.source).toMatchObject({ url: "https://runway.test/out.mp4" })
await ai.dispose()
})
test("reads, writes, and decodes assets without leaving promises", async () => {
const ai = AI.make({ layer: executor([]) })
const dir = await mkdtemp(join(tmpdir(), "ai-promise-"))
try {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
await Bun.write(join(dir, "source.bin"), png)
const asset = await ai.file(join(dir, "source.bin"))
expect(asset.mediaType).toBe("image/png")
await ai.write(asset, join(dir, "copy.png"))
expect(await ai.bytes(await ai.file(join(dir, "copy.png")))).toEqual(png)
} finally {
await rm(dir, { recursive: true })
await ai.dispose()
}
})
test("generates, streams, and starts transcriptions over the same runtime", async () => {
const ai = AI.make({ layer: executor([]) })
const audio = Media.url("https://audio.test/hello.mp3")
@@ -233,6 +266,12 @@ describe("AI promise client", () => {
expect(failure).toBeInstanceOf(AIError)
expect(failure instanceof AIError && failure.reason.http?.status).toBe(404)
const invalid = await ai.llm
// @ts-expect-error Invalid input must reject with AIError, not throw synchronously.
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
.catch((error: unknown) => error)
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
const controller = new AbortController()
controller.abort()
const aborted = await ai.llm
+1
View File
@@ -533,6 +533,7 @@ describe("Video / fal", () => {
"InvalidRequest",
])
expect(errors[1].message).toContain("end_image_url")
expect(errors[4].message).toContain("; got fal:handle")
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
)
})
+5 -4
View File
@@ -94,9 +94,10 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
from storage. Desktop provides the local server it discovers or starts through native initialization.
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
With no configured servers, or when the only server rejects the saved credentials, the app shows a full-screen
connection form. Enter a server address and password, paste a link from `opencode pair`, or choose
**Scan QR code** to read its QR code. Pairing links are single-use; the app exchanges them for a session token
and immediately attempts to connect. Failed connections leave the details available
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
@@ -106,7 +107,7 @@ When the service is exposed through an HTTPS reverse proxy, advertise its extern
opencode pair --url https://opencode.example.com
```
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
This replaces the addresses in the printed links and QR code.
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
@@ -1,54 +0,0 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test"
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
@@ -14,7 +14,12 @@ type InboxRow = {
sessionID: string
time: { created: number }
type: "user"
payload: { text: string; metadata?: Record<string, unknown> }
payload: {
text: string
metadata?: Record<string, unknown>
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
}
delivery: "steer" | "queue"
}
@@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
}))
const events: OpenCodeEvent[] = []
const prompts: Record<string, unknown>[] = []
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
const log: string[] = []
let sequence = 0
const emit = <Type extends OpenCodeEvent["type"]>(
@@ -234,6 +239,113 @@ test("editing restores the existing draft and replaces only the original queue p
expect(mock.log[0]).toBe("prompt:queue")
})
test("Move Back cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(3)
const row = view.rows.filter({ hasText: "second queued prompt" })
const actions = row.locator('[data-slot="session-queue-actions"] button')
await expect(actions).toHaveCount(3)
expect(
await actions.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
),
).toEqual(["Steer", "Move Back", "Remove"])
const moveBack = row.getByRole("button", { name: "Move Back" })
await expect(moveBack).toHaveText("")
await expect(moveBack.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-undo-down")
await moveBack.hover()
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
await moveBack.click()
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
"first queued prompt",
"third queued prompt",
])
await expect(view.input).toHaveText("second queued prompt")
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
expect(mock.prompts).toEqual([])
})
test("Move Back preserves an existing draft and restores inline attachments", async ({ page }) => {
const mock = createQueueMock(["queued with image"])
mock.rows[0].payload.files = [
{
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
mime: "image/png",
source: { type: "inline" },
name: "shot.png",
},
]
const view = await openSession(page, mock)
await view.input.fill("my draft")
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.input).toHaveText("my draft")
expect(mock.changes).toEqual([])
await view.input.fill("")
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.rows).toHaveCount(0)
await expect(view.input).toHaveText("queued with image")
await expect(view.input).toBeFocused()
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Move Back stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 })
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
const view = await openSession(page, mock)
await expect(view.rows).toHaveCount(7)
const row = view.rows.filter({ hasText: text })
await row.getByRole("button", { name: "Move Back" }).hover()
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
await page.screenshot({ path: testInfo.outputPath("move-back-narrow-queue.png") })
await row.getByRole("button", { name: "Move Back" }).click()
await expect(view.rows).toHaveCount(6)
await expect(view.input).toHaveText(text)
await expect(view.input).toBeFocused()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
})
test("Move Back preserves mentioned file and agent references on resubmission", async ({ page }) => {
const mock = createQueueMock(["inspect @main.ts with @build"])
mock.rows[0].payload.files = [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri", uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
]
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(view.input).toHaveText("inspect @main.ts with @build")
await view.input.press("Enter")
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.prompts[0].files).toMatchObject([
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
])
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
})
test("Move Back does not discard hidden file context", async ({ page }) => {
const mock = createQueueMock(["inspect this file"])
mock.rows[0].payload.files = [
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
]
const view = await openSession(page, mock)
await view.rows.getByRole("button", { name: "Move Back" }).click()
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
await expect(view.rows).toHaveCount(1)
await expect(view.input).toHaveText("")
expect(mock.changes).toEqual([])
})
for (const delivery of ["steer", "queue"] as const) {
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
page,
+16 -24
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,29 +107,21 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
return (
<ServersProvider
+1
View File
@@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue"
// is loaded in the editor.
export type ComposerQueue = {
count: Accessor<number>
movingBack: Accessor<boolean>
// Delivery a plain submit uses right now.
delivery: Accessor<ComposerDelivery>
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
@@ -168,6 +168,7 @@ function ComposerStory(props: {
alternate: () => props.alternate,
editing: () => undefined,
confirmEdit() {},
movingBack: () => false,
cancelEdit() {},
editFirst: () => false,
}
+2
View File
@@ -16,6 +16,7 @@ export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
readOnly?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
@@ -27,6 +28,7 @@ export function Composer(props: {
<ComposerEditor
controller={props.model}
borderUnderlay={props.borderUnderlay}
readOnly={props.readOnly}
class={props.class}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
+1
View File
@@ -371,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
if (queue?.movingBack()) return
// Confirming an edit re-admits the queued prompt instead of sending
// the composer value as a new prompt. Enter keeps it queued in
// place; the alternate action sends it as a steer.
+4
View File
@@ -496,6 +496,7 @@ export const dict = {
"server.connect.scan": "Scan QR code",
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
"server.connect.camera": "Pairing camera",
"server.connect.camera.starting": "Opening camera…",
"server.connect.mixedContent":
@@ -873,6 +874,9 @@ export const dict = {
"session.queue.send": "Send",
"session.queue.steerTooltip": "Send without interrupting",
"session.queue.remove": "Remove",
"session.queue.moveBack": "Move Back",
"session.queue.moveBackDraft": "Clear your draft before moving a prompt back",
"session.queue.moveBackUnavailable": "Edit this prompt in the queue to preserve its file context",
"session.queue.reorder": "Reorder queued prompt",
"session.queue.attachments.one": "{{count}} attachment",
"session.queue.attachments.other": "{{count}} attachments",
@@ -24,8 +24,6 @@ type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
@@ -141,6 +139,8 @@ type PlatformBase = {
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
/** Single-use code for an `/auth/connect/:code` link. */
code(): Promise<string>
}
}
@@ -29,6 +29,17 @@ describe("checkServerHealth", () => {
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
})
test("reports rejected credentials without retrying", async () => {
let calls = 0
const fetch = (async () => {
calls++
return Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 })
}) as unknown as typeof globalThis.fetch
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: false, unauthorized: true })
expect(calls).toBe(1)
})
test("returns healthy response with version", async () => {
let request: URL | undefined
const fetch = (async (input: RequestInfo | URL) => {
+9 -3
View File
@@ -1,11 +1,17 @@
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "@/runtime/server/registry"
import { authTokenFromCredentials } from "./api"
import { ClientError, OpenCode } from "@opencode/client"
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
export type ServerHealth = {
healthy: boolean
version?: string
incompatible?: boolean
checking?: boolean
unauthorized?: boolean
}
interface CheckServerHealthOptions {
timeoutMs?: number
@@ -100,7 +106,7 @@ export async function checkServerHealth(
.catch((error) => ({ error }))
if ("data" in current) return current.data
if (signal?.aborted) return { healthy: false }
if (isUnauthorizedError(current.error)) return { healthy: false, unauthorized: true }
return next(count, current.error)
}
return attempt(0).finally(() => timeout?.clear?.())
+4 -1
View File
@@ -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)
+3 -13
View File
@@ -29,7 +29,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import { decodePairingCode } from "./pairing"
import type { Pairing } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -117,16 +117,10 @@ export const DialogServer: Component<{
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
list="dialog-server-addresses"
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<datalist id="dialog-server-addresses">
{form.state.urls().map((url) => (
<option value={url} />
))}
</datalist>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
@@ -213,7 +207,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", password: "" },
urls: [] as string[],
scanning: false,
error: "",
status: undefined as boolean | undefined,
@@ -227,7 +220,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
@@ -333,11 +325,10 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
setStore("error", "")
request.mutate()
}
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
const pair = (pairing: Pairing) => {
healthPreview.cancel()
setStore({
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
urls: pairing.urls,
values: { ...store.values, url: pairing.url, password: pairing.password },
scanning: false,
error: "",
})
@@ -359,7 +350,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
value: () => store.values.url,
name: () => store.values.name,
password: () => store.values.password,
urls: () => store.urls,
scanning: () => store.scanning,
error: () => store.error,
status: () => store.status,
@@ -1,95 +1,19 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
import { pairingLink } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://opencode.example.com:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(url.search).toBe("")
expect(url.hash).not.toBe("")
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("keeps accepting query pairing data", () => {
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
urls: info.urls,
password: info.password,
describe("pairing link", () => {
test("reads the server address and code from opencode pair links", () => {
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
url: "http://192.168.1.2:49374",
code: "abc_DEF-123",
})
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
test("decodes the raw JSON code", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
})
test("decodes a direct /connect URL", () => {
expect(
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
).toEqual({
urls: ["http://192.168.1.2:49374"],
password: info.password,
})
})
test("keeps accepting /connect URLs with query data", () => {
expect(
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
).toEqual({
urls: info.urls,
password: info.password,
})
})
test("falls back to the URL origin when the payload omits server URLs", () => {
const origin = "https://opencode.example.com:49709"
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
urls: [origin],
password: "secret",
})
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
test("rejects other URLs", () => {
expect(pairingLink("http://192.168.1.2:49374/auth/connect/")).toBeUndefined()
expect(pairingLink("http://192.168.1.2:49374/auth/connect/abc/extra")).toBeUndefined()
expect(pairingLink("http://192.168.1.2:49374/connect#abc")).toBeUndefined()
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
})
})
+17 -45
View File
@@ -1,15 +1,6 @@
import { Option, Schema } from "effect"
import { base64Encode } from "@opencode/util/encode"
import { OpenCode } from "@opencode/client/promise"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.optional(Schema.Array(Schema.String)),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
)
export function serverAddress(value: string) {
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
const normalized = normalizeServerUrl(value)
@@ -20,42 +11,23 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string, origin?: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
}
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
export function decodePairingScan(value: string) {
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
export function pairingLink(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
const address = serverAddress(url.origin)
if (!code || !address) return
return { url: address, code }
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
export type Pairing = { readonly url: string; readonly password: string }
export function redeemPairingLink(link: { url: string; code: string }) {
return OpenCode.make({ baseUrl: link.url })
.server.connect({ code: link.code })
.then(
(session): Pairing => ({ url: link.url, password: session.token }),
() => undefined,
)
}
+8 -8
View File
@@ -3,13 +3,10 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingScan } from "./pairing"
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onCancel: () => void
}) {
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
const language = useLanguage()
const [state, setState] = createStore({ error: "", ready: false })
const video = document.createElement("video")
@@ -22,13 +19,16 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingScan(result.data)
if (!pairing) {
const link = pairingLink(result.data)
if (!link) {
setState("error", language.t("server.connect.scan.invalid"))
return
}
scanner.stop()
props.onScan(pairing)
void redeemPairingLink(link).then((redeemed) => {
if (redeemed) return props.onScan(redeemed)
setState("error", language.t("server.connect.link.expired"))
})
},
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
)
+14 -26
View File
@@ -8,29 +8,20 @@ import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
export function ConnectServerScreen(props: { url?: string } = {}) {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -39,6 +30,16 @@ export function ConnectServerScreen(
)
const request = useMutation(() => ({
mutationFn: async () => {
const link = pairingLink(state.url)
if (link) {
const redeemed = await redeemPairingLink(link)
if (!redeemed) {
setState("error", language.t("server.connect.link.expired"))
return
}
// Keep the token in the form so a failed connection check can retry without the spent code.
setState({ url: link.url, password: redeemed.password })
}
const url = serverAddress(state.url)
if (!url) {
setState("error", language.t("server.connect.address.invalid"))
@@ -51,7 +52,6 @@ export function ConnectServerScreen(
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
@@ -76,13 +76,7 @@ export function ConnectServerScreen(
void camera.refetch()
}}
onScan={(pairing) => {
setState({
url: pairing.urls[0],
urls: pairing.urls,
password: pairing.password,
error: "",
scanning: false,
})
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
request.mutate()
}}
/>
@@ -110,18 +104,12 @@ export function ConnectServerScreen(
spellcheck={false}
required
appearance="large"
list="server-connect-addresses"
placeholder={language.t("dialog.server.add.placeholder")}
value={state.url}
disabled={request.isPending}
aria-describedby={state.error ? "server-connect-error" : undefined}
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
/>
<datalist id="server-connect-addresses">
{state.urls.map((url) => (
<option value={url} />
))}
</datalist>
</div>
<div class="server-connect-field">
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
@@ -175,6 +175,18 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
</Button>
</Tooltip>
<Tooltip placement="top" value={language.t("session.queue.moveBack")}>
<IconButton
data-action="session-queue-move-back"
type="button"
size="small"
variant="ghost-muted"
icon={<Icon name="arrow-undo-down" />}
disabled={props.queue.busy()}
aria-label={language.t("session.queue.moveBack")}
onClick={() => props.queue.moveBack(props.id)}
/>
</Tooltip>
</Show>
<Tooltip placement="top" value={language.t("session.queue.remove")}>
<IconButton
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
import { queuedPromptAttachments, queuedPromptMoveBackDraft, queuedPromptRows } from "./queue"
const queued = [
{
@@ -104,3 +104,43 @@ describe("queuedPromptAttachments", () => {
expect(queuedPromptAttachments(item)).toEqual([])
})
})
describe("queuedPromptMoveBackDraft", () => {
test("keeps full text, structured mentions, and inline images", () => {
const item = {
...queued[0],
payload: {
text: "inspect @main.ts with @build",
files: [
{
data: "aGk=",
mime: "text/plain",
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
name: "main.ts",
mention: { start: 8, end: 16, text: "@main.ts" },
},
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
],
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptMoveBackDraft(item)).toMatchObject([
{ type: "text", content: "inspect " },
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
{ type: "text", content: " with " },
{ type: "agent", content: "@build", name: "build" },
{ type: "image", filename: "shot.png" },
])
})
test("does not drop hidden file context", () => {
const item = {
...queued[0],
payload: {
text: "inspect this",
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
},
} satisfies SessionInboxInfo
expect(queuedPromptMoveBackDraft(item)).toBeUndefined()
})
})
+115 -2
View File
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import { Skill } from "@opencode/schema/skill"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
mutationFn: async (
change:
| { type: "reorder"; inboxIDs: string[] }
| { type: "move-back"; item: QueuedPrompt; prompt: Prompt }
| {
type: "edit"
inboxIDs: string[]
@@ -54,6 +56,13 @@ export function createSessionQueue(input: {
},
) => {
if (change.type === "reorder") return rewrite(change.inboxIDs)
if (change.type === "move-back") {
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
input.draft.mode.set("normal")
input.draft.set(change.prompt, promptLength(change.prompt))
input.restoreFocus(promptLength(change.prompt))
return
}
const replacement = await editedPromptInput(
input.sessionID,
location().directory,
@@ -139,6 +148,25 @@ export function createSessionQueue(input: {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const moveBack = (id: string) => {
if (mutation.isPending || state.editing) return
const item = queued().find((entry) => entry.id === id)
if (!item) return
if (
input.draft.current().some((part) => ("content" in part ? !!part.content.length : true)) ||
input.draft.mode.current() !== "normal" ||
input.draft.retry.current()
) {
showToast({ title: language.t("session.queue.moveBackDraft") })
return
}
const prompt = queuedPromptMoveBackDraft(item)
if (!prompt) {
showToast({ title: language.t("session.queue.moveBackUnavailable") })
return
}
mutation.mutate({ type: "move-back", item, prompt })
}
const reorder = (inboxIDs: string[]) => {
if (mutation.isPending) return Promise.resolve()
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
@@ -226,9 +254,11 @@ export function createSessionQueue(input: {
editFirst,
rows,
busy: () => mutation.isPending,
movingBack: () => mutation.isPending && mutation.variables?.type === "move-back",
working: input.working,
steer,
remove,
moveBack,
edit,
reorder,
}
@@ -239,7 +269,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
// The slice of the queue the panel renders and drives.
export type SessionQueueView = Pick<
SessionQueue,
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "moveBack" | "edit" | "reorder"
>
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
@@ -249,7 +279,8 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
attachments:
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
@@ -287,6 +318,88 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
]
}
// Use the full model-visible text so comment notes and path references remain
// in the draft. Convert mentioned files, agents, and skills back into editor
// parts; a detached draft cannot represent non-mentioned file context.
export function queuedPromptMoveBackDraft(item: QueuedPrompt): Prompt | undefined {
if (
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
item.payload.agents?.some((agent) => !agent.mention) ||
item.payload.skills?.some((skill) => !skill.mention)
)
return
const text = item.payload.text
const references = [
...(item.payload.files ?? []).flatMap((file) =>
file.mention
? [
{
type: "file" as const,
content: file.mention.text,
start: file.mention.start,
end: file.mention.end,
path: file.name ?? file.mention.text.replace(/^@/, ""),
filename: file.name,
mime: file.mime,
url: `data:${file.mime};base64,${file.data}`,
},
]
: [],
),
...(item.payload.agents ?? []).flatMap((agent) =>
agent.mention
? [
{
type: "agent" as const,
content: agent.mention.text,
start: agent.mention.start,
end: agent.mention.end,
name: agent.name,
},
]
: [],
),
...(item.payload.skills ?? []).flatMap((skill) =>
skill.mention
? [
{
type: "skill" as const,
content: skill.mention.text,
start: skill.mention.start,
end: skill.mention.end,
id: Skill.ID.make(skill.id),
name: Skill.Name.make(skill.name),
},
]
: [],
),
].sort((left, right) => left.start - right.start)
if (
references.some(
(part, index) =>
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
)
)
return
const parts: Prompt = references.flatMap((part, index) => {
const start = references[index - 1]?.end ?? 0
return [
...(part.start > start
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
: []),
part,
]
})
const start = references.at(-1)?.end ?? 0
return [
...parts,
...(text.length > start || !parts.length
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
: []),
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
return !file.mention && file.source.type === "inline"
}
+6 -1
View File
@@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
<Composer
model={props.model.composer}
borderUnderlay
readOnly={props.model.queue.movingBack()}
suggestionBoundary={props.suggestionBoundary}
/>
</div>
</div>
}
+19 -13
View File
@@ -8,8 +8,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { usePlatform } from "@/runtime/platform/platform"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -74,8 +73,8 @@ export function SettingsPairing() {
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()!}
code={pair.code}
/>
))
}
@@ -117,17 +116,19 @@ export function SettingsPairing() {
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
const language = useLanguage()
const platform = usePlatform()
// Codes are single-use, so keep replacing the link while the dialog is open.
const code = useQuery(() => ({
queryKey: ["pairing", "code"],
queryFn: props.code,
gcTime: 0,
refetchInterval: 60_000,
}))
const url = createMemo(() => {
if (!props.info) return
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
if (!code.isSuccess) return
return new URL(`/auth/connect/${code.data}`, props.host).href
})
const copy = useMutation(() => ({
mutationFn: async () => {
@@ -153,7 +154,7 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<Show when={url()}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
@@ -176,12 +177,17 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
{new URL(props.host).origin}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={code.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
+33 -47
View File
@@ -1,5 +1,5 @@
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,7 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
@@ -28,7 +27,6 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -36,48 +34,29 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
onMount(() => {
if (!pairing) return
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
navigate("/", { replace: true })
})
return (
<Show when={!pairing}>
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
</Show>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
)
}
@@ -97,8 +76,15 @@ function TargetServerRoute(props: ParentProps) {
function AppLayout(props: ParentProps) {
const servers = useServers()
const global = useGlobal()
// A lone server that rejects our credentials (e.g. the web app before pairing) has nothing else to show.
const signedOut = () => {
const only = servers.list.length === 1 ? servers.list[0] : undefined
if (only?.type !== "http") return
return global.servers.health[ServerConnection.key(only)]?.unauthorized ? only : undefined
}
return (
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<Show when={servers.list.length > 0 && !signedOut()} fallback={<ConnectServerScreen url={signedOut()?.http.url} />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
+2 -1
View File
@@ -18,7 +18,8 @@ export function serviceWorker(directory: string) {
skipWaiting: false,
inlineWorkboxRuntime: true,
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Pairing links must reach the server so it can set the session cookie.
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
globPatterns: ["**/*"],
globIgnores: ["**/*.map", "_headers", "_redirects"],
+2 -2
View File
@@ -495,10 +495,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
},
}),
Spec.make("pair", {
description: "Show server pairing information",
description: "Print one-time links to connect a browser or app",
params: {
url: Flag.string("url").pipe(
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
Flag.mapTryCatch(
(value) => {
const url = new URL(value)
+21 -22
View File
@@ -2,7 +2,6 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -12,34 +11,25 @@ export default Runtime.handler(
Commands.commands.pair,
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const password = yield* ServiceConfig.password()
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const urls = Option.isSome(input.url)
? [input.url.value]
: (yield* Effect.tryPromise(() =>
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
const link = info.urls[0]
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
: undefined
: (yield* Effect.tryPromise(() => client.server.info())).urls
const pairing = yield* Effect.tryPromise(() => client.server.pair())
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
process.stdout.write(
[
"",
` URLs ${info.urls[0] ?? "(none)"}`,
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
...(link
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
"",
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
...(links[0]
? [
"",
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
renderUnicodeCompact(links[0], { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
]
: []),
"",
@@ -47,8 +37,17 @@ export default Runtime.handler(
)
if (Option.isSome(input.url)) return
const hostname = new URL(endpoint.url).hostname
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
const url = new URL(endpoint.url)
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
process.stderr.write(
[
` Over SSH? Forward the port, then open the link on your machine:`,
` ssh -L ${url.port}:${url.hostname}:${url.port} <host>`,
` If port ${url.port} is busy locally, forward another port and use it in the link.`,
"",
" To connect from other devices, run `opencode service set hostname 0.0.0.0`.",
"",
].join(EOL) + EOL,
)
}),
)
+7 -2
View File
@@ -13,8 +13,13 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
// Serve the web shell before API authentication so a signed-out browser gets the app's sign-in screen.
if (
url.pathname === "/api" ||
url.pathname.startsWith("/api/") ||
url.pathname.startsWith("/auth/") ||
url.pathname === "/openapi.json"
)
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
+19 -1
View File
@@ -35,7 +35,7 @@ describe("web UI", () => {
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/settings",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
@@ -68,6 +68,24 @@ describe("web UI", () => {
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
const pairing = yield* Effect.promise(() =>
fetch(new URL("/api/pair", origin), {
method: "POST",
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}).then((response) => response.json() as Promise<{ code: string }>),
)
const redirect = yield* Effect.promise(() =>
fetch(new URL(`/auth/connect/${pairing.code}`, origin), {
redirect: "manual",
headers: { accept: "text/html" },
}),
)
expect(redirect.status).toBe(302)
const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0]
const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } }))
expect(authorized.status).toBe(200)
yield* Effect.promise(() => authorized.arrayBuffer())
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
+9 -16
View File
@@ -47,8 +47,17 @@ export type ServerInfoOutput = {
}
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
export type ServerPairOutput = { readonly code: string; readonly expires_in: number }
export type ServerPairOperation<E = never> = () => Effect.Effect<ServerPairOutput, E>
export type ServerConnectInput = { readonly code: string }
export type ServerConnectOutput = { readonly token: string }
export type ServerConnectOperation<E = never> = (input: ServerConnectInput) => Effect.Effect<ServerConnectOutput, E>
export interface ServerApi<E = never> {
readonly info: ServerInfoOperation<E>
readonly pair: ServerPairOperation<E>
readonly connect: ServerConnectOperation<E>
}
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -226,20 +235,6 @@ export type SessionForkInput = { readonly sessionID: Session.ID; readonly before
export type SessionForkOutput = Session.Info
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
export type SessionSubagentInput = {
readonly sessionID: Session.ID
readonly text: string
readonly description: string
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly fork?: boolean | undefined
readonly resume?: boolean | undefined
}
export type SessionSubagentOutput = Session.Info
export type SessionSubagentOperation<E = never> = (
input: SessionSubagentInput,
) => Effect.Effect<SessionSubagentOutput, E>
export type SessionSwitchAgentInput = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
export type SessionSwitchAgentOutput = void
export type SessionSwitchAgentOperation<E = never> = (
@@ -603,7 +598,6 @@ export type SessionLogOutput =
readonly sessionID: Session.ID
readonly parentID: Session.ID
readonly boundary: Session.ForkBoundary
readonly child?: boolean | undefined
readonly instructions?:
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
| undefined
@@ -1424,7 +1418,6 @@ export interface SessionApi<E = never> {
readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E>
readonly fork: SessionForkOperation<E>
readonly subagent: SessionSubagentOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly update: SessionUpdateOperation<E>
+16 -22
View File
@@ -6,6 +6,9 @@ import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../../contract"
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -36,8 +39,6 @@ import type {
SessionRemoveOutput,
SessionForkInput,
SessionForkOutput,
SessionSubagentInput,
SessionSubagentOutput,
SessionSwitchAgentInput,
SessionSwitchAgentOutput,
SessionSwitchModelInput,
@@ -283,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>()(
@@ -437,24 +450,6 @@ const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: Sessio
),
)
const EndpointSessionSubagent = (raw: RawClient["server.session"]) => (input: SessionSubagentInput) =>
preserveEffect<SessionSubagentOutput>()(
raw["session.subagent"]({
params: { sessionID: input["sessionID"] },
payload: {
text: input["text"],
description: input["description"],
agent: input["agent"],
model: input["model"],
fork: input["fork"],
resume: input["resume"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionSwitchAgent = (raw: RawClient["server.session"]) => (input: SessionSwitchAgentInput) =>
preserveEffect<SessionSwitchAgentOutput>()(
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
@@ -767,7 +762,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
get: EndpointSessionGet(raw),
remove: EndpointSessionRemove(raw),
fork: EndpointSessionFork(raw),
subagent: EndpointSessionSubagent(raw),
switchAgent: EndpointSessionSwitchAgent(raw),
switchModel: EndpointSessionSwitchModel(raw),
update: EndpointSessionUpdate(raw),
+19 -21
View File
@@ -1,5 +1,8 @@
import type {
ServerInfoOutput,
ServerPairOutput,
ServerConnectInput,
ServerConnectOutput,
LocationGetInput,
LocationGetOutput,
LocationReloadOutput,
@@ -30,8 +33,6 @@ import type {
SessionRemoveOutput,
SessionForkInput,
SessionForkOutput,
SessionSubagentInput,
SessionSubagentOutput,
SessionSwitchAgentInput,
SessionSwitchAgentOutput,
SessionSwitchModelInput,
@@ -418,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) =>
@@ -639,25 +656,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
subagent: (input: SessionSubagentInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionSubagentOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/subagent`,
body: {
text: input["text"],
description: input["description"],
agent: input["agent"],
model: input["model"],
fork: input["fork"],
resume: input["resume"],
},
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionSwitchAgentOutput>(
{
+10 -55
View File
@@ -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 }
@@ -1811,7 +1815,6 @@ export type SessionForked = {
sessionID: string
parentID: string
boundary: SessionForkBoundary
child?: boolean
instructions?: { [x: string]: string }
instructionEntries?: InstructionEntrySnapshot
}
@@ -2697,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"]
}
@@ -3939,60 +3948,6 @@ export type SessionForkInput = {
export type SessionForkOutput = { data: SessionInfo }["data"]
export type SessionSubagentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly text: {
readonly text: string
readonly description: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly fork?: boolean | null
readonly resume?: boolean | null
}["text"]
readonly description: {
readonly text: string
readonly description: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly fork?: boolean | null
readonly resume?: boolean | null
}["description"]
readonly agent?: {
readonly text: string
readonly description: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly fork?: boolean | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
readonly text: string
readonly description: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly fork?: boolean | null
readonly resume?: boolean | null
}["model"]
readonly fork?: {
readonly text: string
readonly description: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly fork?: boolean | null
readonly resume?: boolean | null
}["fork"]
readonly resume?: {
readonly text: string
readonly description: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly fork?: boolean | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionSubagentOutput = { data: SessionInfo }["data"]
export type SessionSwitchAgentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: { readonly agent: string }["agent"]
-2
View File
@@ -23,8 +23,6 @@ const Background = Schema.Struct({
childSessionID: SessionSchema.ID,
agent: Schema.String,
description: Schema.String,
/** False admits the completion notice without resuming the parent. */
resume: Schema.optionalKey(Schema.Boolean),
}),
]),
status: Schema.Literals(["running", "completed", "error", "cancelled"]),
File diff suppressed because one or more lines are too long
-1
View File
@@ -542,7 +542,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
}),
move: sessions.move,
synthetic: sessions.synthetic,
subagent: sessions.subagent,
interrupt: (input) =>
sessions
.interrupt(input.sessionID, { resume: input.resume })
-14
View File
@@ -26,7 +26,6 @@ import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { SessionExecution } from "./session/execution.js"
import {
AgentNotFoundError,
AttachmentError,
BusyError,
CompactionConflictError,
@@ -46,8 +45,6 @@ import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { SessionCommand } from "./session/command.js"
import { SessionSubagent } from "./session/subagent.js"
import { SubagentJob } from "./session/subagent-job.js"
import {
SessionMove,
DestinationNotFoundError,
@@ -98,12 +95,9 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
before?: SessionMessage.ID
/** Makes the fork a child of the source session instead of a top-level session. */
child?: boolean
}
export {
AgentNotFoundError,
AttachmentError,
BusyError,
CompactionConflictError,
@@ -193,10 +187,6 @@ export interface Interface {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
/** Starts a background subagent in a child Session and delivers its outcome to the parent when it settles. */
readonly subagent: (
input: SessionSubagent.Input,
) => Effect.Effect<SessionSchema.Info, NotFoundError | AgentNotFoundError>
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
@@ -351,7 +341,6 @@ const layer = Layer.effect(
sessionID,
parentID: parent.id,
boundary: { type: input.before ? "before" : "through", messageID: boundary.id },
...(input.child ? { child: true } : {}),
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
@@ -421,8 +410,6 @@ const layer = Layer.effect(
Effect.provideService(LLMClient.Service, llm),
)
}),
subagent: (input) =>
SessionSubagent.spawn(result, subagents, input).pipe(Effect.provideService(Instance.Service, instances)),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionCommand.execute({ ...input, session }).pipe(
@@ -467,7 +454,6 @@ const layer = Layer.effect(
commit: (sessionID) => sessions.forSession(sessionID).revert.commit(),
},
})
const subagents = yield* SubagentJob.make.pipe(Effect.provideService(Service, result))
return result
}),
+1 -1
View File
@@ -148,7 +148,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.insert(SessionTable)
.values({
id: event.data.sessionID,
parent_id: event.data.child ? event.data.parentID : null,
parent_id: null,
fork_session_id: event.data.parentID,
fork_boundary: event.data.boundary,
project_id: parent.project_id,
@@ -36,7 +36,7 @@ export const deliver = Effect.fnUntraced(function* (
yield* sessions.synthetic({
...(input.notificationID ? { id: input.notificationID } : {}),
sessionID: recovery.parentSessionID,
...((input.resume ?? recovery.resume) === false ? { resume: false } : {}),
...(input.resume === false ? { resume: false } : {}),
description: recovery.description,
text: `<subagent sessionID="${recovery.childSessionID}" state="${input.status}" description="${recovery.description}">\n${text}\n</subagent>`,
metadata: { source: "subagent", childID: recovery.childSessionID, agent: recovery.agent, state: input.status },
+1 -1
View File
@@ -7,7 +7,7 @@ import { SubagentCompletion } from "./subagent-completion.js"
type Recovery = Extract<Job.Recovery, { kind: "subagent" }>
export interface Runner {
interface Runner {
start: (recovery: Recovery) => Effect.Effect<Job.Info>
background: (recovery: Recovery) => Effect.Effect<void>
notify: (recovery: Recovery, startedAt: number) => Effect.Effect<void>
-85
View File
@@ -1,85 +0,0 @@
export * as SessionSubagent from "./subagent.js"
import { Effect } from "effect"
import { Agent } from "../agent.js"
import { Instance } from "../instance/service.js"
import type { Model } from "../model.js"
import { Plugin } from "../plugin/service.js"
import type { Session } from "../session.js"
import { AgentNotFoundError } from "./error.js"
import type { SessionSchema } from "./schema.js"
import type { SubagentJob } from "./subagent-job.js"
const preamble = "You are a subagent spawned by another session."
export type Input = {
readonly sessionID: SessionSchema.ID
readonly text: string
readonly description: string
readonly agent?: Agent.ID
readonly model?: Model.Ref
/** Copies the parent's settled history into the child instead of starting with fresh context. */
readonly fork?: boolean
/** False admits the completion notice to the parent without resuming it. */
readonly resume?: boolean
}
/** Starts a background child Session whose outcome is delivered to the parent when it settles. */
export const spawn = Effect.fn("SessionSubagent.spawn")(function* (
sessions: Session.Interface,
subagents: SubagentJob.Runner,
input: Input,
) {
const instances = yield* Instance.Service
const parent = yield* sessions.get(input.sessionID)
const selected = yield* Plugin.awaitActivation.pipe(
Effect.andThen(Agent.Service),
Effect.flatMap((agents) => agents.select(input.agent ?? parent.agent)),
instances.provide(parent),
)
if (input.agent !== undefined && selected.info === undefined)
return yield* new AgentNotFoundError({ sessionID: parent.id, agent: input.agent })
const create = sessions.create({
parentID: parent.id,
title: input.description,
agent: selected.id,
model: input.model ?? selected.info?.model ?? parent.model,
})
const child = input.fork
? yield* sessions.fork({ sessionID: parent.id, child: true }).pipe(
Effect.tap((forked) => {
// A fork inherits the parent's agent and model; an explicit model wins over the switched agent's model.
const switched = forked.agent !== selected.id
const model = input.model ?? (switched ? selected.info?.model : undefined)
return Effect.all(
[
sessions.rename({ sessionID: forked.id, title: input.description }),
switched ? sessions.switchAgent({ sessionID: forked.id, agent: selected.id }) : Effect.void,
model === undefined ? Effect.void : sessions.switchModel({ sessionID: forked.id, model }),
],
{ discard: true },
)
}),
Effect.catchTag("Session.ForkEmptyError", () => create),
// Only a `before` boundary can be missing.
Effect.catchTag("Session.MessageNotFoundError", Effect.die),
)
: yield* create
// A text-only prompt without an explicit ID cannot fail admission on the Session just created.
yield* sessions
.prompt({ sessionID: child.id, text: [preamble, input.text].join("\n"), resume: false })
.pipe(Effect.orDie)
const recovery = {
kind: "subagent" as const,
parentSessionID: parent.id,
childSessionID: child.id,
agent: selected.id,
description: input.description,
...(input.resume === false ? { resume: false } : {}),
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
return yield* sessions.get(child.id)
})
-1
View File
@@ -173,7 +173,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
update: overrides.session?.update ?? (() => Effect.die("unused session.update")),
move: overrides.session?.move ?? (() => Effect.die("unused session.move")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
subagent: overrides.session?.subagent ?? (() => Effect.die("unused session.subagent")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
context: overrides.session?.context ?? (() => Effect.die("unused session.context")),
-149
View File
@@ -1,149 +0,0 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
import { TestLLM } from "@opencode/ai/testing"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode/core/effect/app-node-platform"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Docs updated", "docs") })
const it = testEffect(
Layer.merge(
llmLayer,
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
offlineModels,
Watcher.node.replace(Watcher.configured({ enabled: false })),
LayerNodePlatform.llmClient.replace(llmLayer),
SessionRunnerModel.node.replace(
Layer.succeed(SessionRunnerModel.Service, {
resolve: (session) =>
Effect.succeed(
SessionRunnerModel.resolved(
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
{
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 200_000, output: 32_000 },
},
),
),
}),
),
]),
),
)
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
describe("session subagents", () => {
it.live("forks settled history into a child and admits its outcome without resuming the parent", () =>
Effect.gen(function* () {
const parent = yield* project()
const sessions = yield* Session.Service
const llm = yield* TestLLM.Test
yield* sessions.prompt({ sessionID: parent.id, text: "Earlier question" })
yield* sessions.wait(parent.id)
const gate = yield* llm.gate()
// This must return while the child's model is still blocked.
const child = yield* sessions.subagent({
sessionID: parent.id,
text: "Update the docs",
description: "Update docs",
fork: true,
resume: false,
})
yield* gate.started
expect(child).toMatchObject({
parentID: parent.id,
fork: { sessionID: parent.id },
title: "Update docs",
agent: "build",
model: parentModel,
})
expect((yield* sessions.list({ parentID: parent.id })).data.map((session) => session.id)).toEqual([child.id])
expect(
(yield* sessions.context(child.id)).flatMap((message) => (message.type === "user" ? [message.text] : [])),
).toEqual(["Earlier question", "You are a subagent spawned by another session.\nUpdate the docs"])
yield* gate.release
const notice = yield* sessions.log({ sessionID: parent.id, follow: true }).pipe(
Stream.filter(
(event) =>
!Bus.isSynced(event) && event.type === "session.inbox.enqueued" && event.data.item.type === "synthetic",
),
Stream.runHead,
)
expect(notice).toMatchObject({
_tag: "Some",
value: { data: { item: { metadata: { source: "subagent", childID: child.id, state: "completed" } } } },
})
expect(yield* sessions.inbox(parent.id)).toMatchObject([{ type: "synthetic" }])
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")).toEqual([])
expect(yield* llm.requests()).toHaveLength(2)
}),
)
it.live("creates a fresh child with the requested agent when there is no history to fork", () =>
Effect.gen(function* () {
const parent = yield* project()
const sessions = yield* Session.Service
const llm = yield* TestLLM.Test
const gate = yield* llm.gate()
const child = yield* sessions.subagent({
sessionID: parent.id,
text: "Review the changes",
description: "Review changes",
agent: Agent.ID.make("reviewer"),
fork: true,
})
yield* gate.started
expect(child).toMatchObject({ parentID: parent.id, agent: "reviewer", model: { id: "child" } })
expect(child.fork).toBeUndefined()
yield* gate.release
expect(
yield* sessions
.subagent({ sessionID: parent.id, text: "x", description: "x", agent: Agent.ID.make("missing") })
.pipe(Effect.flip),
).toBeInstanceOf(Session.AgentNotFoundError)
}),
)
})
function project() {
return Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "opencode.json"),
JSON.stringify({ agents: { reviewer: { mode: "subagent", model: "test/child" } } }),
),
)
const sessions = yield* Session.Service
return yield* sessions.create({
location: { directory: AbsolutePath.make(tmp.path) },
title: "Parent session",
agent: Agent.ID.make("build"),
model: parentModel,
})
})
}
@@ -76,6 +76,7 @@ export const appHandlers = AppRpcs.toLayer(
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
AppPairInfo: () => pair(pairing.info),
AppPairCode: () => pair(pairing.code),
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
AppSetKeepScreenActive: ({ enabled }) =>
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
+6 -9
View File
@@ -1,21 +1,18 @@
import { SidecarCredentials } from "./sidecar-credentials"
export function createPairing() {
const requireCredentials = () => {
const client = async () => {
const credentials = SidecarCredentials.get()
if (!credentials) throw new Error("The local desktop server is not ready")
return credentials
}
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
const { OpenCode } = await import("@opencode/client/promise")
const info = await OpenCode.make({
return OpenCode.make({
baseUrl: credentials.url,
headers: credentials.password
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
: undefined,
}).server.info()
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
})
}
const info = () => readInfo(requireCredentials())
return { info }
const info = async () => ({ urls: (await (await client()).server.info()).urls })
const code = async () => (await (await client()).server.pair()).code
return { info, code }
}
@@ -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>
}
+1
View File
@@ -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(),
},
}
}
+2 -2
View File
@@ -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
View File
@@ -160,7 +160,6 @@ export type SessionDomain = Pick<
| "generate"
| "command"
| "synthetic"
| "subagent"
| "interrupt"
| "update"
| "move"
-1
View File
@@ -586,7 +586,6 @@ export function fromPromise(plugin: Plugin) {
generate: adaptApiMethod(SessionEndpoints["session.generate"], host.session.generate),
command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
subagent: adaptApiMethod(SessionEndpoints["session.subagent"], host.session.subagent),
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
update: adaptApiMethod(SessionEndpoints["session.update"], host.session.update),
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
-1
View File
@@ -160,7 +160,6 @@ export type SessionDomain = Pick<
| "generate"
| "command"
| "synthetic"
| "subagent"
| "interrupt"
| "update"
| "move"
+10
View File
@@ -499,6 +499,16 @@ export interface UI {
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
close(sessionID?: string): boolean
}
readonly model: {
/** The prompt's selected model; variant is undefined for the model default. Reactive when read in a Solid computation. */
current(): { readonly providerID: string; readonly modelID: string; readonly variant?: string } | undefined
readonly variant: {
/** Variant IDs of the selected model. Reactive when read in a Solid computation. */
list(): readonly string[]
/** Selects a variant of the selected model, or the model default when undefined. Returns false when no model is selected or the variant is unavailable. */
set(variant: string | undefined): boolean
}
}
/** Claims a place in the slot tree; see SlotClaim. */
readonly slot: (claim: SlotClaim) => () => void
}
+44
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { UnauthorizedError } from "../errors.js"
export const ServerInfo = Schema.Struct({
version: Schema.String,
@@ -12,6 +13,24 @@ export const ServerInfo = Schema.Struct({
}).annotate({ identifier: "ServerInfo" })
export type ServerInfo = typeof ServerInfo.Type
export const PairingCode = Schema.Struct({
code: Schema.String,
expires_in: Schema.Int,
}).annotate({ identifier: "PairingCode" })
export type PairingCode = typeof PairingCode.Type
export const PairingSession = Schema.Struct({
token: Schema.String,
}).annotate({ identifier: "PairingSession" })
export type PairingSession = typeof PairingSession.Type
const PAIRING_CONNECT_PATH = /^\/auth\/connect\/[^/]+$/
// Authorization middleware skips credential checks for pairing links; the connect handler consumes the code instead.
export function isPairingConnectURL(url: URL) {
return PAIRING_CONNECT_PATH.test(url.pathname)
}
export const ServerGroup = HttpApiGroup.make("server.server")
.add(
HttpApiEndpoint.get("server.info", "/api/info", {
@@ -24,4 +43,29 @@ export const ServerGroup = HttpApiGroup.make("server.server")
}),
),
)
.add(
HttpApiEndpoint.post("server.pair", "/api/pair", {
success: PairingCode,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.pair",
summary: "Create pairing code",
description: "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
}),
),
)
.add(
HttpApiEndpoint.get("server.connect", "/auth/connect/:code", {
params: { code: Schema.String },
success: PairingSession,
error: UnauthorizedError,
}).annotateMerge(
OpenApi.annotations({
identifier: "server.connect",
summary: "Redeem pairing code",
description:
"Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "server" }))
-25
View File
@@ -17,7 +17,6 @@ import { Event } from "@opencode/schema/event"
import { Context, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
AgentNotFoundError,
ConflictError,
CommandExecutionError,
CommandNotFoundError,
@@ -323,30 +322,6 @@ export const makeSessionGroup = <
}),
),
)
.add(
HttpApiEndpoint.post("session.subagent", "/api/session/:sessionID/subagent", {
params: { sessionID: Session.ID },
payload: Schema.Struct({
text: Schema.String,
description: Schema.String,
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
fork: Schema.Boolean.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: PublicSessionInfo }),
error: [SessionNotFoundError, AgentNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.subagent",
summary: "Start subagent",
description:
"Start a background subagent in a child session and deliver its outcome to this session when it settles. Set fork to copy this session's settled history into the child. Set resume to false to admit the outcome without resuming this session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", {
params: { sessionID: Session.ID },
-2
View File
@@ -197,8 +197,6 @@ export const Forked = Event.durable({
...Base,
parentID: SessionID,
boundary: SessionFork.Boundary,
/** The fork is a child of its source session, such as a forked subagent. */
child: Schema.Boolean.pipe(optional),
instructions: Instruction.Values.pipe(optional),
instructionEntries: InstructionEntry.Snapshot.pipe(optional),
},
+36 -5
View File
@@ -1,6 +1,7 @@
export * as ServerAuth from "./auth"
import { Context, Layer, Option, Redacted } from "effect"
import { createHmac, timingSafeEqual } from "node:crypto"
export type DecodedCredentials = {
readonly username: string
@@ -12,6 +13,8 @@ export type Info = {
readonly username: string
}
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
static configLayer(input: Pick<Info, "password">) {
return Layer.succeed(this, this.of({ ...input, username: "opencode" }))
@@ -26,10 +29,38 @@ export function required(config: Info) {
return Option.isSome(config.password) && config.password.value !== ""
}
// Session tokens issued by pairing links are accepted anywhere the password is.
export function authorized(credentials: DecodedCredentials, config: Info) {
return (
Option.isSome(config.password) &&
credentials.username === config.username &&
Redacted.value(credentials.password) === config.password.value
)
if (Option.isNone(config.password) || credentials.username !== config.username) return false
const password = Redacted.value(credentials.password)
return password === config.password.value || verifySession(password, config)
}
// Sessions are signed with a key derived from the server password, so rotating the password revokes every session.
export function issueSession(config: Info, now = Date.now()) {
if (Option.isNone(config.password)) return
const expires = String(Math.floor(now / 1000) + SESSION_TTL_SECONDS)
return `${expires}.${sign(config.password.value, expires)}`
}
export function verifySession(token: string, config: Info, now = Date.now()) {
if (Option.isNone(config.password)) return false
const parts = token.split(".")
if (parts.length !== 2) return false
const expires = Number(parts[0])
if (!Number.isSafeInteger(expires) || expires * 1000 <= now) return false
const expected = Buffer.from(sign(config.password.value, parts[0]))
const actual = Buffer.from(parts[1])
return actual.length === expected.length && timingSafeEqual(actual, expected)
}
// Browsers share cookies across ports on the same host, so the name carries the port to keep local servers apart.
export function sessionCookieName(host: string | undefined) {
const port = URL.parse(`http://${host ?? ""}`)?.port
return port ? `opencode_session_${port}` : "opencode_session"
}
function sign(password: string, payload: string) {
const key = createHmac("sha256", password).update("opencode-session-v1").digest()
return createHmac("sha256", key).update(payload).digest("base64url")
}
+48 -12
View File
@@ -1,18 +1,54 @@
import { Effect } from "effect"
import { Duration, Effect } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { UnauthorizedError } from "@opencode/protocol/errors"
import { Api } from "../api"
import { ServerAuth } from "../auth"
import { ServerInfo } from "../server-info"
import { ServerPairing } from "../pairing"
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
handlers.handle("server.info", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
version: info.app.version ?? "unknown",
pid: process.pid ?? 0,
urls: info.urls(),
paths: info.paths,
}
}),
),
Effect.gen(function* () {
const pairing = yield* ServerPairing.Service
const auth = yield* ServerAuth.Config
return handlers
.handle("server.info", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return {
version: info.app.version ?? "unknown",
pid: process.pid ?? 0,
urls: info.urls(),
paths: info.paths,
}
}),
)
.handle("server.pair", () => pairing.issue())
.handle(
"server.connect",
Effect.fn(function* (ctx) {
const request = yield* HttpServerRequest.HttpServerRequest
// Browser navigations ask for HTML; everything else is an API client that wants the token.
const browser = request.headers.accept?.includes("text/html") === true
const token = (yield* pairing.consume(ctx.params.code)) ? ServerAuth.issueSession(auth) : undefined
if (token === undefined) {
if (!browser) return yield* new UnauthorizedError({ message: "Pairing link expired or already used" })
return HttpServerResponse.text(
"This pairing link expired or was already used. Run `opencode pair` to get a new one.",
{ status: 401 },
)
}
if (!browser) return { token }
return HttpServerResponse.redirect("/").pipe(
HttpServerResponse.setCookieUnsafe(ServerAuth.sessionCookieName(request.headers.host), token, {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: Duration.seconds(ServerAuth.SESSION_TTL_SECONDS),
}),
)
}),
)
}),
)
-15
View File
@@ -9,7 +9,6 @@ import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { SessionsCursor } from "@opencode/protocol/groups/session"
import {
AgentNotFoundError,
ConflictError,
CommandExecutionError,
CommandNotFoundError,
@@ -235,20 +234,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.subagent",
Effect.fn(function* (ctx) {
return {
data: yield* session.subagent({ sessionID: ctx.params.sessionID, ...ctx.payload }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag(
"Session.AgentNotFoundError",
(error) => new AgentNotFoundError({ agentID: error.agent, message: error.message }),
),
),
}
}),
)
.handle(
"session.switchAgent",
Effect.fn(function* (ctx) {
@@ -4,6 +4,7 @@ import { Authorization } from "@opencode/protocol/middleware/authorization"
export { Authorization } from "@opencode/protocol/middleware/authorization"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -36,8 +37,36 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
return Effect.succeed(emptyCredential())
}
const UNAUTHORIZED_MESSAGE = "Authentication required"
// Browsers show a native credentials prompt for a Basic challenge even on fetch, which would stall the web app
// before it can show its own sign-in screen. Only non-browser clients and page navigations get the challenge.
function challengeRequest(request: HttpServerRequest.HttpServerRequest) {
const mode = request.headers["sec-fetch-mode"]
return mode === undefined || mode === "navigate"
}
// Matches what the Authorization middleware encodes, for requests rejected before the HttpApi runs.
export function unauthorizedResponse(request: HttpServerRequest.HttpServerRequest) {
return HttpServerResponse.jsonUnsafe(
{ _tag: "UnauthorizedError", message: UNAUTHORIZED_MESSAGE },
{ status: 401, headers: challengeRequest(request) ? { "www-authenticate": WWW_AUTHENTICATE } : undefined },
)
}
export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
return credentialFromRequest(request).pipe(Effect.map((credential) => ServerAuth.authorized(credential, config)))
return credentialFromRequest(request).pipe(
Effect.map((credential) => ServerAuth.authorized(credential, config) || authorizedSessionCookie(request, config)),
)
}
function authorizedSessionCookie(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
const token = request.cookies[ServerAuth.sessionCookieName(request.headers.host)]
if (!token) return false
// Same-site pages on other ports still send this cookie, so only same-origin requests may use it.
const origin = request.headers.origin
if (origin !== undefined && URL.parse(origin)?.host !== request.headers.host) return false
return ServerAuth.verifySession(token, config)
}
export const authorizationLayer = Layer.effect(
@@ -48,15 +77,17 @@ export const authorizationLayer = Layer.effect(
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
// credential checks here; the connect handler consumes and validates the ticket.
// Ticketed PTY connects (browsers cannot set headers on WebSocket upgrades) and pairing links
// skip credential checks here; their handlers consume and validate the ticket or code.
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url) || isPairingConnectURL(url))
return yield* effect
if (yield* authorizedRequest(request, config)) return yield* effect
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: "Authentication required" })
if (challengeRequest(request))
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
)
return yield* new UnauthorizedError({ message: UNAUTHORIZED_MESSAGE })
}),
)
}),
+36
View File
@@ -0,0 +1,36 @@
export * as ServerPairing from "./pairing"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { randomBytes } from "node:crypto"
const TTL = Duration.minutes(5)
export interface Interface {
readonly issue: () => Effect.Effect<{ readonly code: string; readonly expires_in: number }>
readonly consume: (code: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ServerPairing") {}
// Codes are inserted via Cache.set and removed via invalidateWhen, so the lookup never runs.
const noLookup = () => Effect.die(new Error("ServerPairing cache must be used via set/invalidateWhen, never get"))
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const cache = yield* Cache.make<string, true>({ capacity: 1_000, lookup: noLookup, timeToLive: TTL })
return Service.of({
issue: Effect.fn("ServerPairing.issue")(function* () {
const code = randomBytes(16).toString("base64url")
yield* Cache.set(cache, code, true)
return { code, expires_in: Duration.toSeconds(TTL) }
}),
consume: Effect.fn("ServerPairing.consume")(function* (code) {
return yield* Cache.invalidateWhen(cache, code, () => true)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+5 -10
View File
@@ -6,13 +6,14 @@ import { SessionRestart } from "@opencode/core/session/execution/restart"
import { InstallationEvent } from "@opencode/schema/installation-event"
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
import { Global } from "@opencode/util/global"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
import { authorizedRequest } from "./middleware/authorization"
import { authorizedRequest, unauthorizedResponse } from "./middleware/authorization"
import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
@@ -182,26 +183,20 @@ function dispatch(
const app = yield* Ref.get(application)
const ready = state.type === "ready" && Option.isSome(app)
if (request.method === "GET" && url.pathname === "/api/info" && !ready) {
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
if (!(yield* authorizedRequest(request, auth))) return unauthorizedResponse(request)
return yield* infoResponse(status, version, urls, tmp)
}
if (
!isPairingConnectURL(url) &&
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
!(yield* authorizedRequest(request, auth))
)
return unauthorized()
return unauthorizedResponse(request)
if (ready) return yield* app.value
return unavailable(state)
})
}
function unauthorized() {
return HttpServerResponse.empty({
status: 401,
headers: { "www-authenticate": 'Basic realm="Secure Area"' },
})
}
const infoResponse = Effect.fnUntraced(function* (
status: Status.Interface,
version: string,
+2
View File
@@ -42,6 +42,7 @@ import { handlers } from "./handlers"
import { authorizationLayer } from "./middleware/authorization"
import { schemaErrorLayer } from "./middleware/schema-error"
import { PtyEnvironment } from "./pty-environment"
import { ServerPairing } from "./pairing"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
@@ -68,6 +69,7 @@ const applicationServiceNodes = [
Credential.node,
WellKnown.node,
PtyEnvironment.node,
ServerPairing.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
+15
View File
@@ -7,3 +7,18 @@ test("accepts only the fixed opencode username", () => {
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
})
test("session tokens expire, resist tampering, and are revoked by changing the password", () => {
const config = { password: Option.some("secret"), username: "opencode" }
const now = Date.now()
const token = ServerAuth.issueSession(config, now)
if (!token) throw new Error("Expected a session token")
expect(ServerAuth.verifySession(token, config, now)).toBe(true)
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make(token) }, config)).toBe(true)
expect(ServerAuth.verifySession(token, config, now + ServerAuth.SESSION_TTL_SECONDS * 1000)).toBe(false)
expect(ServerAuth.verifySession(`${token}x`, config, now)).toBe(false)
const parts = token.split(".")
expect(ServerAuth.verifySession(`${Number(parts[0]) + 1}.${parts[1]}`, config, now)).toBe(false)
expect(ServerAuth.verifySession(token, { ...config, password: Option.some("rotated") }, now)).toBe(false)
expect(ServerAuth.issueSession({ ...config, password: Option.none() })).toBeUndefined()
})
+66 -1
View File
@@ -128,7 +128,10 @@ it.live("authenticates API requests behind the frontend transform while allowing
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, HttpServer.formatAddress(server.address))))
expect(response.status).toBe(401)
expect(yield* Effect.promise(() => response.text())).toBe("")
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "UnauthorizedError",
message: "Authentication required",
})
}),
)
@@ -164,6 +167,68 @@ it.live("authenticates API requests behind the frontend transform while allowing
}),
)
it.live("pairing links sign in browsers with a cookie and API clients with a token", () =>
Effect.gen(function* () {
const server = yield* ServerProcess.start<never, never>({
hostname: "127.0.0.1",
port: 0,
password: "secret",
app: { version: "test-version" },
database: { path: ":memory:" },
})
const base = HttpServer.formatAddress(server.address)
const request = (pathname: string, init?: RequestInit) =>
Effect.promise(() => fetch(new URL(pathname, base), { redirect: "manual", ...init }))
const pair = Effect.gen(function* () {
const response = yield* request("/api/pair", {
method: "POST",
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(200)
return (yield* Effect.promise(() => response.json())) as { code: string; expires_in: number }
})
const rejected = yield* request("/api/pair", { method: "POST" })
expect(rejected.status).toBe(401)
expect(rejected.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
// A Basic challenge on fetch makes browsers show a native prompt instead of the app's sign-in screen.
const fetched = yield* request("/api/info", { headers: { "sec-fetch-mode": "cors" } })
expect(fetched.status).toBe(401)
expect(fetched.headers.get("www-authenticate")).toBeNull()
const browser = yield* pair
expect(browser.expires_in).toBe(300)
const redirect = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
expect(redirect.status).toBe(302)
expect(redirect.headers.get("location")).toBe("/")
const setCookie = redirect.headers.get("set-cookie") ?? ""
expect(setCookie).toContain(`opencode_session_${new URL(base).port}=`)
expect(setCookie).toContain("HttpOnly")
expect(setCookie).toContain("SameSite=Lax")
const cookie = setCookie.split(";")[0]
const reused = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
expect(reused.status).toBe(401)
expect(yield* Effect.promise(() => reused.text())).toContain("opencode pair")
expect((yield* request("/api/info", { headers: { cookie } })).status).toBe(200)
expect((yield* request("/api/info", { headers: { cookie, origin: base } })).status).toBe(200)
expect((yield* request("/api/info", { headers: { cookie, origin: "http://127.0.0.1:1" } })).status).toBe(401)
expect((yield* request("/api/info", { headers: { cookie: `${cookie}x` } })).status).toBe(401)
const client = yield* pair
const redeemed = yield* request(`/auth/connect/${client.code}`)
expect(redeemed.status).toBe(200)
const session = (yield* Effect.promise(() => redeemed.json())) as { token: string }
expect(
(yield* request("/api/info", { headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` } }))
.status,
).toBe(200)
expect((yield* request(`/auth/connect/${client.code}`)).status).toBe(401)
expect((yield* request("/auth/connect/unknown")).status).toBe(401)
}),
)
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
while (true) {
const next = await reader.read()
+4 -13
View File
@@ -63,7 +63,7 @@ import { DialogMcp } from "./component/dialog-mcp"
import { DialogStatus } from "./component/dialog-status"
import { DialogConfig } from "./component/dialog-config"
import { DialogDebug } from "./component/dialog-debug"
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
import { DialogPair } from "./component/dialog-pair"
import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
@@ -403,16 +403,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
packages={input.packages}
directories={pluginDirectories}
>
<App
pair={
input.server.endpoint.auth
? input.server.endpoint.auth
: {
username: "opencode",
password: "",
}
}
/>
<App />
</PluginProvider>
</PanelProvider>
</UpdateNotificationProvider>
@@ -465,7 +456,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})
function App(props: { pair?: DialogPairCredentials }) {
function App() {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
@@ -1007,7 +998,7 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Pair device",
slash: { name: "pair", aliases: ["web"] },
run: () => {
dialog.replace(() => <DialogPair credentials={props.pair} />)
dialog.replace(() => <DialogPair />)
},
category: "System",
},
+36 -64
View File
@@ -8,95 +8,65 @@ import { useDialog } from "../ui/dialog"
import { Link } from "../ui/link"
import { errorMessage } from "../util/error"
export type DialogPairCredentials = {
readonly username: string
readonly password: string
}
export function DialogPair(props: { credentials?: DialogPairCredentials }) {
export function DialogPair() {
const client = useClient()
const dialog = useDialog()
const dimensions = useTerminalDimensions()
const theme = useTheme().surface("dialog")
const [loadError, setLoadError] = createSignal<unknown>()
const [showPassword, setShowPassword] = createSignal(false)
const [passwordHover, setPasswordHover] = createSignal(false)
dialog.setSize("large")
dialog.setCentered(true)
const [server] = createResource(() =>
client.api.server.info().catch((error) => {
setLoadError(error)
return undefined
}),
const [info] = createResource(() =>
Promise.all([client.api.server.info(), client.api.server.pair()])
.then(([server, pairing]) => {
const link = (url: string) => new URL(`/auth/connect/${pairing.code}`, url).href
const local = server.urls[0] ? new URL(server.urls[0]) : undefined
if (local) local.hostname = "localhost"
return {
links: server.urls.map(link),
localhost: local ? link(local.href) : undefined,
minutes: Math.round(pairing.expires_in / 60),
loopback: server.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname)),
}
})
.catch((error) => {
setLoadError(error)
return undefined
}),
)
const info = createMemo(() => {
const current = server()
if (!current) return
return {
urls: current.urls,
username: props.credentials?.username ?? "opencode",
password: props.credentials?.password ?? "",
}
})
const localhost = createMemo(() => {
const value = info()?.urls[0]
if (!value) return ""
const url = new URL(value)
url.hostname = "localhost"
return url.toString().replace(/\/$/, "")
})
const horizontal = createMemo(() => dimensions().width >= 96)
const content = () => {
const value = info()
if (!value) return
const href = (input: string) => {
const url = new URL(input)
url.username = encodeURIComponent(value.username)
url.password = encodeURIComponent(value.password)
return url.toString()
}
return (
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={theme.text.muted}>This device</text>
<Show when={localhost()}>
{(url) => (
<Link href={href(url())} fg={theme.text.base}>
<text fg={theme.text.muted} wrapMode="word">
Open a link to connect. Links work once and expire in {value.minutes} minutes.
</text>
<Show when={value.localhost}>
{(url) => (
<box>
<text fg={theme.text.muted}>This device</text>
<Link href={url()} fg={theme.text.base}>
{url()}
</Link>
)}
</Show>
</box>
</box>
)}
</Show>
<box>
<text fg={theme.text.muted}>URLs</text>
<For each={value.urls}>
<text fg={theme.text.muted}>Links</text>
<For each={value.links}>
{(url) => (
<Link href={href(url)} fg={theme.text.base}>
<Link href={url} fg={theme.text.base}>
{url}
</Link>
)}
</For>
</box>
<box>
<text fg={theme.text.muted}>Username</text>
<text fg={theme.text.base}>{value.username}</text>
</box>
<box>
<text fg={theme.text.muted}>Password</text>
<text
fg={passwordHover() ? theme.text.base : theme.text.muted}
wrapMode="word"
onMouseOver={() => setPasswordHover(true)}
onMouseOut={() => setPasswordHover(false)}
onMouseUp={() => setShowPassword((current) => !current)}
>
{showPassword() ? value.password : "************"}
</text>
</box>
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
<Show when={value.loopback}>
<text fg={theme.text.muted} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
@@ -108,7 +78,9 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
flexShrink={0}
alignItems={horizontal() ? "flex-end" : "center"}
>
<text fg={theme.text.base}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
<Show when={value.links[0]}>
{(url) => <text fg={theme.text.base}>{renderUnicodeCompact(url(), { border: 1 })}</text>}
</Show>
</box>
</box>
)
@@ -90,6 +90,7 @@ export type PromptProps = {
export type PromptRef = {
focused: boolean
current: PromptInfo
setMode(mode: "normal" | "shell"): void
set(prompt: PromptInfo): void
reset(): void
blur(): void
@@ -679,6 +680,9 @@ export function Prompt(props: PromptProps) {
blur() {
input.blur()
},
setMode(mode) {
setStore("mode", mode)
},
set(prompt) {
input.setText(prompt.text)
setStore("prompt", prompt)
+1
View File
@@ -129,6 +129,7 @@ export const Definitions = {
"session.aside": keybind("none", "Ask a side question"),
"session.cd": keybind("none", "Change working directory"),
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
"queued_prompt.move_back": keybind("ctrl+m", "Move queued prompt back to input"),
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
"session.child.first": keybind("down", "Toggle subagent picker"),
+13 -1
View File
@@ -539,7 +539,6 @@ export function RunCommandMenuBody(props: {
return
}
if (item.action === "subagent") {
props.onSubagent()
return
@@ -949,6 +948,7 @@ export function RunQueuedPromptSelectBody(props: {
prompts: Accessor<FooterQueuedPrompt[]>
onClose: () => void
onSelect: (prompt: FooterQueuedPrompt) => void
onMoveBack: (prompt: FooterQueuedPrompt) => void
onDelete: (prompt: FooterQueuedPrompt) => void
onRows?: (rows: number) => void
mono?: boolean
@@ -970,10 +970,21 @@ export function RunQueuedPromptSelectBody(props: {
onRows: props.onRows,
})
const shortcuts = Keymap.useShortcuts()
const moveBackShortcut = () => monoShortcut(shortcuts.get("queued_prompt.move_back") ?? "", props.mono ?? false)
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
Keymap.createLayer(() => ({
priority: 1,
commands: [
{
id: "queued_prompt.move_back",
title: "Move back",
group: "Prompt",
run() {
const item = controller.items()[controller.menu.selected()]
if (!item) return false
props.onMoveBack(item.prompt)
},
},
{
id: "queued_prompt.delete",
title: "Delete pending prompt",
@@ -1001,6 +1012,7 @@ export function RunQueuedPromptSelectBody(props: {
hint={[
controller.items()[controller.menu.selected()]?.prompt.delivery === "steer" ? "enter queue" : "enter steer",
deleteShortcut() ? `${deleteShortcut()} delete` : undefined,
moveBackShortcut() ? `${moveBackShortcut()} move back` : undefined,
]
.filter(Boolean)
.join(" · ")}
+5
View File
@@ -160,6 +160,7 @@ export type PromptState = {
onPaste: (event: PasteEvent) => Promise<void>
onContentChange: () => void
onSizeChange: () => void
current: () => RunPrompt
replacePrompt: (prompt: RunPrompt) => void
bind: (area?: TextareaRenderable) => void
}
@@ -1544,6 +1545,10 @@ export function createPromptState(input: PromptInput): PromptState {
scheduleRows()
},
onSizeChange: scheduleRows,
current: () => {
syncDraft()
return promptCopy(draft)
},
replacePrompt: restore,
bind,
}
+14 -2
View File
@@ -320,7 +320,7 @@ export function RunFooterView(props: RunFooterViewProps) {
}
const runQueuedAction = createSingleFlight<string>()
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string) => {
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string, failureLabel?: string) => {
const run = props.onQueuedPromptAction
if (!run) return false
const result = await runQueuedAction(inboxID, async () => {
@@ -329,7 +329,9 @@ export function RunFooterView(props: RunFooterViewProps) {
(error) => error,
)
if (!error) return true
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} pending prompt: ${errorMessage(error)}`)
props.onStatus(
`failed to ${failureLabel ?? (action === "cancel" ? "delete" : action)} pending prompt: ${errorMessage(error)}`,
)
return false
})
return result ?? false
@@ -795,6 +797,16 @@ export function RunFooterView(props: RunFooterViewProps) {
)
closePanel()
}}
onMoveBack={async (item) => {
const current = composer.current()
if (current.text.length || current.parts.length) {
props.onStatus("clear your draft before moving a prompt back")
return
}
if (!(await queuedPromptAction("cancel", item.messageID, "move back"))) return
closePanel()
composer.replacePrompt({ ...item.prompt, messageID: undefined })
}}
onDelete={(item) => {
void queuedPromptAction("cancel", item.messageID)
}}
+18
View File
@@ -21,6 +21,7 @@ import { useAttention } from "../context/attention"
import { useStorage } from "../context/storage"
import { useSessionTabs } from "../context/session-tabs"
import { useOptionalPanel } from "../context/panel"
import { useLocal } from "../context/local"
import { abbreviateHome } from "../util/path-format"
export type Dispose = () => Promise<void>
@@ -70,6 +71,7 @@ export function usePluginHost() {
storage: useStorage(),
sessionTabs: useSessionTabs(),
panel: useOptionalPanel(),
local: useLocal(),
}
}
@@ -249,6 +251,22 @@ export function createPluginContext(input: {
return true
},
},
model: {
current() {
const selection = host.local.model.selection()
if (!selection) return
return { providerID: selection.providerID, modelID: selection.modelID, variant: selection.variant }
},
variant: {
list: () => host.local.model.variant.list(),
set(variant) {
if (!host.local.model.selection()) return false
if (variant !== undefined && !host.local.model.variant.list().includes(variant)) return false
host.local.model.variant.set(variant)
return true
},
},
},
slot(value: SlotClaim) {
// Keys are counter-suffixed so one plugin may claim several places;
// order within the plugin is registration order.
+33 -3
View File
@@ -211,7 +211,9 @@ export function Session(props: {
)
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
const queuedPrompts = createMemo(() =>
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
pendingUsers().flatMap((item) =>
item.delivery === "queue" ? [{ id: item.id, text: item.payload.text, payload: item.payload }] : [],
),
)
const [composer, setComposer] = createStore({
open: false,
@@ -601,7 +603,7 @@ export function Session(props: {
const dialog = useDialog()
const renderer = useRenderer()
const runPendingAction = createSingleFlight<string>()
const mutatePending = async (action: PendingAction, inboxID: string) => {
const mutatePending = async (action: PendingAction, inboxID: string, failureLabel?: string) => {
const result = await runPendingAction(inboxID, async () => {
const request =
action === "steer"
@@ -614,7 +616,7 @@ export function Session(props: {
(error) => error,
)
if (!error) return true
const label = action === "cancel" ? "delete" : action
const label = failureLabel ?? (action === "cancel" ? "delete" : action)
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
return false
})
@@ -645,6 +647,34 @@ export function Session(props: {
})
},
},
{
command: "queued_prompt.move_back",
title: "move back",
side: "right",
onTrigger: (option) => {
const target = prompt()
const queued = queuedPrompts().find((item) => item.id === option.value)
if (!target || !queued) return
const current = target.current
if (
current.text.length ||
current.files?.length ||
current.agents?.length ||
current.skills?.length ||
current.pasted.length
) {
toast.show({ message: "Clear or stash your draft before moving a prompt back", variant: "error" })
return
}
void mutatePending("cancel", queued.id, "move back").then((moved) => {
if (!moved) return
target.setMode("normal")
target.set({ ...projectedPromptInput(queued.payload), pasted: [] })
dialog.clear()
target.focus()
})
},
},
]}
footerHints={[{ title: "steer", label: "enter" }]}
/>

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