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