Compare commits

..

15 Commits

Author SHA1 Message Date
Kit Langton a211114c8b Merge remote-tracking branch 'origin/dev' into httpapi-codegen 2026-06-23 17:22:24 -04:00
Kit Langton 845c0d47f7 refactor(sdk): extract shared schema and embedded host 2026-06-23 17:22:11 -04:00
Kit Langton bbf1021d4a refactor(client): extract model and protocol 2026-06-23 16:12:56 -04:00
Kit Langton 602c470d37 refactor(client): stabilize datatype names 2026-06-23 15:30:44 -04:00
Kit Langton 9ad36251a7 refactor(client): use canonical session schemas 2026-06-23 15:26:53 -04:00
Kit Langton 7e8f35e29e refactor(client): simplify code generation 2026-06-23 14:18:49 -04:00
Kit Langton 22ee85671b refactor(client): simplify embedded setup 2026-06-23 13:18:36 -04:00
Kit Langton 51e5e4186f feat(client): add embedded OpenCode layer 2026-06-23 13:03:20 -04:00
Kit Langton 9c20973501 refactor(client): generate from hosted session group 2026-06-23 12:53:58 -04:00
Kit Langton 07a65d5a16 fix(client): preserve V2 session contracts 2026-06-23 12:33:28 -04:00
Kit Langton 905cd60017 Merge remote-tracking branch 'origin/dev' into httpapi-codegen
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/server/src/groups/session.ts
2026-06-23 12:01:37 -04:00
Kit Langton e03f5dd331 feat(client): add embedded V2 host 2026-06-23 11:56:06 -04:00
Kit Langton 924e00776b feat(client): generate public session API 2026-06-23 09:00:51 -04:00
Kit Langton 66bcddd059 feat(sdk): add Promise client codegen 2026-06-22 22:10:48 -04:00
Kit Langton b241e38cc8 feat(sdk): add Effect HttpApi codegen 2026-06-22 16:48:04 -04:00
244 changed files with 8040 additions and 5480 deletions
+5
View File
@@ -69,6 +69,11 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
+77
View File
@@ -67,6 +67,21 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
**PTY Environment**:
The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory.
**OpenCode Client**:
The generated Effect API shared by networked and in-process consumers, executed through an `HttpClient` against the same `HttpApi` router and handlers.
_Avoid_: Remote client
**SDK Contract IR**:
The runtime-neutral compiled representation of the authoritative `HttpApi`, preserving encoded and decoded type projections plus transport metadata so independent SDK emitters can choose their public value model and runtime interpreter.
**Embedded OpenCode**:
A scoped in-process host that structurally extends the **OpenCode Client**, supplies an in-memory HTTP transport, and exposes additional same-process capabilities directly.
_Avoid_: Local implementation
**Page**:
A bounded ordered result containing `items` and opaque `previous` and `next` cursor links for navigating the same query in either direction.
_Avoid_: Response envelope
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -117,6 +132,51 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `OPENCODE_TERMINAL`.
- Networked and **Embedded OpenCode** use the same **OpenCode Client** and preserve the full HTTP encoding, routing, middleware, and decoding boundary; only the `HttpClient` transport differs.
- The Effect-native network constructor obtains `HttpClient.HttpClient` from its environment so callers own transport selection, recording, tracing, retries, and tests. Convenience runtimes may provide a fetch transport separately.
- Creating **Embedded OpenCode** is scoped. Closing its owning Scope releases the in-process server resources, database resources, registrations, and fibers.
- **Embedded OpenCode** exposes shared client capabilities and embedded-only capabilities on one object; consumers do not navigate through a nested `.client` property.
- The beta **OpenCode Client** currently uses plural consumer-facing capability groups such as `sessions`; whether the stable Session namespace should instead be singular `session` must be settled before stabilization. Internal server identifiers do not implicitly define public client names.
- The public `HttpApi` is authoritative for shared **OpenCode Client** capabilities: the server and code generation consume the same hosted `SessionGroup`. Codegen may assign a separate consumer-facing group name without reconstructing group membership or endpoint contracts.
- SDK generation reflects the public `HttpApi` once into an **SDK Contract IR**. Promise and Effect emitters share endpoint structure and transport metadata without being required to expose identical public values: an emitter may select encoded wire types, decoded domain types, compile-time brands, runtime validation, and its own execution abstraction independently.
- The first Effect emitter is the rich projection: it exposes decoded Effect-native values, preserves brands and schema transformations, performs runtime schema decoding, and delegates transport interpretation to `HttpApiClient`. Lighter wire-shaped Effect output remains possible through another emitter policy rather than constraining the shared IR.
- The rich Effect emitter regenerates private executable schemas when the **SDK Contract IR** proves that their transport semantics can be reproduced exactly. Contracts with authoritative custom transformations use the import-based Effect emitter against the canonical Protocol `HttpApi`; the Promise emitter still derives zero-Effect structural wire types from the same IR.
- `@opencode-ai/protocol` owns the authoritative Session `HttpApi`. The server and client generator consume the exact hosted `SessionGroup`, including `compact`, `wait`, and `context`.
- The first Promise emitter targets the same clean domain-oriented method organization rather than Hey API source compatibility. It returns unwrapped values directly, rejects declared and infrastructure failures, and begins with minimal client-level transport configuration; result wrappers, interceptors, and legacy generated signatures are outside the initial surface.
- The first Promise emitter parses response syntax and trusts its generated structural types; it does not perform runtime structural validation. Malformed payload syntax fails, while a syntactically valid shape mismatch is not detected at the SDK boundary. Standalone validator generation remains an optional future emitter policy.
- Declared Promise-client failures retain their tagged structural wire values and have generated type guards. Consumers do not depend on generated `Error` subclass identity, preserving discrimination across package copies and realms while remaining structurally aligned with Effect domain errors.
- Promise-client infrastructure failures use one generated `ClientError` class with a structured reason such as transport failure, unexpected status, unsupported content type, or malformed response. Promise methods reject with either a tagged declared domain failure or `ClientError`, matching the Effect client's conceptual domain/infrastructure error division.
- Promise methods accept a separate optional per-call transport-options argument containing `AbortSignal` and header overrides. Cancellation and transport metadata do not enter the domain input object; broader interceptor and response-mode APIs remain deferred.
- Promise streaming methods return a lazy `AsyncIterable` directly rather than a Promise-wrapped stream object. Iteration opens the connection, `AbortSignal` cancels it, and ending iteration closes the underlying request; the Effect emitter analogously returns `Stream` directly.
- Promise SSE connection establishment, declared HTTP failures, and infrastructure failures occur during `AsyncIterable` iteration, beginning with its first `next()` call, rather than during synchronous method construction.
- Neither generated streaming runtime automatically reconnects after disconnection. Promise `AsyncIterable` and Effect `Stream` fail explicitly; live consumers refresh and resubscribe, while durable sequence-based resume remains explicit composition above the generated client.
- Promise client construction is synchronous and network-free. It requires `baseUrl`, defaults to `globalThis.fetch`, accepts client-level headers, and merges them with per-call header overrides.
- Effect client construction accepts an explicit `baseUrl` and obtains `HttpClient.HttpClient` from the Effect environment. It does not install fetch or duplicate per-call transport policy; callers transform/provide the client for headers, tracing, retries, recording, and tests, while fiber interruption owns cancellation.
- Promise and Effect emitters each own their generated public type modules. The **SDK Contract IR**, not a physically shared generated type package, is the common source; this permits zero-Effect wire types and rich decoded Effect types to evolve independently.
- Promise and Effect network clients ship from `@opencode-ai/client` behind isolated root and `/effect` exports. The root has no runtime path to Effect; `/effect` imports only Effect, Schema, and Protocol.
- The Effect-native scoped host belongs to `@opencode-ai/sdk-next`, which will assume the existing `@opencode-ai/sdk` name after legacy consumers migrate. Client remains network-only and SDK depends one-way on Client.
- SDK executes Server's assembled `HttpRouter` in memory. It opens no listener and performs no network I/O, while preserving Server routing, middleware, codecs, handlers, and errors.
- The Effect Client and SDK re-export their decoded datatype facade from Schema so callers do not depend on internal package locations or Core's versioned names.
- A capability intended for both networked and **Embedded OpenCode** belongs in the authoritative public `HttpApi`; embedded-only same-process capabilities extend **Embedded OpenCode** separately.
- `sessions.events({ sessionID, after })` is a public durable Session event stream. It verifies the Session, replays durable events after the optional aggregate sequence, continues with newly committed durable events, excludes live-only fragments, and is transported as SSE in both networked and embedded modes.
- `events.subscribe()` is a distinct public instance-wide live stream for Session and non-Session activity. It has no replay guarantee and includes connection, heartbeat, and instance-disposal lifecycle events; consumers recover from disconnection by refreshing authoritative state.
- A Session ID is not an optional filter on `events.subscribe()`: instance-wide live events and durable Session events have different schemas, replay guarantees, cursors, lifecycle events, and failure behavior.
- The initial common OpenCode Client does not expose server-global event aggregation. `events.subscribe()` is bounded to the connected OpenCode instance or workspace; any future cross-instance administrative stream requires a separately designed API.
- `events.subscribe()` does not automatically reconnect after transport loss. The live-only stream fails with `ClientError`; consumers refresh authoritative state before explicitly opening a new subscription because events missed during disconnection cannot be replayed.
- `sessions.events({ sessionID, after })` returns the generated HTTP client's cold durable event stream and does not build reconnection policy into the endpoint or client constructor. Transport loss fails the stream with `ClientError`. Callers may compose an explicit resuming stream above it by retaining the last observed durable sequence and opening a new subscription with `after`; any reusable resume helper remains a separate API design question.
- The stable `sessions.list(...)` design returns a **Page** in both networked and **Embedded OpenCode**; embedded execution does not define a separate unbounded array-returning list operation. The beta client currently preserves the existing HTTP `{ data, cursor }` envelope until emitter-level Page projection is implemented.
- Session list cursors are opaque branded values carrying continuation query and ordering state. Consumers pass them back unchanged and do not inspect storage anchors or encoded filter fields.
- A Session list continuation accepts only its opaque cursor. Scope, filters, ordering, and page size are fixed by the initial query and carried by that cursor.
- `sessions.messages(...)` returns a **Page** and uses the same cursor discipline as `sessions.list(...)`: the initial request supplies `sessionID`, ordering, and page size; continuation supplies `sessionID` plus only an opaque branded message cursor carrying ordering, page size, direction, and message anchor. Using a cursor with another Session is invalid.
- `sessions.message({ sessionID, messageID })` is a required resource lookup. An unknown Session fails with `SessionNotFoundError`; a known Session with an absent or differently owned message fails with `SessionMessageNotFoundError` without disclosing cross-Session ownership. Absence is not represented as `undefined` across the public HTTP boundary.
- `sessions.interrupt({ sessionID })` first verifies that the durable Session exists, failing with `SessionNotFoundError` otherwise. For a known Session, interruption is idempotent: idle, already-settled, or locally unowned execution is a no-op.
- `sessions.context({ sessionID })` preserves the existing message-only operation. It returns projected conversational messages selected as Session context; it does not include or represent the complete provider request context, whose baseline system context and other contributions remain separate.
- **Open question**: Should a future, separately named operation expose the complete provider request context, including baseline system context, selected source contributions, and context-epoch metadata?
- `sessions.prompt(...)` exposes `resume?: boolean`. Omitting it preserves durable admission followed by an advisory execution wake; `resume: false` requests durable admit-only behavior.
- The public operation remains `sessions.prompt(...)`; `SessionInput.admit` is the internal primitive, while the public `Admission` result and `resume` option express its durable admission semantics.
- `sessions.create(...)` accepts an optional `location`. Omission resolves through the connected OpenCode instance's default or current location; an explicit value selects a known location. Networked and embedded transports use the same handler semantics.
- `sessions.switchAgent({ sessionID, agent })` is part of the common client alongside `sessions.switchModel(...)`. It affects subsequent Session activity and fails with `SessionNotFoundError` for an unknown Session.
- The **Embedded OpenCode** Layer delegates to the same scoped creation path; it does not define a second implementation.
- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
@@ -133,6 +193,23 @@ The host-supplied environment overlay applied by the server when creating a PTY,
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Client contract architecture
Semantic values that mean the same thing internally and publicly live in the lightweight Schema leaf. Core consumes Schema for domain behavior; Protocol composes Schema values into paths, payloads, envelopes, errors, cursors, and streams; Server imports both, hosts Protocol's exact groups, and owns protocol/domain adaptation. The root Promise client remains zero-Effect, `/effect` depends on Effect plus Schema and Protocol, and `@opencode-ai/sdk-next` composes the scoped in-process host above Client, Core, and Server.
Shared public records are plain objects declared with `Schema.Struct`. A same-name inferred interface gives object records readable TypeScript signatures without constructors, prototypes, or nominal identity; unions retain explicit type aliases.
Before stabilizing the client API:
- Keep additional public schemas in Schema and additional network groups in Protocol; neither package may transitively load databases, Drizzle, Session execution, providers, watchers, native modules, or WASM.
- Replace the transitional server-internal Session location middleware tag when the remaining location-scoped groups move to Protocol or gain a narrow request-location service contract.
- Project the existing list response envelope to the stable client **Page** shape and enforce separate initial-query and cursor-continuation inputs without changing the hosted V2 wire contract.
- Settle the stable consumer namespace (`session` versus the current beta `sessions`) and use an explicit codegen annotation if the consumer name should differ from the server group identifier.
- Preserve V2 route paths, operation IDs, codecs, errors, middleware behavior, and OpenAPI output while making this change.
- Preserve browser-safe `@opencode-ai/client` and `@opencode-ai/client/effect` bundles through import-boundary tests.
- Define embedded-host placement before supporting multiple hosts over one database. Hosts that share durable Session storage must also share process-local Session execution coordination, or each host must receive isolated storage explicitly.
- Keep an embedded request scope alive until any streamed response body finishes. The initial non-streaming Session surface does not exercise this lifetime boundary; Session and instance event streams must do so before joining the embedded client.
## Example dialogue
> **Dev:** "The date changed while the session was active. Should the **Mid-Conversation System Message** say what the old date was?"
+70 -1
View File
@@ -109,6 +109,27 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/client": {
"name": "@opencode-ai/client",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.83",
},
"optionalPeers": [
"effect",
],
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.9",
@@ -477,11 +498,22 @@
"effect": "4.0.0-beta.83",
},
},
"packages/httpapi-codegen": {
"name": "@opencode-ai/httpapi-codegen",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.9",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
@@ -653,9 +685,22 @@
"@opentui/solid",
],
},
"packages/protocol": {
"name": "@opencode-ai/protocol",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/schema": {
"name": "@opencode-ai/schema",
"dependencies": {
"@noble/hashes": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
@@ -674,6 +719,20 @@
"@types/semver": "^7.5.8",
},
},
"packages/sdk-next": {
"name": "@opencode-ai/sdk-next",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.9",
@@ -694,6 +753,7 @@
"version": "1.17.9",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
@@ -957,6 +1017,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.12",
"@noble/hashes": "2.2.0",
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
@@ -1787,6 +1848,8 @@
"@opencode-ai/cli": ["@opencode-ai/cli@workspace:packages/cli"],
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
@@ -1813,16 +1876,22 @@
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/httpapi-codegen": ["@opencode-ai/httpapi-codegen@workspace:packages/httpapi-codegen"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
"@opencode-ai/schema": ["@opencode-ai/schema@workspace:packages/schema"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@workspace:packages/sdk/js"],
"@opencode-ai/sdk-next": ["@opencode-ai/sdk-next@workspace:packages/sdk-next"],
"@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"],
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-/fq73Jr1mTysaKS0u7VUuwhFzz9tIlYw+9qyW5pA7es=",
"aarch64-linux": "sha256-iomnsCjOvab+v7+ebHoyg/Q7YtQegkln1+RTkb/tLn8=",
"aarch64-darwin": "sha256-7Uaxm1YlhblvtrVSFXPAx2zVjhYmhkjGkCA78bqs6Xg=",
"x86_64-darwin": "sha256-yJbe3TC8wlQJvQP7IwI+9USpsAjlwX7m5Jqa/7dW7bQ="
"x86_64-linux": "sha256-oWSGu+SP66Aquy/0Vaq7Bgp8404ZdOWbQX+O7h3jxHU=",
"aarch64-linux": "sha256-UsS0+c+GwtIukmWwQeFbY/3Oaz3t4Q7C6cFMGkmlyAY=",
"aarch64-darwin": "sha256-CArz92ewPmXO+ORFCBkCH8LzMpU/DjyaO4ic7QL0UpI=",
"x86_64-darwin": "sha256-rhnz9gmG6L06wIzfMhTaXDDEf6IbMD32CavqwXoqcUs="
}
}
+1
View File
@@ -34,6 +34,7 @@
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
"@npmcli/arborist": "9.4.0",
"@noble/hashes": "2.2.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
@@ -19,7 +19,6 @@ import {
pickerMode,
preloadTreeDirectories,
cleanPickerInput,
createPriorityTaskQueue,
createDirectorySearch,
currentPickerSuggestions,
displayPickerPath,
@@ -56,7 +55,6 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const [error, setError] = createSignal(false)
const [rootValid, setRootValid] = createSignal(false)
const listings = new Map<string, Promise<Array<{ name: string; type: "file" | "directory" }> | undefined>>()
const loads = createPriorityTaskQueue<Array<{ name: string; type: "file" | "directory" }> | undefined>(3)
const advanced = new Set<string>()
let tree: FileTree | undefined
let container: HTMLDivElement | undefined
@@ -104,21 +102,16 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
})
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
async function load(path: string, generation: number, eager = false) {
async function load(path: string, generation: number, preload = true) {
const key = path.replace(/\/+$/, "")
setError(false)
const absolute = absoluteTreePath(root(), key)
const existing = listings.get(key)
if (existing && !eager) loads.promote(`${generation}:${key}`)
const request =
existing ??
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
return sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
})
listings.get(key) ??
sdk.client.file
.list({ directory: absolute, path: "" })
.then((result) => result.data ?? [])
.catch(() => undefined)
listings.set(key, request)
const nodes = await request
if (!activeTreeNavigation(generation, navigation)) return false
@@ -128,8 +121,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
return false
}
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
if (!eager && advanceTreePreload(advanced, key)) {
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
if (preload && advanceTreePreload(advanced, key)) {
void Promise.all(preloadTreeDirectories(key, nodes).map((directory) => load(directory, generation, false)))
}
return true
}
@@ -15,7 +15,6 @@ import {
treePathWithin,
currentPickerSuggestions,
createDirectorySearch,
createPriorityTaskQueue,
displayPickerPath,
pickerParent,
pickerRoot,
@@ -169,41 +168,6 @@ test("advances preloading once for every expanded directory", () => {
expect(advanceTreePreload(advanced, "repos/")).toBeTrue()
})
test("limits background tasks and prioritizes newly requested work", async () => {
const queue = createPriorityTaskQueue<void>(2)
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const started: string[] = []
let active = 0
let maximum = 0
const task = (name: string, blocker?: Promise<void>) => async () => {
started.push(name)
active++
maximum = Math.max(maximum, active)
await blocker
active--
}
const running = [
queue.schedule("first", "background", task("first", first.promise)),
queue.schedule("second", "background", task("second", second.promise)),
queue.schedule("preload", "background", task("preload")),
queue.schedule("opened", "user", task("opened")),
]
await Promise.resolve()
expect(started).toEqual(["first", "second"])
first.resolve()
await running[0]
await Promise.resolve()
expect(started).toEqual(["first", "second", "opened"])
second.resolve()
await Promise.all(running)
expect(started).toEqual(["first", "second", "opened", "preload"])
expect(maximum).toBe(2)
})
test("clamps bridged tree wheel scrolling", () => {
expect(nextTreeScrollTop(100, 40, 500, 200)).toBe(140)
expect(nextTreeScrollTop(10, -40, 500, 200)).toBe(0)
@@ -138,79 +138,6 @@ export function activeTreeNavigation(request: number, current: number) {
return request === current
}
export function createPriorityTaskQueue<T>(concurrency: number) {
type Job = {
key: string
priority: "user" | "background"
promise: Promise<T>
run: () => void
}
const jobs = new Map<string, Job>()
const user: Job[] = []
const background: Job[] = []
let active = 0
const drain = () => {
while (active < concurrency) {
const job = user.pop() ?? background.shift()
if (!job) return
active++
job.run()
}
}
const schedule = (key: string, priority: Job["priority"], task: () => Promise<T>) => {
const existing = jobs.get(key)
if (existing) {
if (priority === "user") promote(key)
return existing.promise
}
const deferred = Promise.withResolvers<T>()
const job: Job = {
key,
priority,
promise: deferred.promise,
run: () => {
const complete = () => {
active--
jobs.delete(key)
drain()
}
Promise.resolve()
.then(task)
.then(
(value) => {
complete()
deferred.resolve(value)
},
(error) => {
complete()
deferred.reject(error)
},
)
},
}
jobs.set(key, job)
;(priority === "user" ? user : background).push(job)
drain()
return job.promise
}
const promote = (key: string) => {
const job = jobs.get(key)
if (!job || job.priority === "user") return
const index = background.indexOf(job)
if (index === -1) return
background.splice(index, 1)
job.priority = "user"
user.push(job)
}
return { schedule, promote }
}
export function nextTreeScrollTop(current: number, delta: number, scrollHeight: number, clientHeight: number) {
return Math.min(Math.max(0, scrollHeight - clientHeight), Math.max(0, current + delta))
}
+23 -47
View File
@@ -41,8 +41,6 @@ import { tabHref, useTabs } from "@/context/tabs"
import "./titlebar.css"
import { useServerSDK } from "@/context/server-sdk"
import { Session } from "@opencode-ai/sdk/v2"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createTabPromptState } from "@/context/prompt"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -421,6 +419,22 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (next) tabs.select(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) tabs.select(tab)
},
}
}),
].filter((v) => v !== undefined)
})
@@ -484,7 +498,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<For each={tabsStore}>
{(tab, i) => {
let ref!: HTMLDivElement
useTabShortcut(i, () => tabs.select(tab))
const divider = () =>
i() !== 0 && (
@@ -510,18 +523,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
const sdk = createMemo(() => {
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return sdk
})
const [session] = createResource(
() => {
const id = tab.sessionId
const _sdk = sdk()
if (!_sdk) return null
return { id, sdk: _sdk }
const conn = server.list.find((s) => ServerConnection.key(s) === tab.server)
if (!conn) return null
const { sdk } = global.createServerCtx(conn)
return { id, sdk }
},
({ id, sdk }) =>
sdk.client.session
@@ -530,18 +538,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
.catch(() => undefined),
)
createEffect(() => {
if (tab.type !== "session") return
const _sdk = sdk()
if (!_sdk) return
const sess = session()
if (!sess) return
createTabPromptState(tabs, tab, _sdk.scope, {
dir: base64Encode(sess.directory),
id: sess.id,
})
})
return (
<>
{divider()}
@@ -839,26 +835,6 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
)
}
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}
function TabNavItem(props: {
ref?: HTMLDivElement
href: string
@@ -887,7 +863,7 @@ function TabNavItem(props: {
return (
<div
ref={props.ref}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={props.active}
onMouseDown={(event) => {
if (event.button !== 1) return
@@ -961,7 +937,7 @@ function DraftTabItem(props: {
<div
ref={props.ref}
data-active={props.active}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
@@ -1006,7 +982,7 @@ function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: s
return (
<div
ref={props.ref}
class="group relative flex h-7 w-56 shrink-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
+1 -32
View File
@@ -8,10 +8,6 @@ import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import { useServer } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
interface PartBase {
content: string
@@ -262,23 +258,14 @@ export function createPromptState() {
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
...args: Parameters<typeof createPromptSession>
) => tabs.state(tab, "prompt", () => createPromptSession(...args))
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
gate: false,
init: () => {
const params = useParams<{ serverKey?: string; id?: string }>()
const params = useParams()
const sdk = useSDK()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
@@ -301,25 +288,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const tab = createMemo<Tab | undefined>(() => {
if (!settings.general.newLayoutDesigns()) return
if (search.draftId) {
return tabs.store.find((item) => item.type === "draft" && item.draftID === search.draftId)
}
if (!params.id) return
const serverKey = params.serverKey ? requireServerKey(params.serverKey) : server.key
return (
tabs.store.find(
(item) => item.type === "session" && item.server === serverKey && item.sessionId === params.id,
) ?? { type: "session", server: serverKey, sessionId: params.id }
)
})
const load = (scope: Scope) => {
const current = tab()
if (current) {
return createTabPromptState(tabs, current, serverSDK().scope, scope)
}
const key = scopeKey(scope)
const existing = cache.get(key)
if (existing) {
-33
View File
@@ -1,33 +0,0 @@
import { createRoot, type Owner } from "solid-js"
type Entry = {
value: unknown
dispose: VoidFunction
}
export function createTabMemory(owner: Owner | null) {
const entries = new Map<string, Map<string, Entry>>()
const remove = (key: string) => {
const state = entries.get(key)
if (!state) return
for (const entry of state.values()) entry.dispose()
entries.delete(key)
}
return {
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
const existing = state.get(name)
if (existing) return existing.value as T
const entry = createRoot((dispose) => ({ value: init(), dispose }), owner)
state.set(name, entry)
return entry.value
},
remove,
dispose() {
for (const key of entries.keys()) remove(key)
},
}
}
-24
View File
@@ -1,24 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./tab-memory"
describe("tab memory", () => {
test("keeps state until its tab is removed", () => {
createRoot((dispose) => {
const memory = createTabMemory(getOwner())
let disposed = 0
const first = memory.ensure("tab", "prompt", () => {
onCleanup(() => disposed++)
return { value: "prompt" }
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
expect(disposed).toBe(1)
expect(memory.ensure("tab", "prompt", () => ({ value: "new" }))).not.toBe(first)
dispose()
})
})
})
+2 -18
View File
@@ -3,13 +3,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
import { createEffect, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
import { createTabMemory } from "./tab-memory"
export type SessionTab = {
type: "session"
@@ -67,7 +66,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const memory = createTabMemory(getOwner())
const closing = new Set<string>()
let recentWrite = 0
@@ -91,18 +89,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
onCleanup(memory.dispose)
createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) memory.remove(tabKey(tab))
}
setStore(() => next)
}
if (next.length !== store.length) setStore(() => next)
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
})
@@ -160,7 +151,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
})
memory.remove(`draft:${draftID}`)
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
@@ -180,14 +170,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
memory.remove(key)
if (draftID) removeDraftPersisted(draftID)
},
removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const key of removed) memory.remove(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
@@ -239,7 +227,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
for (const key of removed) memory.remove(key)
},
select: navigateTab,
remember(tab: Tab) {
@@ -259,9 +246,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
navigate("/")
},
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
}
return { ...actions, store, ready, recentReady }
@@ -20,8 +20,6 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useParams: () => ({}),
useSearchParams: () => [{}],
useLocation: () => ({ pathname: "", query: {} }),
useNavigate: () => () => undefined,
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+27
View File
@@ -0,0 +1,27 @@
# @opencode-ai/client
Private generation target for clients derived directly from OpenCode's authoritative Effect `HttpApi`.
## Entrypoints
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface contains `sessions.list`, `create`, `get`, `switchAgent`, `switchModel`, `prompt`, `compact`, `wait`, and `context`. The server and generator consume the exact same hosted `SessionGroup`. Run `bun run generate` after changing that contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. The authoritative `SessionGroup` lives in `@opencode-ai/protocol`; Server hosts that exact group and adapts it to Core.
The Promise root remains structural and has no Core or Effect runtime dependency. `/effect` depends only on Effect, Schema, and Protocol and is browser-bundle safe. Bundle-boundary tests enforce both import graphs.
Effect consumers construct canonical decoded inputs:
```ts
import { AbsolutePath, Location, OpenCode, Prompt } from "@opencode-ai/client/effect"
const client = yield * OpenCode.make({ baseUrl: "https://opencode.example" })
yield *
client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/workspace") }),
})
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
```
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./effect": "./src/effect.ts"
},
"scripts": {
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/generated src/generated-effect",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.83"
},
"peerDependenciesMeta": {
"effect": {
"optional": true
}
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:"
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NodeFileSystem } from "@effect/platform-node"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { Effect } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { fileURLToPath } from "url"
const contract = compile(HttpApi.make("opencode-client").add(SessionGroup), {
groupNames: { "server.session": "sessions" },
})
await Effect.runPromise(
Effect.all(
[
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write(
emitEffectImported(contract, {
module: "@opencode-ai/protocol/session",
group: "SessionGroup",
}),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
),
],
{ concurrency: 2, discard: true },
).pipe(Effect.provide(NodeFileSystem.layer)),
)
+12
View File
@@ -0,0 +1,12 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
export * from "./generated-effect/index"
export { Agent } from "@opencode-ai/schema/agent"
export { Location } from "@opencode-ai/schema/location"
export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Prompt } from "@opencode-ai/schema/prompt"
@@ -0,0 +1,5 @@
[
"client-error.ts",
"client.ts",
"index.ts"
]
@@ -0,0 +1,5 @@
import { Schema } from "effect"
export class ClientError extends Schema.TaggedErrorClass<ClientError>()("ClientError", {
cause: Schema.Defect(),
}) {}
@@ -0,0 +1,136 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import { Effect, Schema } from "effect"
import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http"
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { ClientError } from "./client-error"
const Api = HttpApi.make("generated").add(SessionGroup)
type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error })
: error
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint0_0Input = {
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
readonly limit?: Endpoint0_0Request["query"]["limit"]
readonly order?: Endpoint0_0Request["query"]["order"]
readonly search?: Endpoint0_0Request["query"]["search"]
readonly directory?: Endpoint0_0Request["query"]["directory"]
readonly project?: Endpoint0_0Request["query"]["project"]
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
}
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) =>
raw["session.list"]({
query: {
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint0_1Input = {
readonly id?: Endpoint0_1Request["payload"]["id"]
readonly agent?: Endpoint0_1Request["payload"]["agent"]
readonly model?: Endpoint0_1Request["payload"]["model"]
readonly location?: Endpoint0_1Request["payload"]["location"]
}
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) =>
raw["session.create"]({
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_2Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_2Input = { readonly sessionID: Endpoint0_2Request["params"]["sessionID"] }
const Endpoint0_2 = (raw: RawClient["server.session"]) => (input: Endpoint0_2Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_3Input = {
readonly sessionID: Endpoint0_3Request["params"]["sessionID"]
readonly agent: Endpoint0_3Request["payload"]["agent"]
}
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
readonly model: Endpoint0_4Request["payload"]["model"]
}
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
readonly id?: Endpoint0_5Request["payload"]["id"]
readonly prompt: Endpoint0_5Request["payload"]["prompt"]
readonly delivery?: Endpoint0_5Request["payload"]["delivery"]
readonly resume?: Endpoint0_5Request["payload"]["resume"]
}
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
raw["session.prompt"]({
params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_6Input = { readonly sessionID: Endpoint0_6Request["params"]["sessionID"] }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw),
create: Endpoint0_1(raw),
get: Endpoint0_2(raw),
switchAgent: Endpoint0_3(raw),
switchModel: Endpoint0_4(raw),
prompt: Endpoint0_5(raw),
compact: Endpoint0_6(raw),
wait: Endpoint0_7(raw),
context: Endpoint0_8(raw),
})
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
@@ -0,0 +1,2 @@
export { ClientError } from "./client-error"
export * as OpenCode from "./client"
@@ -0,0 +1,6 @@
[
"client-error.ts",
"client.ts",
"index.ts",
"types.ts"
]
@@ -0,0 +1,11 @@
export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"
export class ClientError extends Error {
override readonly name = "ClientError"
constructor(
readonly reason: ClientErrorReason,
options?: ErrorOptions,
) {
super(reason, options)
}
}
+309
View File
@@ -0,0 +1,309 @@
import type {
SessionsListInput,
SessionsListOutput,
SessionsCreateInput,
SessionsCreateOutput,
SessionsGetInput,
SessionsGetOutput,
SessionsSwitchAgentInput,
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
SessionsCompactOutput,
SessionsWaitInput,
SessionsWaitOutput,
SessionsContextInput,
SessionsContextOutput,
} from "./types"
import { ClientError } from "./client-error"
export interface ClientOptions {
readonly baseUrl: string
readonly fetch?: typeof globalThis.fetch
readonly headers?: HeadersInit
}
export interface RequestOptions {
readonly signal?: AbortSignal
readonly headers?: HeadersInit
}
interface RequestDescriptor {
readonly method: string
readonly path: string
readonly query?: Record<string, unknown>
readonly headers?: Record<string, unknown>
readonly body?: unknown
readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean
}
export function make(options: ClientOptions) {
const fetch = options.fetch ?? globalThis.fetch
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
const url = new URL(descriptor.path, options.baseUrl)
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
const headers = new Headers(options.headers)
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
if (value !== undefined && value !== null) headers.set(key, String(value))
}
for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)
if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")
return {
url,
init: {
method: descriptor.method,
signal: requestOptions?.signal,
headers,
body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),
} satisfies RequestInit,
}
}
const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
try {
const prepared = prepare(descriptor, requestOptions)
return await fetch(prepared.url, prepared.init)
} catch (cause) {
throw new ClientError("Transport", { cause })
}
}
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
}
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
if (descriptor.empty) {
try {
await response.body?.cancel()
} catch {}
return undefined as A
}
return (await json(response)) as A
}
const sse = <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable<A> => ({
async *[Symbol.asyncIterator]() {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) await responseError(response, descriptor)
if (!isContentType(response, "text/event-stream")) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType")
}
if (response.body === null) throw new ClientError("MalformedResponse")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
let next
try {
next = await reader.read()
} catch (cause) {
throw new ClientError("Transport", { cause })
}
buffer += decoder.decode(next.value, { stream: !next.done })
if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")
const trailingCarriageReturn = !next.done && buffer.endsWith("\r")
if (trailingCarriageReturn) buffer = buffer.slice(0, -1)
buffer = buffer.replaceAll("\r\n", "\n").replaceAll("\r", "\n")
if (trailingCarriageReturn) buffer += "\r"
if (next.done && buffer !== "") buffer += "\n\n"
let boundary = buffer.indexOf("\n\n")
while (boundary >= 0) {
const block = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const data = block
.split("\n")
.flatMap((line) => (line.startsWith("data:") ? [line.slice(5).trimStart()] : []))
.join("\n")
if (data !== "") {
try {
yield JSON.parse(data) as A
} catch (cause) {
throw new ClientError("MalformedResponse", { cause })
}
}
boundary = buffer.indexOf("\n\n")
}
if (next.done) return
}
} finally {
try {
await reader.cancel()
} catch {}
reader.releaseLock()
}
},
})
return {
sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>(
{
method: "GET",
path: `/api/session`,
query: {
workspace: input?.workspace,
limit: input?.limit,
order: input?.order,
search: input?.search,
directory: input?.directory,
project: input?.project,
subpath: input?.subpath,
cursor: input?.cursor,
},
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsCreateOutput }>(
{
method: "POST",
path: `/api/session`,
body: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 200,
declaredStatuses: [400, 404, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchAgentOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
body: { agent: input.agent },
successStatus: 204,
declaredStatuses: [400, 404, 401],
empty: true,
},
requestOptions,
),
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchModelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
body: { model: input.model },
successStatus: 204,
declaredStatuses: [400, 404, 401],
empty: true,
},
requestOptions,
),
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
body: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
successStatus: 200,
declaredStatuses: [409, 400, 404, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
request<SessionsCompactOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
request<SessionsWaitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsContextOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
successStatus: 200,
declaredStatuses: [404, 401, 500, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
}
}
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
if (value === undefined || value === null) return
if (Array.isArray(value)) {
for (const item of value) appendQuery(params, key, item)
return
}
if (typeof value === "object") {
for (const [child, item] of Object.entries(value)) appendQuery(params, `${key}[${child}]`, item)
return
}
params.append(key, String(value))
}
async function json(response: Response): Promise<unknown> {
if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {
try {
await response.body?.cancel()
} catch {}
throw new ClientError("UnsupportedContentType")
}
let text: string
try {
text = await response.text()
} catch (cause) {
throw new ClientError("Transport", { cause })
}
if (text === "") throw new ClientError("MalformedResponse")
try {
return JSON.parse(text)
} catch (cause) {
throw new ClientError("MalformedResponse", { cause })
}
}
function isContentType(response: Response, expected: string) {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
}
+3
View File
@@ -0,0 +1,3 @@
export { ClientError, type ClientErrorReason } from "./client-error"
export * as OpenCode from "./client"
export * from "./types"
+555
View File
@@ -0,0 +1,555 @@
export type JsonValue =
| null
| boolean
| number
| string
| ReadonlyArray<JsonValue>
| { readonly [key: string]: JsonValue }
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidCursorError"
export type InvalidRequestError = {
readonly _tag: "InvalidRequestError"
readonly message: string
readonly kind?: string | undefined
readonly field?: string | undefined
}
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "InvalidRequestError"
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnauthorizedError"
export type SessionNotFoundError = {
readonly _tag: "SessionNotFoundError"
readonly sessionID: string
readonly message: string
}
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "SessionNotFoundError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ConflictError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "ServiceUnavailableError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
readonly ref?: string | undefined
}
export const isUnknownError = (value: unknown): value is UnknownError =>
typeof value === "object" && value !== null && "_tag" in value && value._tag === "UnknownError"
export type SessionsListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["workspace"]
readonly limit?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["limit"]
readonly order?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["order"]
readonly search?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["search"]
readonly directory?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["directory"]
readonly project?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["project"]
readonly subpath?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["subpath"]
readonly cursor?: {
readonly workspace?: string | undefined
readonly limit?: string | undefined
readonly order?: "asc" | "desc" | undefined
readonly search?: string | undefined
readonly directory?: string | undefined
readonly project?: string | undefined
readonly subpath?: string | undefined
readonly cursor?: string | undefined
}["cursor"]
}
export type SessionsListOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
readonly subpath?: string | null
}>
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
export type SessionsCreateInput = {
readonly id?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["id"]
readonly agent?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["agent"]
readonly model?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["model"]
readonly location?: {
readonly id?: string | undefined
readonly agent?: string | undefined
readonly model?:
| { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
| undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string | undefined } | undefined
}["location"]
}
export type SessionsCreateOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
readonly subpath?: string | null
}
}["data"]
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsGetOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string | null } | null
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number | null }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string | null }
readonly subpath?: string | null
}
}["data"]
export type SessionsSwitchAgentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: { readonly agent: string }["agent"]
}
export type SessionsSwitchAgentOutput = void
export type SessionsSwitchModelInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly model: {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | undefined }
}["model"]
}
export type SessionsSwitchModelOutput = void
export type SessionsPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["id"]
readonly prompt: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["prompt"]
readonly delivery?: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["delivery"]
readonly resume?: {
readonly id?: string | undefined
readonly prompt: {
readonly text: string
readonly files?:
| ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | undefined
readonly description?: string | undefined
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
readonly agents?:
| ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | undefined
}>
| undefined
}
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}["resume"]
}
export type SessionsPromptOutput = {
readonly data: {
readonly admittedSeq: number
readonly id: string
readonly sessionID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | null
readonly description?: string | null
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
}
readonly delivery: "steer" | "queue"
readonly timeCreated: number
readonly promotedSeq?: number | null
}
}["data"]
export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsCompactOutput = void
export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsWaitOutput = void
export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsContextOutput = {
readonly data: ReadonlyArray<
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly type: "agent-switched"
readonly agent: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly type: "model-switched"
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | null
readonly description?: string | null
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly type: "user"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly sessionID: string
readonly text: string
readonly type: "synthetic"
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
readonly type: "system"
readonly text: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number; readonly completed?: number | null }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number; readonly completed?: number | null }
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string | null }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string }
| {
readonly type: "reasoning"
readonly id: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
}
| {
readonly type: "tool"
readonly id: string
readonly name: string
readonly provider?: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } | null
} | null
readonly state:
| { readonly status: "pending"; readonly input: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: JsonValue }
readonly structured: { readonly [x: string]: any }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: JsonValue }
readonly attachments?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string | null
readonly description?: string | null
readonly source?: { readonly start: number; readonly end: number; readonly text: string } | null
}> | null
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>
readonly outputPaths?: ReadonlyArray<string> | null
readonly structured: { readonly [x: string]: any }
readonly result?: JsonValue | null
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: JsonValue }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| {
readonly type: "file"
readonly uri: string
readonly mime: string
readonly name?: string | null
}
>
readonly structured: { readonly [x: string]: any }
readonly error: { readonly type: "unknown"; readonly message: string }
readonly result?: JsonValue | null
}
readonly time: {
readonly created: number
readonly ran?: number | null
readonly completed?: number | null
readonly pruned?: number | null
}
}
>
readonly snapshot?: { readonly start?: string | null; readonly end?: string | null } | null
readonly finish?: string | null
readonly cost?: number | null
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
} | null
readonly error?: { readonly type: "unknown"; readonly message: string } | null
}
| {
readonly type: "compaction"
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue } | null
readonly time: { readonly created: number }
}
>
}["data"]
+1
View File
@@ -0,0 +1 @@
export * from "./generated/index"
@@ -0,0 +1,50 @@
import { expect, test } from "bun:test"
import { Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Location as CoreLocation } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
import { Prompt as CorePrompt } from "@opencode-ai/core/session/prompt"
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Project } from "@opencode-ai/schema/project"
import { Provider } from "@opencode-ai/schema/provider"
import { Prompt } from "@opencode-ai/schema/prompt"
import { Session } from "@opencode-ai/schema/session"
import { SessionInput } from "@opencode-ai/schema/session-input"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { SessionGroup } from "@opencode-ai/protocol/session"
import { SessionGroup as ServerSessionGroup } from "@opencode-ai/server/groups/session"
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID)
expect(CoreLocation.Ref).toBe(Location.Ref)
expect(ModelV2.Ref).toBe(Model.Ref)
expect(SessionV2.Info).toBe(Session.Info)
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt)
expect(ServerSessionGroup).toBe(SessionGroup)
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.ID.fromExternal({ namespace: "opencord.agent-thread", key: "thread-1" })).toMatch(/^ses_[a-f0-9]{64}$/)
expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_")
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" })
expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)
expect(Object.getPrototypeOf(content)).toBe(Object.prototype)
expect(Prompt.ast.annotations?.identifier).toBe("Prompt")
expect(SessionMessage.AssistantText.ast.annotations?.identifier).toBe("Session.Message.Assistant.Text")
expect(CoreSessionMessage.AssistantText).toBe(SessionMessage.AssistantText)
})
+98
View File
@@ -0,0 +1,98 @@
import { expect, test } from "bun:test"
import { DateTime, Effect } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session } from "../src/effect"
test("sessions.get returns the decoded Effect projection", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
})
test("session methods retain decoded Effect inputs and outputs", async () => {
const httpClient = HttpClient.make((request) => {
const url = request.url
if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
}
if (url.includes("/context")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
}
if (request.method === "POST" && url.endsWith("/api/session")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session)))
}
if (request.method === "POST") {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
}
return Effect.succeed(
HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 })
const created = yield* client.sessions.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.sessions.switchModel({
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
})
const admitted = yield* client.sessions.prompt({
sessionID: Session.ID.make("ses_test"),
prompt: Prompt.make({ text: "Hello" }),
resume: false,
})
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
return { page, created, admitted, context }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
})
const session = {
data: {
id: "ses_test",
projectID: "project",
cost: 0,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: { read: 4, write: 5 },
},
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
},
title: "Test",
location: { directory: "/tmp/project" },
},
}
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
prompt: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import { realpathSync } from "node:fs"
import { mkdtemp, rm } from "node:fs/promises"
import { join, resolve, sep } from "node:path"
const directory = resolve(import.meta.dir, "..")
const effect = realpathSync(resolve(import.meta.dir, "../node_modules/effect"))
const schema = resolve(import.meta.dir, "../../schema")
const protocol = resolve(import.meta.dir, "../../protocol")
const core = resolve(import.meta.dir, "../../core")
const server = resolve(import.meta.dir, "../../server")
describe("public import boundaries", () => {
test("isolates each public entrypoint", async () => {
const root = await bundleInputs("@opencode-ai/client", "browser")
expect(within(root, effect)).toEqual([])
expect(within(root, schema)).toEqual([])
expect(within(root, protocol)).toEqual([])
expect(within(root, core)).toEqual([])
expect(within(root, server)).toEqual([])
const network = await bundleInputs("@opencode-ai/client/effect", "browser")
expect(within(network, effect).length).toBeGreaterThan(0)
expect(within(network, schema).length).toBeGreaterThan(0)
expect(within(network, protocol).length).toBeGreaterThan(0)
expect(within(network, core)).toEqual([])
expect(within(network, server)).toEqual([])
})
})
async function bundleInputs(specifier: string, target: "browser" | "bun") {
const temporary = await mkdtemp(join(import.meta.dir, ".import-boundary-"))
const entrypoint = join(temporary, "index.ts")
const metafile = join(temporary, "meta.json")
try {
await Bun.write(entrypoint, `export * from ${JSON.stringify(specifier)}`)
const child = Bun.spawn(
[
process.execPath,
"build",
entrypoint,
`--target=${target}`,
"--format=esm",
"--packages=bundle",
`--metafile=${metafile}`,
`--outdir=${join(temporary, "out")}`,
],
{ cwd: directory, stdout: "pipe", stderr: "pipe" },
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(stdout + stderr)
const metadata = await Bun.file(metafile).json()
return Object.keys(metadata.inputs).map((input) => resolve(directory, input))
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function within(inputs: ReadonlyArray<string>, directory: string) {
const prefix = directory.endsWith(sep) ? directory : directory + sep
return inputs.filter((input) => input === directory || input.startsWith(prefix))
}
+117
View File
@@ -0,0 +1,117 @@
import { expect, test } from "bun:test"
import { isUnauthorizedError, OpenCode } from "../src"
test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
expect(typeof input === "string" ? input : input instanceof URL ? input.href : input.url).toBe(
"http://localhost:3000/api/session/ses_test",
)
return Response.json(session)
},
})
const result = await client.sessions.get({ sessionID: "ses_test" })
expect(result.time.created).toBe(1_717_171_717_000)
})
test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push({ url, init })
if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } })
},
})
const page = await client.sessions.list({ limit: "10", order: "desc" })
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
const admitted = await client.sessions.prompt({
sessionID: "ses_test",
prompt: { text: "Hello" },
resume: false,
})
await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" })
expect(page.cursor.next).toBe("next")
expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test")
expect(context).toEqual([])
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/session?limit=10&order=desc"],
["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"],
["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
])
const body = requests[4]?.init?.body
if (typeof body !== "string") throw new Error("Expected JSON request body")
expect(JSON.parse(body)).toEqual({
prompt: { text: "Hello" },
resume: false,
})
})
test("middleware errors remain declared client errors", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 }),
})
try {
await client.sessions.create({})
throw new Error("Expected request to fail")
} catch (error) {
expect(isUnauthorizedError(error)).toBe(true)
}
})
const session = {
data: {
id: "ses_test",
projectID: "project",
cost: 0,
tokens: {
input: 1,
output: 2,
reasoning: 3,
cache: { read: 4, write: 5 },
},
time: {
created: 1_717_171_717_000,
updated: 1_717_171_717_000,
},
title: "Test",
location: { directory: "/tmp/project" },
},
}
const admission = {
data: {
admittedSeq: 0,
id: "msg_test",
sessionID: "ses_test",
prompt: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false
},
"include": ["src"]
}
+34 -4
View File
@@ -1,17 +1,47 @@
export * as AgentV2 from "./agent"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Array, Context, Effect, Layer, Schema, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { ModelV2 } from "./model"
import { PermissionSchema } from "./permission/schema"
import { ProviderV2 } from "./provider"
import { PositiveInt } from "./schema"
import { State } from "./state"
export const ID = Agent.ID
export type ID = typeof ID.Type
export const defaultID = ID.make("build")
export const Color = Agent.Color
export const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
export const Info = Agent.Info
export type Info = Agent.Info
export class Info extends Schema.Class<Info>("AgentV2.Info")({
id: ID,
model: ModelV2.Ref.pipe(Schema.optional),
request: ProviderV2.Request,
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]),
hidden: Schema.Boolean,
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset,
}) {
static empty(id: ID) {
return new Info({
id,
request: {
headers: {},
body: {},
},
mode: "all",
hidden: false,
permissions: [],
})
}
}
export interface Selection {
readonly id: ID
+3 -3
View File
@@ -73,7 +73,7 @@ export const layer = Layer.effect(
if (provider.disabled) return false
if (typeof provider.request.body.apiKey === "string") return true
if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration
return !integration
}
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
@@ -89,7 +89,7 @@ export const layer = Layer.effect(
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
variant: model.request.variant,
}
return ModelV2.Info.make({
return new ModelV2.Info({
...model,
api,
request,
@@ -184,7 +184,7 @@ export const layer = Layer.effect(
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
return (yield* result.provider.all()).filter((provider) =>
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
available(provider, active.get(Integration.ID.make(provider.id))),
)
}),
},
+11 -5
View File
@@ -1,11 +1,17 @@
export * as CommandV2 from "./command"
import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { ModelV2 } from "./model"
import { State } from "./state"
export const Info = Command.Info
export type Info = Command.Info
export class Info extends Schema.Class<Info>("CommandV2.Info")({
name: Schema.String,
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ModelV2.Ref.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
}) {}
export type Data = {
commands: Map<string, Types.DeepMutable<Info>>
@@ -34,7 +40,7 @@ export const layer = Layer.effect(
list: () => Array.from(draft.commands.values()) as Info[],
get: (name) => draft.commands.get(name),
update: (name, update) => {
const current = draft.commands.get(name) ?? ({ name, template: "" } as Types.DeepMutable<Info>)
const current = draft.commands.get(name) ?? (new Info({ name, template: "" }) as Types.DeepMutable<Info>)
if (!draft.commands.has(name)) draft.commands.set(name, current)
update(current)
current.name = name
+2 -2
View File
@@ -3,10 +3,10 @@ export * as Config from "./config"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { PermissionSchema } from "./permission/schema"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
@@ -56,7 +56,7 @@ export class Info extends Schema.Class<Info>("Config.Info")({
username: Schema.String.pipe(Schema.optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
permissions: PermissionSchema.Ruleset.pipe(Schema.optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
+2 -2
View File
@@ -1,7 +1,7 @@
export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSchema } from "../permission/schema"
import { ConfigProvider } from "./provider"
import { PositiveInt } from "../schema"
@@ -21,5 +21,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
permissions: PermissionSchema.Ruleset.pipe(Schema.optional),
}) {}
+2 -2
View File
@@ -28,7 +28,7 @@ export const Plugin = define({
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
@@ -36,7 +36,7 @@ export const Plugin = define({
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: Reference.GitSource.make({
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
+4 -7
View File
@@ -22,23 +22,20 @@ export const Plugin = define({
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
draft.source(new SkillV2.UrlSource({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
+28 -13
View File
@@ -2,26 +2,41 @@ export * as Credential from "./credential"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database"
import { IntegrationSchema } from "./integration/schema"
import { NonNegativeInt, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { CredentialTable } from "./credential/sql"
export const ID = Credential.ID
export type ID = Credential.ID
export const ID = Schema.String.pipe(
Schema.brand("Credential.ID"),
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const OAuth = Credential.OAuth
export type OAuth = Credential.OAuth
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
type: Schema.Literal("oauth"),
methodID: IntegrationSchema.MethodID,
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Key = Credential.Key
export type Key = Credential.Key
export class Key extends Schema.Class<Key>("Credential.Key")({
type: Schema.Literal("key"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Value = Credential.Value
export type Value = Credential.Value
export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" })
export type Value = Schema.Schema.Type<typeof Value>
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: Integration.ID,
integrationID: IntegrationSchema.ID,
label: Schema.String,
value: Value,
}) {}
@@ -30,12 +45,12 @@ export interface Interface {
/** Returns every stored credential. */
readonly all: () => Effect.Effect<Info[]>
/** Returns stored credentials belonging to one integration. */
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: Integration.ID
readonly integrationID: IntegrationSchema.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
+2 -1
View File
@@ -1,10 +1,11 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { IntegrationSchema } from "../integration/schema"
import type { Credential } from "../credential"
export const CredentialTable = sqliteTable("credential", {
id: text().$type<Credential.ID>().primaryKey(),
integration_id: text().$type<Credential.Info["integrationID"]>(),
integration_id: text().$type<IntegrationSchema.ID>(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
connector_id: text(),
-9
View File
@@ -1,9 +0,0 @@
export * as EventManifest from "./event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
export const Definitions = Event.inventory(...SessionV1.Events, ...SessionEvent.Definitions)
export const Latest = Event.latest(Definitions)
export const Durable = Event.durable(Definitions)
+95 -25
View File
@@ -1,19 +1,47 @@
export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { externalID, type ExternalID, withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util"
import { EventManifest } from "./event-manifest"
export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"),
withStatics((schema) => ({
create: () => schema.make("evt_" + Identifier.ascending()),
fromExternal: (input: ExternalID) => schema.make(externalID("evt", input)),
})),
)
export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema
}
export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
export type Payload<D extends Definition = Definition> = {
readonly id: ID
readonly type: D["type"]
readonly data: Data<D>
readonly durable?: {
readonly aggregateID: string
readonly seq: number
readonly version: number
}
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
}
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
@@ -47,8 +75,52 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
},
) {}
export const define = Event.define
export const versionedType = Event.versionedType
export function versionedType(type: string, version: number) {
return `${type}.${version}`
}
export const registry = new Map<string, Definition>()
const durableRegistry = new Map<string, Definition>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly durable?: {
readonly version: number
readonly aggregate: string
}
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
const Payload = Schema.Struct({
id: ID,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable: Schema.optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })),
location: Schema.optional(Location.Ref),
data: Data,
}).annotate({ identifier: input.type })
const definition = Object.assign(Payload, {
type: input.type,
...(input.durable === undefined ? {} : { durable: input.durable }),
data: Data,
})
const existing = registry.get(input.type)
if (
input.durable === undefined ||
existing?.durable === undefined ||
input.durable.version >= existing.durable.version
) {
registry.set(input.type, definition)
}
if (input.durable) durableRegistry.set(versionedType(input.type, input.durable.version), definition)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
export function definitions() {
return registry.values().toArray()
}
export interface PublishOptions {
readonly id?: ID
@@ -86,21 +158,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ev
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
readonly definitions?: ReadonlyArray<Definition>
}
export const layerWith = (options?: LayerOptions) =>
Layer.effect(
Service,
Effect.gen(function* () {
const durableDefinitions = Event.durable([...EventManifest.Definitions, ...(options?.definitions ?? [])])
const pubsub = {
all: yield* PubSub.unbounded<Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
// Projectors intentionally retain type-only dispatch. Exact type+version dispatch is a separate replay design.
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
@@ -126,7 +195,6 @@ export const layerWith = (options?: LayerOptions) =>
)
function commitDurableEvent(
definition: Definition,
event: Payload,
input?: {
readonly seq: number
@@ -137,6 +205,7 @@ export const layerWith = (options?: LayerOptions) =>
commit?: (seq: number) => Effect.Effect<void>,
) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
const durable = definition?.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
@@ -170,10 +239,9 @@ export const layerWith = (options?: LayerOptions) =>
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
const encoded = Schema.encodeUnknownSync(
definition.data as Schema.Codec<unknown, unknown, never, never>,
)(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
@@ -289,8 +357,9 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
function publishEvent<D extends Definition>(event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
const definition = registry.get(event.type)
if (!definition?.durable && commit)
return yield* Effect.die(
new InvalidDurableEventError({
@@ -299,7 +368,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
if (definition?.durable) {
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
const committed = yield* commitDurableEvent(event as Payload, undefined, commit)
if (committed) {
event = {
...event,
@@ -348,7 +417,6 @@ export const layerWith = (options?: LayerOptions) =>
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent(
definition,
{
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
@@ -366,7 +434,7 @@ export const layerWith = (options?: LayerOptions) =>
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
) {
return Effect.gen(function* () {
const definition = durableDefinitions.get(event.type)
const definition = durableRegistry.get(event.type)
if (!definition?.durable) {
yield* Effect.die(
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
@@ -375,9 +443,11 @@ export const layerWith = (options?: LayerOptions) =>
const payload = {
id: event.id,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(
event.data,
),
} as Payload
const committed = yield* commitDurableEvent(definition, payload, {
const committed = yield* commitDurableEvent(payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
@@ -461,8 +531,8 @@ export const layerWith = (options?: LayerOptions) =>
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent) => {
const definition = durableDefinitions.get(event.type)
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = durableRegistry.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
@@ -470,7 +540,7 @@ export const layerWith = (options?: LayerOptions) =>
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data),
data: Schema.decodeUnknownSync(definition.data as Schema.Codec<unknown, unknown, never, never>)(event.data),
}
}
+4 -3
View File
@@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
export { Entry, Match, Submatch } from "@opencode-ai/schema/filesystem"
import { Entry, Match } from "./filesystem/schema"
export { Entry, Match, Submatch } from "./filesystem/schema"
export const ReadInput = Schema.Struct({
path: RelativePath,
@@ -108,9 +108,10 @@ const baseLayer = Layer.effect(
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
Entry.make({
new Entry({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
}),
]
})
+23
View File
@@ -0,0 +1,23 @@
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const Submatch = Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
})
export type Submatch = typeof Submatch.Type
export class Match extends Schema.Class<Match>("FileSystem.Match")({
entry: Entry,
line: PositiveInt,
offset: NonNegativeInt,
text: Schema.String,
submatches: Schema.Array(Submatch),
}) {}
+30 -20
View File
@@ -59,11 +59,12 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
@@ -84,14 +85,15 @@ export const ripgrepLayer = Layer.effect(
})
.pipe(
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
}),
),
),
Effect.orDie,
@@ -108,9 +110,12 @@ export const ripgrepLayer = Layer.effect(
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
return FileSystem.Entry.make({
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
const absolute = path.resolve(location.directory, clean)
return new FileSystem.Entry({
path: RelativePath.make(relative),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
@@ -143,12 +148,14 @@ export const fffLayer = Layer.effect(
pageSize: input.limit,
})
if (!found.ok) throw found.error
return found.value.items.map((item) =>
FileSystem.Entry.make({
return found.value.items.map((item) => {
const absolute = path.resolve(location.directory, item.relativePath)
return new FileSystem.Entry({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
}),
)
mime: FSUtil.mimeType(absolute),
})
})
}),
grep: (input) =>
Effect.sync(() => {
@@ -162,10 +169,11 @@ export const fffLayer = Layer.effect(
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return FileSystem.Match.make({
entry: FileSystem.Entry.make({
return new FileSystem.Match({
entry: new FileSystem.Entry({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(match.relativePath),
}),
line: match.lineNumber,
offset: match.byteOffset,
@@ -212,9 +220,11 @@ export const fffLayer = Layer.effect(
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
.map((item) => {
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
return FileSystem.Entry.make({
const absolute = path.resolve(location.directory, relative)
return new FileSystem.Entry({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
+1 -6
View File
@@ -14,12 +14,7 @@ export namespace FSUtil {
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
method: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {
override get message() {
const detail = this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause)
return `Filesystem operation failed: ${this.method}${detail ? `: ${detail}` : ""}`
}
}
}) {}
export type Error = PlatformError | FileSystemError
+35 -2
View File
@@ -1,4 +1,4 @@
import { create as createIdentifier } from "@opencode-ai/schema/identifier"
import { randomBytes } from "crypto"
const prefixes = {
job: "job",
@@ -13,6 +13,12 @@ const prefixes = {
workspace: "wrk",
} as const
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
@@ -32,8 +38,35 @@ function generateID(prefix: keyof typeof prefixes, direction: "descending" | "as
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
return prefix + "_" + createIdentifier(direction === "descending", timestamp)
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
+76 -72
View File
@@ -14,19 +14,19 @@ import {
SynchronizedRef,
Types,
} from "effect"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { IntegrationSchema } from "./integration/schema"
import { withStatics } from "./schema"
import { State } from "./state"
import { Identifier } from "./util/identifier"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
export const ID = Integration.ID
export type ID = Integration.ID
export const ID = IntegrationSchema.ID
export type ID = IntegrationSchema.ID
export const MethodID = Integration.MethodID
export type MethodID = Integration.MethodID
export const MethodID = IntegrationSchema.MethodID
export type MethodID = IntegrationSchema.MethodID
export const AttemptID = Schema.String.pipe(
Schema.brand("Integration.AttemptID"),
@@ -34,29 +34,64 @@ export const AttemptID = Schema.String.pipe(
)
export type AttemptID = typeof AttemptID.Type
export const When = Integration.When
export type When = Integration.When
export const When = Schema.Struct({
key: Schema.String,
op: Schema.Literals(["eq", "neq"]),
value: Schema.String,
}).annotate({ identifier: "Integration.When" })
export type When = typeof When.Type
export const TextPrompt = Integration.TextPrompt
export type TextPrompt = Integration.TextPrompt
export const TextPrompt = Schema.Struct({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}).annotate({ identifier: "Integration.TextPrompt" })
export type TextPrompt = typeof TextPrompt.Type
export const SelectPrompt = Integration.SelectPrompt
export type SelectPrompt = Integration.SelectPrompt
export const SelectPrompt = Schema.Struct({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.mutable(
Schema.Array(
Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}),
),
),
when: Schema.optional(When),
}).annotate({ identifier: "Integration.SelectPrompt" })
export type SelectPrompt = typeof SelectPrompt.Type
export const Prompt = Integration.Prompt
export type Prompt = Integration.Prompt
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export const OAuthMethod = Integration.OAuthMethod
export type OAuthMethod = Integration.OAuthMethod
export const OAuthMethod = Schema.Struct({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.mutable(Schema.Array(Prompt))),
}).annotate({ identifier: "Integration.OAuthMethod" })
export type OAuthMethod = typeof OAuthMethod.Type
export const KeyMethod = Integration.KeyMethod
export type KeyMethod = Integration.KeyMethod
export const KeyMethod = Schema.Struct({
type: Schema.Literal("key"),
label: Schema.optional(Schema.String),
}).annotate({ identifier: "Integration.KeyMethod" })
export type KeyMethod = typeof KeyMethod.Type
export const EnvMethod = Integration.EnvMethod
export type EnvMethod = Integration.EnvMethod
export const EnvMethod = Schema.Struct({
type: Schema.Literal("env"),
names: Schema.mutable(Schema.Array(Schema.String)),
}).annotate({ identifier: "Integration.EnvMethod" })
export type EnvMethod = typeof EnvMethod.Type
export const Method = Integration.Method
export type Method = Integration.Method
export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Integration.Info")({
id: ID,
@@ -65,8 +100,7 @@ export class Info extends Schema.Class<Info>("Integration.Info")({
connections: Schema.mutable(Schema.Array(IntegrationConnection.Info)),
}) {}
export const Inputs = Integration.Inputs
export type Inputs = Integration.Inputs
export type Inputs = Readonly<{ [key: string]: string }>
export type OAuthAuthorization = {
readonly url: string
@@ -74,11 +108,11 @@ export type OAuthAuthorization = {
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.OAuth, unknown>
readonly callback: Effect.Effect<Credential.Value, unknown>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.OAuth, unknown>
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
}
)
@@ -87,7 +121,6 @@ export interface OAuthImplementation {
readonly method: OAuthMethod
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
readonly label?: (credential: Credential.OAuth) => string | undefined
}
export interface KeyImplementation {
@@ -102,6 +135,10 @@ export interface EnvImplementation {
export type Implementation = OAuthImplementation | KeyImplementation | EnvImplementation
function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation {
return implementation.method.type === "oauth"
}
export class Attempt extends Schema.Class<Attempt>("Integration.Attempt")({
attemptID: AttemptID,
url: Schema.String,
@@ -141,14 +178,12 @@ export const Event = {
type: "integration.updated",
schema: {},
}),
ConnectionUpdated: EventV2.define({
type: "integration.connection.updated",
schema: { integrationID: ID },
}),
}
export const Ref = Integration.Ref
export type Ref = Integration.Ref
export type Ref = {
id: ID
name: string
}
type Entry = {
ref: Types.DeepMutable<Ref>
@@ -180,11 +215,7 @@ export interface Interface extends State.Transformable<Draft> {
readonly list: () => Effect.Effect<Info[]>
readonly connection: {
/** Returns the active connection for one integration. */
readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Resolves a connection into usable credential material. */
readonly resolve: (
connection: IntegrationConnection.Info,
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
readonly forIntegration: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Integration receiving the credential. */
@@ -354,7 +385,7 @@ export const locationLayer = Layer.effect(
return error instanceof Error ? error.message : String(error)
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
@@ -366,13 +397,14 @@ export const locationLayer = Layer.effect(
})
if (!result) return
if (Exit.isSuccess(exit)) {
const implementation = state.get().integrations.get(result.integrationID)?.implementations.get(result.methodID)
yield* credentials.create({
integrationID: result.integrationID,
label: result.label ?? implementation?.label?.(exit.value),
value: exit.value,
label: result.label,
value:
exit.value.type === "oauth"
? new Credential.OAuth({ ...exit.value, methodID: result.methodID })
: exit.value,
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: result.integrationID })
yield* events.publish(Event.Updated, {})
}
yield* close(result.scope)
@@ -413,29 +445,10 @@ export const locationLayer = Layer.effect(
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
active: Effect.fn("Integration.connection.active")(function* (id) {
forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) {
const entry = state.get().integrations.get(id)
return resolveConnections(entry, yield* credentials.list(id))[0]
}),
resolve: Effect.fn("Integration.connection.resolve")(function* (connection) {
if (connection.type === "env") {
const key = process.env[connection.name]
return key ? Credential.Key.make({ type: "key", key }) : undefined
}
const credential = yield* credentials.get(connection.id)
if (!credential) return undefined
if (credential.value.type === "key") return credential.value
const implementation = state
.get()
.integrations.get(credential.integrationID)
?.implementations.get(credential.value.methodID)
if (!implementation?.refresh) return credential.value
const now = yield* Clock.currentTimeMillis
if (credential.value.expires > now + Duration.toMillis(Duration.minutes(5))) return credential.value
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
return value
}),
key: Effect.fn("Integration.connection.key")(function* (input) {
const method = state
.get()
@@ -445,9 +458,8 @@ export const locationLayer = Layer.effect(
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({ type: "key", key: input.key }),
value: new Credential.Key({ type: "key", key: input.key }),
})
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* events.publish(Event.Updated, {})
}),
oauth: Effect.fn("Integration.connection.oauth")(function* (input) {
@@ -491,19 +503,11 @@ export const locationLayer = Layer.effect(
})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* events.publish(Event.Updated, {})
}),
},
+17 -7
View File
@@ -1,12 +1,22 @@
export * as IntegrationConnection from "./connection"
import { Connection } from "@opencode-ai/schema/connection"
import { Schema } from "effect"
import { Credential } from "../credential"
export const CredentialInfo = Connection.CredentialInfo
export type CredentialInfo = Connection.CredentialInfo
export const CredentialInfo = Schema.Struct({
type: Schema.Literal("credential"),
id: Credential.ID,
label: Schema.String,
}).annotate({ identifier: "Connection.CredentialInfo" })
export type CredentialInfo = typeof CredentialInfo.Type
export const EnvInfo = Connection.EnvInfo
export type EnvInfo = Connection.EnvInfo
export const EnvInfo = Schema.Struct({
type: Schema.Literal("env"),
name: Schema.String,
}).annotate({ identifier: "Connection.EnvInfo" })
export type EnvInfo = typeof EnvInfo.Type
export const Info = Connection.Info
export type Info = Connection.Info
export const Info = Schema.Union([CredentialInfo, EnvInfo])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Connection.Info" })
export type Info = typeof Info.Type
+9
View File
@@ -0,0 +1,9 @@
export * as IntegrationSchema from "./schema"
import { Schema } from "effect"
export const ID = Schema.String.pipe(Schema.brand("Integration.ID"))
export type ID = typeof ID.Type
export const MethodID = Schema.String.pipe(Schema.brand("Integration.MethodID"))
export type MethodID = typeof MethodID.Type
+82 -26
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, LayerMap } from "effect"
import { Context, Data, Effect, Layer, LayerMap, Scope } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
@@ -48,12 +48,48 @@ import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
class LocationRefKey extends Data.Class<{
readonly directory: Location.Ref["directory"]
readonly workspaceID: Location.Ref["workspaceID"] | null
}> {}
const locationRefKey = (ref: Location.Ref) =>
new LocationRefKey({ directory: ref.directory, workspaceID: ref.workspaceID ?? null })
const locationServiceDependencies = [
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
] as const
class LocationServiceCache extends LayerMap.Service<LocationServiceCache>()("@opencode/example/LocationServiceCache", {
lookup: (ref: ReturnType<typeof locationRefKey>) => {
const locationRef = Location.Ref.make({
directory: ref.directory,
...(ref.workspaceID === null ? {} : { workspaceID: ref.workspaceID }),
})
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
Effect.logInfo("booting location services", {
directory: locationRef.directory,
workspaceID: locationRef.workspaceID,
}),
)
const location = Location.layer(ref)
const location = Location.layer(locationRef)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
location,
@@ -123,25 +159,45 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
],
dependencies: [...locationServiceDependencies, ApplicationTools.layer],
}) {}
type LocationServices = Layer.Success<ReturnType<typeof LocationServiceCache.get>>
export interface LocationServiceMapService {
readonly get: (ref: Location.Ref) => Layer.Layer<LocationServices>
readonly contextEffect: (ref: Location.Ref) => Effect.Effect<Context.Context<LocationServices>, never, Scope.Scope>
readonly invalidate: (ref: Location.Ref) => Effect.Effect<void>
}
const locationServiceMapLayer = <E, R>(
service: Context.Service<LocationServiceMap, LocationServiceMapService>,
cache: Layer.Layer<LocationServiceCache, E, R>,
) =>
Layer.effect(
service,
Effect.map(LocationServiceCache, (locations) =>
service.of({
get: (ref) => locations.get(locationRefKey(ref)),
contextEffect: (ref) => locations.contextEffect(locationRefKey(ref)),
invalidate: (ref) => locations.invalidate(locationRefKey(ref)),
}),
),
).pipe(Layer.provide(cache))
export class LocationServiceMap extends Context.Service<LocationServiceMap, LocationServiceMapService>()(
"@opencode/example/LocationServiceMap",
) {
static readonly get = (ref: Location.Ref) =>
Layer.unwrap(Effect.map(LocationServiceMap, (locations) => locations.get(ref)))
static readonly contextEffect = (ref: Location.Ref) =>
Effect.flatMap(LocationServiceMap, (locations) => locations.contextEffect(ref))
static readonly invalidate = (ref: Location.Ref) =>
Effect.flatMap(LocationServiceMap, (locations) => locations.invalidate(ref))
static readonly layer: Layer.Layer<LocationServiceMap> = locationServiceMapLayer(this, LocationServiceCache.layer)
static readonly layerWithApplicationTools: Layer.Layer<LocationServiceMap, never, ApplicationTools.Service> =
locationServiceMapLayer(
this,
LocationServiceCache.layerNoDeps.pipe(Layer.provide(Layer.mergeAll(...locationServiceDependencies))),
)
}
+3 -2
View File
@@ -1,12 +1,13 @@
import { Context, Effect, Layer, Schema } from "effect"
import { Ref } from "@opencode-ai/schema/location"
import { Location as SchemaLocation } from "@opencode-ai/schema/location"
import { Project } from "./project"
import { AbsolutePath, optionalOmitUndefined } from "./schema"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export { Ref }
export const Ref = SchemaLocation.Ref
export type Ref = SchemaLocation.Ref
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
+27 -5
View File
@@ -1,12 +1,34 @@
export * as ModelRequest from "./model-request"
import { ModelRequest } from "@opencode-ai/schema/model-request"
import { Effect, Schema } from "effect"
export const Generation = ModelRequest.Generation
export type Generation = ModelRequest.Generation
export const Generation = Schema.Struct({
maxTokens: Schema.Number.pipe(Schema.optional),
temperature: Schema.Number.pipe(Schema.optional),
topP: Schema.Number.pipe(Schema.optional),
topK: Schema.Number.pipe(Schema.optional),
frequencyPenalty: Schema.Number.pipe(Schema.optional),
presencePenalty: Schema.Number.pipe(Schema.optional),
seed: Schema.Number.pipe(Schema.optional),
stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional),
})
export type Generation = typeof Generation.Type
export const Request = ModelRequest.Request
export type Request = ModelRequest.Request
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
generation: Generation.pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed({})),
Schema.withDecodingDefaultKey(Effect.succeed({})),
),
options: Schema.Record(Schema.String, Schema.Any).pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed({})),
Schema.withDecodingDefaultKey(Effect.succeed({})),
),
})
export type Request = typeof Request.Type
interface MutableRequest {
headers: Record<string, string>
+96 -10
View File
@@ -1,6 +1,7 @@
import { Types } from "effect"
import { Schema, Types } from "effect"
import { Model } from "@opencode-ai/schema/model"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
export const ID = Model.ID
export type ID = typeof ID.Type
@@ -9,22 +10,107 @@ export const VariantID = Model.VariantID
export type VariantID = typeof VariantID.Type
// Grouping of models, eg claude opus, claude sonnet
export const Family = Model.Family
export type Family = Model.Family
export const Family = Schema.String.pipe(Schema.brand("Family"))
export type Family = typeof Family.Type
export const Capabilities = Model.Capabilities
export type Capabilities = Model.Capabilities
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
// mime patterns, image, audio, video/*, text/*
input: Schema.String.pipe(Schema.Array, Schema.mutable),
output: Schema.String.pipe(Schema.Array, Schema.mutable),
})
export type Capabilities = typeof Capabilities.Type
export const Cost = Model.Cost
export const Cost = Schema.Struct({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
input: Schema.Finite,
output: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
export const Ref = Model.Ref
export type Ref = typeof Ref.Type
export const Api = Model.Api
export type Api = Model.Api
export const Api = Schema.Union([
Schema.Struct({
id: ID,
...ProviderV2.AISDK.fields,
}),
Schema.Struct({
id: ID,
...ProviderV2.Native.fields,
}),
]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export const Info = Model.Info
export type Info = Model.Info
export class Info extends Schema.Class<Info>("ModelV2.Info")({
id: ID,
providerID: ProviderV2.ID,
family: Family.pipe(Schema.optional),
name: Schema.String,
api: Api,
capabilities: Capabilities,
request: Schema.Struct({
...ModelRequest.Request.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...ModelRequest.Request.fields,
}).pipe(Schema.Array, Schema.mutable),
time: Schema.Struct({
released: Schema.Finite,
}),
cost: Cost.pipe(Schema.Array, Schema.mutable),
status: Schema.Literals(["alpha", "beta", "deprecated", "active"]),
enabled: Schema.Boolean,
limit: Schema.Struct({
context: Schema.Int,
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int,
}),
}) {
static empty(providerID: ProviderV2.ID, modelID: ID): Info {
return new Info({
id: modelID,
providerID,
name: modelID,
api: {
id: modelID,
type: "native",
settings: {},
},
capabilities: {
tools: false,
input: [],
output: [],
},
request: {
headers: {},
body: {},
generation: {},
options: {},
},
variants: [],
time: {
released: 0,
},
cost: [],
status: "active",
enabled: true,
limit: {
context: 0,
output: 0,
},
})
}
}
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & {
api: ProviderV2.MutableApi<Api>
+14 -11
View File
@@ -1,7 +1,6 @@
export * as PermissionV2 from "./permission"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { EventV2 } from "./event"
import { Location } from "./location"
import { AgentV2 } from "./agent"
@@ -10,10 +9,14 @@ import { SessionStore } from "./session/store"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { Wildcard } from "./util/wildcard"
import { PermissionSchema } from "./permission/schema"
import { PermissionSaved } from "./permission/saved"
export { Effect, Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export { Effect, Rule, Ruleset } from "./permission/schema"
type Effect = PermissionSchema.Effect
type Rule = PermissionSchema.Rule
type Ruleset = PermissionSchema.Ruleset
const missingAgentPermissions: Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionV2.ID"),
@@ -64,7 +67,7 @@ export type ReplyInput = typeof ReplyInput.Type
export const AskResult = Schema.Struct({
id: ID,
effect: Permission.Effect,
effect: PermissionSchema.Effect,
}).annotate({ identifier: "PermissionV2.AskResult" })
export type AskResult = typeof AskResult.Type
@@ -87,7 +90,7 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
}) {}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
rules: Permission.Ruleset,
rules: PermissionSchema.Ruleset,
}) {}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
@@ -96,7 +99,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Per
export type Error = DeniedError | RejectedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
export function evaluate(action: string, resource: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
@@ -108,7 +111,7 @@ export function evaluate(action: string, resource: string, ...rulesets: Permissi
)
}
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
@@ -153,7 +156,7 @@ export const layer = Layer.effect(
const savedRules = EffectRuntime.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Permission.Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
(item): Rule => ({ action: item.action, resource: item.resource, effect: "allow" }),
)
})
@@ -167,11 +170,11 @@ export const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
function denied(input: AssertInput, rules: Permission.Ruleset) {
function denied(input: AssertInput, rules: Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Permission.Ruleset) {
function relevant(input: AssertInput, rules: Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
@@ -180,7 +183,7 @@ export const layer = Layer.effect(
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
const effect: Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
return { effect, rules: all }
})
@@ -1,16 +1,16 @@
export * as Permission from "./permission"
export * as PermissionSchema from "./schema"
import { Schema } from "effect"
export const Effect = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Effect" })
export type Effect = typeof Effect.Type
export interface Rule extends Schema.Schema.Type<typeof Rule> {}
export const Rule = Schema.Struct({
action: Schema.String,
resource: Schema.String,
effect: Effect,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.mutable(Schema.Array(Rule)).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
-61
View File
@@ -6,7 +6,6 @@ import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Credential } from "../credential"
import { Integration } from "../integration"
import { ModelV2 } from "../model"
import { PluginV2 } from "../plugin"
@@ -98,13 +97,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
integration: {
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
resolve: (connection) =>
integration.connection.resolve(
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
},
transform: (callback) =>
integration.transform((draft) =>
callback({
@@ -115,59 +107,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
method: {
list: (id) => draft.method.list(Integration.ID.make(id)),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}
return {
...authorization,
callback: (code: string) =>
authorization.callback(code).pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}),
),
...(refresh
? {
refresh: (value: Credential.OAuth) =>
refresh(value).pipe(
Effect.map((next) =>
Credential.OAuth.make({
...next,
methodID: Integration.MethodID.make(next.methodID),
}),
),
),
}
: {}),
...(input.label ? { label: input.label } : {}),
})
return
}
if (input.method.type === "env") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
-5
View File
@@ -23,7 +23,6 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { Reference } from "../reference"
import { SkillV2 } from "../skill"
import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
@@ -39,7 +38,6 @@ export type Requirements =
| FileSystem.Service
| FSUtil.Service
| Global.Service
| HttpClient.HttpClient
| Integration.Service
| Location.Service
| ModelsDev.Service
@@ -71,7 +69,6 @@ export const locationLayer = Layer.effectDiscard(
const fs = yield* FSUtil.Service
const filesystem = yield* FileSystem.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const skill = yield* SkillV2.Service
const reference = yield* Reference.Service
const add = <R>(input: Plugin<R>) => {
@@ -93,7 +90,6 @@ export const locationLayer = Layer.effectDiscard(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(FileSystem.Service, filesystem),
Effect.provideService(Global.Service, global),
Effect.provideService(HttpClient.HttpClient, http),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, reference),
),
@@ -119,5 +115,4 @@ export const locationLayer = Layer.effectDiscard(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(FetchHttpClient.layer),
)
-4
View File
@@ -65,10 +65,6 @@ export function fromPromise(plugin: Plugin) {
integration: {
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
connection: {
active: (id) => Effect.runPromiseWith(context)(host.integration.connection.active(id)),
resolve: (connection) => Effect.runPromiseWith(context)(host.integration.connection.resolve(connection)),
},
},
plugin: {
add: (input) => {
@@ -0,0 +1,257 @@
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Integration } from "../../integration"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
type Pkce = {
verifier: string
challenge: string
}
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type Claims = {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
}
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
export const browser = {
integrationID: Integration.ID.make("openai"),
method: {
id: browserMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.close()
}),
)
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, redirect, pkce)),
Effect.map((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(value),
} satisfies Integration.OAuthImplementation
export const headless = {
integrationID: Integration.ID.make("openai"),
method: {
id: headlessMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
},
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
`${issuer}/api/accounts/deviceauth/usercode`,
{
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ client_id: clientID }),
},
)
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
return {
mode: "auto" as const,
url: `${issuer}/codex/device`,
instructions: `Enter code: ${device.user_code}`,
callback: Effect.gen(function* () {
while (true) {
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(`${issuer}/api/accounts/deviceauth/token`, {
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
signal,
}),
catch: (cause) => cause,
})
if (response.ok) {
const data = (yield* Effect.promise(() => response.json())) as {
authorization_code: string
code_verifier: string
}
return credential(
headlessMethodID,
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
verifier: data.code_verifier,
challenge: "",
}),
)
}
if (response.status !== 403 && response.status !== 404) {
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
}
yield* Effect.sleep(interval + pollingSafetyMargin)
}
}),
}
}),
refresh: (value) => refresh(value),
} satisfies Integration.OAuthImplementation
function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
}
function exchange(code: string, redirect: string, pkce: Pkce) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
})
}
function refresh(value: Credential.OAuth) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
}).pipe(
Effect.map((tokens) => {
const next = credential(value.methodID, tokens)
return new Credential.OAuth({
...next,
metadata: next.metadata ?? value.metadata,
})
}),
)
}
function request<A>(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json() as Promise<A>
},
catch: (cause) => cause,
})
}
function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
const accountID = extractAccountID(tokens)
return new Credential.OAuth({
type: "oauth",
methodID,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
metadata: accountID ? { accountID } : undefined,
})
}
async function generatePKCE(): Promise<Pkce> {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer) {
return Buffer.from(buffer).toString("base64url")
}
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
return `${issuer}/oauth/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirect,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})}`
}
function extractAccountID(tokens: TokenResponse) {
return claim(tokens.id_token) ?? claim(tokens.access_token)
}
function claim(token: string) {
const part = token.split(".")[1]
if (!part) return
try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return (
claims.chatgpt_account_id ??
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
claims.organizations?.[0]?.id
)
} catch {
return
}
}
const successPage =
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
+7 -255
View File
@@ -1,155 +1,15 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Deferred, Effect } from "effect"
import type { Scope } from "effect"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
import { Integration } from "../../integration"
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import type { PluginInternal } from "../internal"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
type Pkce = {
verifier: string
challenge: string
}
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type Claims = {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
}
const browser = {
integrationID: Integration.ID.make("openai"),
method: {
id: browserMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
},
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, redirect, pkce)),
Effect.map((tokens) => credential(browserMethodID, tokens)),
),
}
}),
refresh: (value) => refresh(browserMethodID, value),
} satisfies IntegrationOAuthMethodRegistration
const headless = {
integrationID: Integration.ID.make("openai"),
method: {
id: headlessMethodID,
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
},
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
`${issuer}/api/accounts/deviceauth/usercode`,
{
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ client_id: clientID }),
},
)
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
return {
mode: "auto" as const,
url: `${issuer}/codex/device`,
instructions: `Enter code: ${device.user_code}`,
callback: Effect.gen(function* () {
while (true) {
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(`${issuer}/api/accounts/deviceauth/token`, {
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
signal,
}),
catch: (cause) => cause,
})
if (response.ok) {
const data = (yield* Effect.promise(() => response.json())) as {
authorization_code: string
code_verifier: string
}
return credential(
headlessMethodID,
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
verifier: data.code_verifier,
challenge: "",
}),
)
}
if (response.status !== 403 && response.status !== 404) {
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
}
yield* Effect.sleep(interval + pollingSafetyMargin)
}
}),
}
}),
refresh: (value) => refresh(headlessMethodID, value),
} satisfies IntegrationOAuthMethodRegistration
import { Integration } from "../../integration"
import { browser, headless } from "./openai-auth"
export const OpenAIPlugin = define({
id: "openai",
effect: Effect.fn(function* (ctx) {
yield* ctx.integration.transform((draft) => {
const integrations = yield* Integration.Service
yield* integrations.transform((draft) => {
draft.method.update(browser)
draft.method.update(headless)
})
@@ -181,112 +41,4 @@ export const OpenAIPlugin = define({
}),
)
}),
} satisfies PluginInternal.Plugin<PluginInternal.Requirements | Scope.Scope>)
function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
}
function exchange(code: string, redirect: string, pkce: Pkce) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
})
}
function refresh(methodID: Integration.MethodID, value: Pick<Credential.OAuth, "refresh" | "metadata">) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
}).pipe(
Effect.map((tokens) => {
const next = credential(methodID, tokens)
return Credential.OAuth.make({ ...next, metadata: next.metadata ?? value.metadata })
}),
)
}
function request<A>(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json() as Promise<A>
},
catch: (cause) => cause,
})
}
function credential(methodID: Integration.MethodID, tokens: TokenResponse) {
const accountID = extractAccountID(tokens)
return Credential.OAuth.make({
type: "oauth",
methodID,
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
metadata: accountID ? { accountID } : undefined,
})
}
async function generatePKCE(): Promise<Pkce> {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer) {
return Buffer.from(buffer).toString("base64url")
}
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
return `${issuer}/oauth/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirect,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})}`
}
function extractAccountID(tokens: TokenResponse) {
return claim(tokens.id_token) ?? claim(tokens.access_token)
}
function claim(token: string) {
const part = token.split(".")[1]
if (!part) return
try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return (
claims.chatgpt_account_id ??
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
claims.organizations?.[0]?.id
)
} catch {
return
}
}
const successPage =
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
})
+22 -304
View File
@@ -1,314 +1,32 @@
import { Duration, Effect, Schema, Stream } from "effect"
import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import type { CredentialValue } from "@opencode-ai/sdk/v2/types"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { EventV2 } from "../../event"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { Effect } from "effect"
import { define } from "../internal"
import { ProviderV2 } from "../../provider"
import { ConfigProviderV1 } from "../../v1/config/provider"
import { ConfigV1 } from "../../v1/config/config"
import { Integration } from "../../integration"
const defaultServer = "https://console.opencode.ai"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: Schema.Number,
interval: Schema.Number,
})
const Token = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.String,
expires_in: Schema.Number,
})
const TokenPending = Schema.Struct({ error: Schema.String })
const DeviceToken = Schema.Union([Token, TokenPending])
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
function oauth(http: HttpClient.HttpClient) {
return {
integrationID: Integration.ID.make("opencode"),
method: {
id: methodID,
type: "oauth",
label: "OpenCode Console account",
},
authorize: () =>
Effect.gen(function* () {
const device = yield* post(http, `${defaultServer}/auth/device/code`, { client_id: clientID }, Device)
return {
mode: "auto" as const,
url: `${defaultServer}${device.verification_uri_complete}`,
instructions: `Enter code: ${device.user_code}`,
callback: poll(http, defaultServer, device.device_code, Duration.seconds(device.interval)),
}
}),
refresh: (credential) =>
Effect.gen(function* () {
const server = typeof credential.metadata?.server === "string" ? credential.metadata.server : defaultServer
const token = yield* post(
http,
`${server}/auth/device/token`,
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
Token,
)
return {
...credential,
access: token.access_token,
refresh: token.refresh_token,
expires: Date.now() + token.expires_in * 1000,
}
}),
label: (credential) => {
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
},
} satisfies IntegrationOAuthMethodRegistration
}
export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | Scope.Scope>({
export const OpencodePlugin = define({
id: "opencode",
effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
connected = connection !== undefined
providers = credential
? yield* fetchProviders(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
),
)
: undefined
})
yield* ctx.integration.transform((draft) => {
draft.update("opencode", (integration) => {
integration.name = "OpenCode"
})
draft.method.update(oauth(http))
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => {
provider.integrationID = Integration.ID.make("opencode")
if (item.name !== undefined) provider.name = item.name
provider.api = item.npm
? { type: "aisdk", package: item.npm, url: item.api }
: { type: "native", url: item.api, settings: {} }
Object.assign(provider.request.headers, item.options?.headers)
Object.assign(provider.request.body, withoutCredentials(item.options))
const integrations = yield* Integration.Service
let hasKey = false
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.opencode)
if (!item) return
const integration = yield* integrations.get(Integration.ID.make(item.provider.id))
hasKey = Boolean(
process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey,
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"
})
const modelIDs = new Set(Object.keys(item.models ?? {}))
for (const model of catalog.provider.get(providerID)?.models.values() ?? []) {
if (!modelIDs.has(model.id)) catalog.model.remove(providerID, model.id)
}
for (const [modelID, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, modelID, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.api.id = config.id
if (config.provider !== undefined) {
model.api = config.provider.npm
? {
id: model.api.id,
type: "aisdk",
package: config.provider.npm,
url: config.provider.api,
}
: { id: model.api.id, type: "native", url: config.provider.api, settings: {} }
}
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
const packageName = config.provider?.npm ?? item.npm
ModelRequest.assign(model.request, {
headers: config.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, withoutCredentials(config.options)),
})
if (config.variants !== undefined) {
model.variants = Object.entries(config.variants).map(([id, options]) => ({
id: ModelV2.VariantID.make(id),
headers: { ...(options.headers ?? {}) },
...ModelRequest.normalizeAiSdkOptions(packageName, withoutCredentials(options)),
}))
}
if (config.release_date !== undefined) {
const released = Date.parse(config.release_date)
model.time.released = Number.isFinite(released) ? released : 0
}
if (config.cost !== undefined) {
model.cost = remoteCost(config.cost)
}
model.status = config.status ?? "active"
model.enabled = config.status !== "deprecated"
if (config.limit !== undefined) model.limit = { ...config.limit }
if (hasKey) return
for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
evt.model.update(item.provider.id, model.id, (draft) => {
draft.enabled = false
})
}
}
const item = catalog.provider.get(ProviderV2.ID.opencode)
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.request.body.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"
})
if (hasKey) return
for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
catalog.model.update(item.provider.id, model.id, (draft) => {
draft.enabled = false
})
}
})
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))),
Effect.forkScoped({ startImmediately: true }),
}),
)
}),
})
function fetchProviders(http: HttpClient.HttpClient, value: CredentialValue) {
const metadata = value.metadata
const server = typeof metadata?.server === "string" ? metadata.server : defaultServer
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
),
)
.pipe(
Effect.flatMap((response) => {
if (response.status === 404) return Effect.succeed(undefined)
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
)
}),
)
}
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
}
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: input.input,
output: input.output,
cache: { read: input.cache_read ?? 0, write: input.cache_write ?? 0 },
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: input.context_over_200k.cache_read ?? 0,
write: input.context_over_200k.cache_write ?? 0,
},
},
]
}
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* post(
http,
`${server}/auth/device/token`,
{
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
device_code: deviceCode,
client_id: clientID,
},
DeviceToken,
false,
)
if ("access_token" in result) return yield* credential(http, server, result)
if (result.error === "authorization_pending") return yield* loop(wait)
if (result.error === "slow_down") {
return yield* loop(Duration.sum(wait, Duration.seconds(5)))
}
return yield* Effect.fail(new Error(`Device authorization failed: ${result.error}`))
})
return loop(interval)
}
function credential(http: HttpClient.HttpClient, server: string, token: typeof Token.Type) {
return Effect.gen(function* () {
const [user, orgs] = yield* Effect.all(
[
get(http, `${server}/api/user`, token.access_token, User),
get(http, `${server}/api/orgs`, token.access_token, Schema.Array(Org)),
],
{ concurrency: 2 },
)
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
return Credential.OAuth.make({
type: "oauth" as const,
methodID,
access: token.access_token,
refresh: token.refresh_token,
expires: Date.now() + token.expires_in * 1000,
metadata: {
server,
accountID: user.id,
email: user.email,
orgID: org?.id,
orgName: org?.name,
},
})
})
}
function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, token: string, schema: S) {
return HttpClient.filterStatusOk(http)
.execute(HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(token)))
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)))
}
function post<S extends Schema.Top>(
http: HttpClient.HttpClient,
url: string,
body: Record<string, string>,
schema: S,
statusOk = true,
) {
return HttpClientRequest.post(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
Effect.flatMap((request) => http.execute(request)),
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
)
}
+2 -2
View File
@@ -15,9 +15,9 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
yield* ctx.skill.transform((draft) => {
draft.source(
SkillV2.EmbeddedSource.make({
new SkillV2.EmbeddedSource({
type: "embedded",
skill: SkillV2.Info.make({
skill: new SkillV2.Info({
name: "customize-opencode",
description:
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.",
+1 -8
View File
@@ -10,14 +10,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
exitCode: Schema.optional(Schema.Number),
stderr: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {
override get message() {
const detail =
this.stderr?.trim() || (this.cause instanceof Error ? this.cause.message : this.cause && String(this.cause))
const status = this.exitCode === undefined ? "" : ` (exit ${this.exitCode})`
return `Command failed${status}: ${this.command}${detail ? `: ${detail}` : ""}`
}
}
}) {}
export interface RunOptions {
readonly maxOutputBytes?: number
+41 -9
View File
@@ -1,25 +1,57 @@
export * as ProviderV2 from "./provider"
import { Types } from "effect"
import { Schema, Types } from "effect"
import { Provider } from "@opencode-ai/schema/provider"
export const ID = Provider.ID
export type ID = typeof ID.Type
export const AISDK = Provider.AISDK
export const AISDK = Schema.Struct({
type: Schema.Literal("aisdk"),
package: Schema.String,
url: Schema.String.pipe(Schema.optional),
settings: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
})
export const Native = Provider.Native
export const Native = Schema.Struct({
type: Schema.Literal("native"),
url: Schema.String.pipe(Schema.optional),
settings: Schema.Record(Schema.String, Schema.Unknown),
})
export const Api = Provider.Api
export type Api = Provider.Api
export const Api = Schema.Union([AISDK, Native]).pipe(Schema.toTaggedUnion("type"))
export type Api = typeof Api.Type
export type MutableApi<T extends Api = Api> = T extends Api
? Omit<Types.DeepMutable<T>, "settings"> & (undefined extends T["settings"] ? { settings?: any } : { settings: any })
: never
export const Request = Provider.Request
export type Request = Provider.Request
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
})
export type Request = typeof Request.Type
export const Info = Provider.Info
export type Info = Provider.Info
export class Info extends Schema.Class<Info>("ProviderV2.Info")({
id: ID,
name: Schema.String,
disabled: Schema.Boolean.pipe(Schema.optional),
api: Api,
request: Request,
}) {
static empty(providerID: ID): Info {
return new Info({
id: providerID,
name: providerID,
api: {
type: "native",
settings: {},
},
request: {
headers: {},
body: {},
},
})
}
}
export type MutableInfo = Omit<Types.DeepMutable<Info>, "api"> & { api: MutableApi }
@@ -1,47 +0,0 @@
export * as PublicEventManifest from "./public-event-manifest"
import { Event } from "@opencode-ai/schema/event"
import { Todo } from "@opencode-ai/schema/todo"
import { Catalog } from "./catalog"
import { FileSystem } from "./filesystem"
import { Watcher } from "./filesystem/watcher"
import { Integration } from "./integration"
import { ModelsDev } from "./models-dev"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Reference } from "./reference"
import { EventManifest } from "./event-manifest"
export const FoundationDefinitions = Event.inventory(
ModelsDev.Event.Refreshed,
Integration.Event.Updated,
Integration.Event.ConnectionUpdated,
Catalog.Event.Updated,
...EventManifest.Definitions,
)
export const FeatureDefinitions = Event.inventory(
FileSystem.Event.Edited,
Reference.Event.Updated,
PermissionV2.Event.Asked,
PermissionV2.Event.Replied,
PluginV2.Event.Added,
ProjectCopy.Event.Updated,
Watcher.Event.Updated,
Pty.Event.Created,
Pty.Event.Updated,
Pty.Event.Exited,
Pty.Event.Deleted,
QuestionV2.Event.Asked,
QuestionV2.Event.Replied,
QuestionV2.Event.Rejected,
Todo.Event.Updated,
)
export const Definitions = Event.inventory(...FoundationDefinitions, ...FeatureDefinitions)
export const Latest = Event.latest(Definitions)
export const Durable = Event.durable(Definitions)
+1 -1
View File
@@ -28,7 +28,7 @@ const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(LocationServiceMap.layer.pipe(Layer.provide(ApplicationTools.layer))),
Layer.provide(LocationServiceMap.layerWithApplicationTools.pipe(Layer.provide(ApplicationTools.layer))),
Layer.orDie,
)
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
+15 -7
View File
@@ -1,7 +1,6 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope, Types } from "effect"
import { Reference } from "@opencode-ai/schema/reference"
import { Global } from "./global"
import { EventV2 } from "./event"
import { Repository } from "./repository"
@@ -9,14 +8,23 @@ import { RepositoryCache } from "./repository-cache"
import { AbsolutePath } from "./schema"
import { State } from "./state"
export const LocalSource = Reference.LocalSource
export type LocalSource = Reference.LocalSource
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
type: Schema.Literal("local"),
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const GitSource = Reference.GitSource
export type GitSource = Reference.GitSource
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
type: Schema.Literal("git"),
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Source = Reference.Source
export type Source = Reference.Source
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
+15 -8
View File
@@ -2,8 +2,10 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem"
import path from "path"
import { LayerNode } from "./effect/layer-node"
import { Entry, Match } from "./filesystem/schema"
import { FSUtil } from "./fs-util"
import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
@@ -175,12 +177,14 @@ export const layer = Layer.effect(
),
}).pipe(
Effect.map((result) =>
result.items.map((relative) =>
Entry.make({
result.items.map((relative) => {
const absolute = path.resolve(input.cwd, relative)
return new Entry({
path: RelativePath.make(relative),
type: "file",
}),
),
mime: FSUtil.mimeType(absolute),
})
}),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
@@ -204,9 +208,10 @@ export const layer = Layer.effect(
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Effect.succeed(
Entry.make({
new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(path.resolve(input.cwd, relative)),
}),
)
},
@@ -257,10 +262,12 @@ export const layer = Layer.effect(
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Match.make({
entry: Entry.make({
const absolute = path.resolve(input.cwd, relative)
return new Match({
entry: new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(absolute),
}),
line: match.line_number,
offset: match.absolute_offset,
+4
View File
@@ -2,6 +2,8 @@ import { Schema } from "effect"
import {
AbsolutePath,
DateTimeUtcFromMillis,
externalID,
type ExternalID,
NonNegativeInt,
optionalOmitUndefined,
PositiveInt,
@@ -12,12 +14,14 @@ import {
export {
AbsolutePath,
DateTimeUtcFromMillis,
externalID,
NonNegativeInt,
optionalOmitUndefined,
PositiveInt,
RelativePath,
withStatics,
}
export type { ExternalID }
/**
* Strip `readonly` from a nested type. Stand-in for `effect`'s `Types.DeepMutable`
+3 -2
View File
@@ -2,7 +2,7 @@ export * as SessionV2 from "./session"
export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { Session as SchemaSession } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
@@ -39,7 +39,8 @@ import { SessionInput } from "./session/input"
// - by subpath
// - by workspace (home is special)
export { ListAnchor }
export const ListAnchor = SchemaSession.ListAnchor
export type ListAnchor = typeof ListAnchor.Type
const ListInputBase = {
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
+1 -5
View File
@@ -5,11 +5,7 @@ import { SessionSchema } from "./schema"
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {
override get message() {
return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
}
}
}) {}
export class ContextSnapshotDecodeError extends Schema.TaggedErrorClass<ContextSnapshotDecodeError>()(
"Session.ContextSnapshotDecodeError",
+471 -2
View File
@@ -1,2 +1,471 @@
export * from "@opencode-ai/schema/session-event"
export { SessionEvent } from "@opencode-ai/schema/session-event"
import { Schema } from "effect"
import { ProviderMetadata, ToolContent } from "@opencode-ai/llm"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { DateTimeUtcFromMillis, NonNegativeInt, RelativePath } from "../schema"
import { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
import { Location } from "../location"
import { SessionMessageID } from "./message-id"
export { FileAttachment }
export const Source = Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
text: Schema.String,
}).annotate({
identifier: "session.next.event.source",
})
export type Source = typeof Source.Type
const Base = {
timestamp: DateTimeUtcFromMillis,
sessionID: SessionSchema.ID,
}
const PromptFields = {
...Base,
messageID: SessionMessageID.ID,
prompt: Prompt,
delivery: Schema.Literals(["steer", "queue"]),
}
const options = {
durable: {
aggregate: "sessionID",
version: 1,
},
} as const
const stepSettlementOptions = {
durable: {
aggregate: "sessionID",
version: 2,
},
} as const
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = typeof UnknownError.Type
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
agent: Schema.String,
},
})
export type AgentSwitched = typeof AgentSwitched.Type
export const ModelSwitched = EventV2.define({
type: "session.next.model.switched",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
model: ModelV2.Ref,
},
})
export type ModelSwitched = typeof ModelSwitched.Type
export const Moved = EventV2.define({
type: "session.next.moved",
...options,
schema: {
...Base,
location: Location.Ref,
subdirectory: RelativePath.pipe(Schema.optional),
},
})
export type Moved = typeof Moved.Type
export const Prompted = EventV2.define({
type: "session.next.prompted",
...options,
schema: PromptFields,
})
export type Prompted = typeof Prompted.Type
export const PromptAdmitted = EventV2.define({
type: "session.next.prompt.admitted",
...options,
schema: PromptFields,
})
export type PromptAdmitted = typeof PromptAdmitted.Type
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type ContextUpdated = typeof ContextUpdated.Type
export const Synthetic = EventV2.define({
type: "session.next.synthetic",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Synthetic = typeof Synthetic.Type
export namespace Shell {
export const Started = EventV2.define({
type: "session.next.shell.started",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
callID: Schema.String,
command: Schema.String,
},
})
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.shell.ended",
...options,
schema: {
...Base,
callID: Schema.String,
output: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
export namespace Step {
export const Started = EventV2.define({
type: "session.next.step.started",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
agent: Schema.String,
model: ModelV2.Ref,
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Started = typeof Started.Type
export const Ended = EventV2.define({
type: "session.next.step.ended",
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
finish: Schema.String,
cost: Schema.Finite,
tokens: Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
snapshot: Schema.String.pipe(Schema.optional),
},
})
export type Ended = typeof Ended.Type
export const Failed = EventV2.define({
type: "session.next.step.failed",
...stepSettlementOptions,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
error: UnknownError,
},
})
export type Failed = typeof Failed.Type
}
export namespace Text {
export const Started = EventV2.define({
type: "session.next.text.started",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Text.Ended is the replayable full-value boundary.
export const Delta = EventV2.define({
type: "session.next.text.delta",
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
delta: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.text.ended",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
textID: Schema.String,
text: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
export namespace Reasoning {
export const Started = EventV2.define({
type: "session.next.reasoning.started",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Reasoning.Ended is the replayable full-value boundary.
export const Delta = EventV2.define({
type: "session.next.reasoning.delta",
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
delta: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.reasoning.ended",
...options,
schema: {
...Base,
assistantMessageID: SessionMessageID.ID,
reasoningID: Schema.String,
text: Schema.String,
providerMetadata: ProviderMetadata.pipe(Schema.optional),
},
})
export type Ended = typeof Ended.Type
}
export namespace Tool {
const ToolBase = {
...Base,
assistantMessageID: SessionMessageID.ID,
callID: Schema.String,
}
export namespace Input {
export const Started = EventV2.define({
type: "session.next.tool.input.started",
...options,
schema: {
...ToolBase,
name: Schema.String,
},
})
export type Started = typeof Started.Type
// Stream fragments are live-only; Input.Ended is the replayable raw-input boundary.
export const Delta = EventV2.define({
type: "session.next.tool.input.delta",
schema: {
...ToolBase,
delta: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.tool.input.ended",
...options,
schema: {
...ToolBase,
text: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
export const Called = EventV2.define({
type: "session.next.tool.called",
...options,
schema: {
...ToolBase,
tool: Schema.String,
input: Schema.Record(Schema.String, Schema.Unknown),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Called = typeof Called.Type
/**
* Replayable bounded running-tool state. Tools should checkpoint semantic
* transitions or at a bounded cadence, not persist every stdout/stderr chunk.
*/
export const Progress = EventV2.define({
type: "session.next.tool.progress",
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
},
})
export type Progress = typeof Progress.Type
export const Success = EventV2.define({
type: "session.next.tool.success",
...options,
schema: {
...ToolBase,
structured: Schema.Record(Schema.String, Schema.Any),
content: Schema.Array(ToolContent),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Success = typeof Success.Type
export const Failed = EventV2.define({
type: "session.next.tool.failed",
...options,
schema: {
...ToolBase,
error: UnknownError,
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: ProviderMetadata.pipe(Schema.optional),
}),
},
})
export type Failed = typeof Failed.Type
}
export const RetryError = Schema.Struct({
message: Schema.String,
statusCode: Schema.Finite.pipe(Schema.optional),
isRetryable: Schema.Boolean,
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
responseBody: Schema.String.pipe(Schema.optional),
metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
}).annotate({
identifier: "session.next.retry_error",
})
export type RetryError = typeof RetryError.Type
export const Retried = EventV2.define({
type: "session.next.retried",
...options,
schema: {
...Base,
attempt: Schema.Finite,
error: RetryError,
},
})
export type Retried = typeof Retried.Type
export namespace Compaction {
export const Started = EventV2.define({
type: "session.next.compaction.started",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Schema.Union([Schema.Literal("auto"), Schema.Literal("manual")]),
},
})
export type Started = typeof Started.Type
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
const DurableDefinitions = [
AgentSwitched,
ModelSwitched,
Moved,
Prompted,
PromptAdmitted,
ContextUpdated,
Synthetic,
Shell.Started,
Shell.Ended,
Step.Started,
Step.Ended,
Step.Failed,
Text.Started,
Text.Ended,
Tool.Input.Started,
Tool.Input.Ended,
Tool.Called,
Tool.Progress,
Tool.Success,
Tool.Failed,
Reasoning.Started,
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Ended,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type DurableEvent = typeof Durable.Type
export const All = Schema.Union([...DurableDefinitions, ...EphemeralDefinitions], { mode: "oneOf" }).pipe(
Schema.toTaggedUnion("type"),
)
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./event"
+6 -2
View File
@@ -2,7 +2,7 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull, lte } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
import { SessionInput as SchemaSessionInput } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { SessionEvent } from "./event"
@@ -13,7 +13,11 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
export { Admitted, Delivery }
export const Delivery = SchemaSessionInput.Delivery
export type Delivery = typeof Delivery.Type
export const Admitted = SchemaSessionInput.Admitted
export type Admitted = SchemaSessionInput.Admitted
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
+1 -1
View File
@@ -1,2 +1,2 @@
export * as SessionMessageID from "./message-id"
export { ID } from "@opencode-ai/schema/session-message-id"
export { ID } from "@opencode-ai/schema/session-message"
+28 -40
View File
@@ -10,6 +10,7 @@ import { produce } from "immer"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { IntegrationConnection } from "../../integration/connection"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { ProviderV2 } from "../../provider"
@@ -20,11 +21,7 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
{
sessionID: SessionSchema.ID,
},
) {
override get message() {
return `No model is available for session ${this.sessionID}`
}
}
) {}
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
@@ -32,11 +29,7 @@ export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavaila
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
) {
override get message() {
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
@@ -45,11 +38,7 @@ export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnav
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
) {}
export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiError>()(
"SessionRunnerModel.UnsupportedApiError",
@@ -58,18 +47,9 @@ export class UnsupportedApiError extends Schema.TaggedErrorClass<UnsupportedApiE
modelID: ModelV2.ID,
api: Schema.String,
},
) {
override get message() {
return `Unsupported API for ${this.providerID}/${this.modelID}: ${this.api}`
}
}
) {}
export type Error =
| ModelNotSelectedError
| ModelUnavailableError
| VariantUnavailableError
| UnsupportedApiError
| Integration.AuthorizationError
export type Error = ModelNotSelectedError | ModelUnavailableError | VariantUnavailableError | UnsupportedApiError
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Model, Error>
@@ -80,11 +60,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Info) => {
if (credential?.value.type === "key") return Auth.value(credential.value.key)
if (credential?.value.type === "oauth") return Auth.value(credential.value.access)
const value = model.request.body.apiKey ?? model.api.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return connection?.type === "env" ? Auth.config(connection.name) : undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
@@ -133,15 +114,16 @@ const apiName = (model: ModelV2.Info) =>
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
connection?: IntegrationConnection.Info,
credential?: Credential.Info,
): Effect.Effect<Model, UnsupportedApiError> => {
const resolved =
credential?.metadata === undefined
credential?.value.metadata === undefined
? model
: produce(model, (draft) => {
Object.assign(draft.request.body, credential.metadata)
Object.assign(draft.request.body, credential.value.metadata)
})
const key = apiKey(resolved, credential)
const key = apiKey(resolved, connection, credential)
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
@@ -172,8 +154,15 @@ export const fromCatalogModel = (
)
}
export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, credential?: Credential.Value) =>
withVariant(model, session.model?.variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential)))
export const resolve = (
session: SessionSchema.Info,
model: ModelV2.Info,
connection?: IntegrationConnection.Info,
credential?: Credential.Info,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, connection, credential)),
)
export const supported = (model: ModelV2.Info) =>
model.api.type === "aisdk" &&
@@ -186,6 +175,7 @@ export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const integrations = yield* Integration.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
@@ -204,14 +194,12 @@ export const locationLayer = Layer.effect(
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID))
return yield* resolve(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
connection,
connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined,
)
}),
})
+2
View File
@@ -1,9 +1,11 @@
export * as SessionSchema from "./schema"
import { Session } from "@opencode-ai/schema/session"
import type { ExternalID } from "../schema"
export const ID = Session.ID
export type ID = typeof ID.Type
export type { ExternalID }
export const Info = Session.Info
export type Info = Session.Info
+19 -5
View File
@@ -1,16 +1,30 @@
export * as SessionTodo from "./todo"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer } from "effect"
import { Todo } from "@opencode-ai/schema/todo"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { SessionSchema } from "./schema"
import { TodoTable } from "./sql"
export const Info = Todo.Info
export type Info = Todo.Info
export const Event = Todo.Event
export const Info = Schema.Struct({
content: Schema.String.annotate({ description: "Brief description of the task" }),
status: Schema.String.annotate({
description: "Current status of the task: pending, in_progress, completed, cancelled",
}),
priority: Schema.String.annotate({ description: "Priority level of the task: high, medium, low" }),
}).annotate({ identifier: "SessionTodo.Info" })
export type Info = typeof Info.Type
export const Event = {
Updated: EventV2.define({
type: "todo.updated",
schema: {
sessionID: SessionSchema.ID,
todos: Schema.Array(Info),
},
}),
}
export interface Interface {
readonly update: (input: {
+47 -18
View File
@@ -2,29 +2,56 @@ export * as SkillV2 from "./skill"
import path from "path"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Skill } from "@opencode-ai/schema/skill"
import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
import { FSUtil } from "./fs-util"
import { PermissionV2 } from "./permission"
import { AbsolutePath } from "./schema"
import { AbsolutePath, withStatics } from "./schema"
import { SkillDiscovery } from "./skill/discovery"
import { State } from "./state"
export const DirectorySource = Skill.DirectorySource
export type DirectorySource = Skill.DirectorySource
export class DirectorySource extends Schema.Class<DirectorySource>("SkillV2.DirectorySource")({
type: Schema.Literal("directory"),
path: AbsolutePath,
}) {}
export const UrlSource = Skill.UrlSource
export type UrlSource = Skill.UrlSource
export class UrlSource extends Schema.Class<UrlSource>("SkillV2.UrlSource")({
type: Schema.Literal("url"),
url: Schema.String,
}) {}
export const EmbeddedSource = Skill.EmbeddedSource
export type EmbeddedSource = Skill.EmbeddedSource
export class EmbeddedSource extends Schema.Class<EmbeddedSource>("SkillV2.EmbeddedSource")({
type: Schema.Literal("embedded"),
skill: Schema.suspend(() => Info),
}) {}
export const Source = Skill.Source
export const Source = Schema.Union([DirectorySource, UrlSource, EmbeddedSource]).pipe(
Schema.toTaggedUnion("type"),
withStatics(() => ({
equals: (a: DirectorySource | UrlSource | EmbeddedSource, b: DirectorySource | UrlSource | EmbeddedSource) => {
if (a.type !== b.type) return false
if (a.type === "directory" && b.type === "directory") return a.path === b.path
if (a.type === "url" && b.type === "url") return a.url === b.url
if (a.type === "embedded" && b.type === "embedded") return a.skill.name === b.skill.name
return false
},
key: (source: DirectorySource | UrlSource | EmbeddedSource) =>
source.type === "directory"
? `directory:${source.path}`
: source.type === "url"
? `url:${source.url}`
: `embedded:${source.skill.name}`,
})),
)
export type Source = typeof Source.Type
export const Info = Skill.Info
export type Info = Skill.Info
export class Info extends Schema.Class<Info>("SkillV2.Info")({
name: Schema.String,
description: Schema.String.pipe(Schema.optional),
slash: Schema.Boolean.pipe(Schema.optional),
location: AbsolutePath,
content: Schema.String,
}) {}
export const available = (skills: ReadonlyArray<Info>, agent: AgentV2.Info) =>
skills.filter((skill) => PermissionV2.evaluate("skill", skill.name, agent.permissions).effect !== "deny")
@@ -91,13 +118,15 @@ export const layer = Layer.effect(
? path.basename(filepath, ".md")
: undefined
if (!name) continue
skills.push({
name,
description: frontmatter.description,
slash: frontmatter.slash,
location: AbsolutePath.make(filepath),
content: markdown.content,
})
skills.push(
new Info({
name,
description: frontmatter.description,
slash: frontmatter.slash,
location: AbsolutePath.make(filepath),
content: markdown.content,
}),
)
}
}
return skills
+1 -5
View File
@@ -82,11 +82,7 @@ export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | Replace
export class InitializationBlocked extends Schema.TaggedErrorClass<InitializationBlocked>()(
"SystemContext.InitializationBlocked",
{ keys: Schema.Array(Key) },
) {
override get message() {
return `System context initialization blocked by unavailable sources: ${this.keys.join(", ")}`
}
}
) {}
export class DuplicateKeyError extends Schema.TaggedErrorClass<DuplicateKeyError>()("SystemContext.DuplicateKeyError", {
key: Key,
+1 -6
View File
@@ -29,12 +29,7 @@ export interface BoundResult {
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
operation: Schema.Literals(["encode", "write"]),
cause: Schema.Defect(),
}) {
override get message() {
const detail = this.cause instanceof Error ? this.cause.message : String(this.cause)
return `Failed to ${this.operation} tool output${detail ? `: ${detail}` : ""}`
}
}
}) {}
export type Error = StorageError
+6 -5
View File
@@ -79,11 +79,12 @@ export const layer = Layer.effectDiscard(
})
.pipe(
Effect.map((result) =>
result.map((entry) =>
FileSystem.Entry.make({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
)
+14 -13
View File
@@ -102,22 +102,23 @@ export const layer = Layer.effectDiscard(
})
.pipe(
Effect.map((result) =>
result.map((match) =>
FileSystem.Match.make({
...match,
entry: FileSystem.Entry.make({
...match.entry,
path: RelativePath.make(
path.relative(
location.directory,
path.resolve(
info?.type === "Directory" ? target : path.dirname(target),
match.entry.path,
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(
path.relative(
location.directory,
path.resolve(
info?.type === "Directory" ? target : path.dirname(target),
match.entry.path,
),
),
),
),
}),
}),
}),
),
),
)
+2 -1
View File
@@ -335,9 +335,10 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return FileSystem.Entry.make({
return new FileSystem.Entry({
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
})
}),
{ concurrency: 16 },
+48 -1
View File
@@ -1 +1,48 @@
export * as Identifier from "@opencode-ai/schema/identifier"
import { randomBytes } from "crypto"
export namespace Identifier {
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending() {
return create(false)
}
export function descending() {
return create(true)
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = descending ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
}
+96 -2
View File
@@ -1,2 +1,96 @@
export * from "@opencode-ai/schema/permission-v1"
export { PermissionV1 } from "@opencode-ai/schema/permission-v1"
export * as PermissionV1 from "./permission"
import { Schema } from "effect"
import { ProjectV2 } from "../project"
import { withStatics } from "../schema"
import { SessionSchema } from "../session/schema"
import { Identifier } from "../util/identifier"
export const ID = Schema.String.check(Schema.isStartsWith("per")).pipe(
Schema.brand("PermissionID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "per_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionAction" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionRule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionRuleset" })
export type Ruleset = typeof Ruleset.Type
export const Request = Schema.Struct({
id: ID,
sessionID: SessionSchema.ID,
permission: Schema.String,
patterns: Schema.Array(Schema.String),
metadata: Schema.Record(Schema.String, Schema.Unknown),
always: Schema.Array(Schema.String),
tool: Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).pipe(Schema.optional),
}).annotate({ identifier: "PermissionRequest" })
export type Request = typeof Request.Type
export const Reply = Schema.Literals(["once", "always", "reject"])
export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
export const Approval = Schema.Struct({
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = typeof Approval.Type
export const AskInput = Schema.Struct({
...Request.fields,
id: ID.pipe(Schema.optional),
ruleset: Ruleset,
}).annotate({ identifier: "PermissionAskInput" })
export type AskInput = typeof AskInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
...ReplyBody.fields,
}).annotate({ identifier: "PermissionReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
override get message() {
return "The user rejected permission to use this specific tool call."
}
}
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
feedback: Schema.String,
}) {
override get message() {
return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
}
}
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
ruleset: Schema.Any,
}) {
override get message() {
return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Permission.NotFoundError", {
requestID: ID,
}) {}
export type Error = DeniedError | RejectedError | CorrectedError
+632 -2
View File
@@ -1,2 +1,632 @@
export * from "@opencode-ai/schema/session-v1"
export { SessionV1 } from "@opencode-ai/schema/session-v1"
export * as SessionV1 from "./session"
import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event"
import { PermissionV1 } from "./permission"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { ModelV2 } from "../model"
import { optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { NonNegativeInt } from "../schema"
import { NamedError } from "../util/error"
import { SessionSchema } from "../session/schema"
import { WorkspaceV2 } from "../workspace"
const Timestamp = Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0))
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
Schema.brand("MessageID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })),
)
export type MessageID = typeof MessageID.Type
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
Schema.brand("PartID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })),
)
export type PartID = typeof PartID.Type
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
export const AuthError = NamedError.create("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
message: Schema.String,
retries: NonNegativeInt,
})
export const APIError = NamedError.create("APIError", {
message: Schema.String,
statusCode: Schema.optional(NonNegativeInt),
isRetryable: Schema.Boolean,
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
responseBody: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
export const ContentFilterError = NamedError.create("ContentFilterError", {
message: Schema.String,
})
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
type: Schema.Literal("text"),
}) {}
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
type: Schema.Literal("json_schema"),
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
}) {}
export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
discriminator: "type",
identifier: "OutputFormat",
})
export type OutputFormat = Schema.Schema.Type<typeof Format>
const partBase = {
id: PartID,
sessionID: SessionSchema.ID,
messageID: MessageID,
}
export const SnapshotPart = Schema.Struct({
...partBase,
type: Schema.Literal("snapshot"),
snapshot: Schema.String,
}).annotate({ identifier: "SnapshotPart" })
export type SnapshotPart = Types.DeepMutable<Schema.Schema.Type<typeof SnapshotPart>>
export const PatchPart = Schema.Struct({
...partBase,
type: Schema.Literal("patch"),
hash: Schema.String,
files: Schema.Array(Schema.String),
}).annotate({ identifier: "PatchPart" })
export type PatchPart = Types.DeepMutable<Schema.Schema.Type<typeof PatchPart>>
export const TextPart = Schema.Struct({
...partBase,
type: Schema.Literal("text"),
text: Schema.String,
synthetic: Schema.optional(Schema.Boolean),
ignored: Schema.optional(Schema.Boolean),
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "TextPart" })
export type TextPart = Types.DeepMutable<Schema.Schema.Type<typeof TextPart>>
export const ReasoningPart = Schema.Struct({
...partBase,
type: Schema.Literal("reasoning"),
text: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
}).annotate({ identifier: "ReasoningPart" })
export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof ReasoningPart>>
const filePartSourceBase = {
text: Schema.Struct({
value: Schema.String,
start: Schema.Finite,
end: Schema.Finite,
}).annotate({ identifier: "FilePartSourceText" }),
}
export const Range = Schema.Struct({
start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
}).annotate({ identifier: "Range" })
export type Range = typeof Range.Type
export const FileSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("file"),
path: Schema.String,
}).annotate({ identifier: "FileSource" })
export const SymbolSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("symbol"),
path: Schema.String,
range: Range,
name: Schema.String,
kind: NonNegativeInt,
}).annotate({ identifier: "SymbolSource" })
export const ResourceSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("resource"),
clientName: Schema.String,
uri: Schema.String,
}).annotate({ identifier: "ResourceSource" })
export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
discriminator: "type",
identifier: "FilePartSource",
})
export const FilePart = Schema.Struct({
...partBase,
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(FilePartSource),
}).annotate({ identifier: "FilePart" })
export type FilePart = Types.DeepMutable<Schema.Schema.Type<typeof FilePart>>
export const AgentPart = Schema.Struct({
...partBase,
type: Schema.Literal("agent"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "AgentPart" })
export type AgentPart = Types.DeepMutable<Schema.Schema.Type<typeof AgentPart>>
export const CompactionPart = Schema.Struct({
...partBase,
type: Schema.Literal("compaction"),
auto: Schema.Boolean,
overflow: Schema.optional(Schema.Boolean),
tail_start_id: Schema.optional(MessageID),
}).annotate({ identifier: "CompactionPart" })
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
export const SubtaskPart = Schema.Struct({
...partBase,
type: Schema.Literal("subtask"),
prompt: Schema.String,
description: Schema.String,
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}),
),
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPart" })
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
export const RetryPart = Schema.Struct({
...partBase,
type: Schema.Literal("retry"),
attempt: NonNegativeInt,
error: APIError.EffectSchema,
time: Schema.Struct({
created: NonNegativeInt,
}),
}).annotate({ identifier: "RetryPart" })
export type RetryPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof RetryPart>>, "error"> & {
error: APIError
}
export const StepStartPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-start"),
snapshot: Schema.optional(Schema.String),
}).annotate({ identifier: "StepStartPart" })
export type StepStartPart = Types.DeepMutable<Schema.Schema.Type<typeof StepStartPart>>
export const StepFinishPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-finish"),
reason: Schema.String,
snapshot: Schema.optional(Schema.String),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
}).annotate({ identifier: "StepFinishPart" })
export type StepFinishPart = Types.DeepMutable<Schema.Schema.Type<typeof StepFinishPart>>
export const ToolStatePending = Schema.Struct({
status: Schema.Literal("pending"),
input: Schema.Record(Schema.String, Schema.Any),
raw: Schema.String,
}).annotate({ identifier: "ToolStatePending" })
export type ToolStatePending = Types.DeepMutable<Schema.Schema.Type<typeof ToolStatePending>>
export const ToolStateRunning = Schema.Struct({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Any),
title: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
}),
}).annotate({ identifier: "ToolStateRunning" })
export type ToolStateRunning = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateRunning>>
export const ToolStateCompleted = Schema.Struct({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Any),
output: Schema.String,
title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Any),
time: Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
compacted: Schema.optional(NonNegativeInt),
}),
attachments: Schema.optional(Schema.Array(FilePart)),
}).annotate({ identifier: "ToolStateCompleted" })
export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>
export const ToolStateError = Schema.Struct({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Any),
error: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
}),
}).annotate({ identifier: "ToolStateError" })
export type ToolStateError = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateError>>
export const ToolState = Schema.Union([
ToolStatePending,
ToolStateRunning,
ToolStateCompleted,
ToolStateError,
]).annotate({
discriminator: "status",
identifier: "ToolState",
})
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export const ToolPart = Schema.Struct({
...partBase,
type: Schema.Literal("tool"),
callID: Schema.String,
tool: Schema.String,
state: ToolState,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "ToolPart" })
export type ToolPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "state"> & {
state: ToolState
}
const messageBase = {
id: MessageID,
sessionID: partBase.sessionID,
}
const FileDiff = Schema.Struct({
file: Schema.optional(Schema.String),
patch: Schema.optional(Schema.String),
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
}).annotate({ identifier: "SnapshotFileDiff" })
export const User = Schema.Struct({
...messageBase,
role: Schema.Literal("user"),
time: Schema.Struct({
created: Timestamp,
}),
format: Schema.optional(Format),
summary: Schema.optional(
Schema.Struct({
title: Schema.optional(Schema.String),
body: Schema.optional(Schema.String),
diffs: Schema.Array(FileDiff),
}),
),
agent: Schema.String,
model: Schema.Struct({
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: Schema.optional(Schema.String),
}),
system: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
}).annotate({ identifier: "UserMessage" })
export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
export const Part = Schema.Union([
TextPart,
SubtaskPart,
ReasoningPart,
FilePart,
ToolPart,
StepStartPart,
StepFinishPart,
SnapshotPart,
PatchPart,
AgentPart,
RetryPart,
CompactionPart,
]).annotate({ discriminator: "type", identifier: "Part" })
export type Part =
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
NamedError.Unknown.EffectSchema,
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
ContentFilterError.EffectSchema,
APIError.EffectSchema,
]).annotate({ discriminator: "name" })
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
export const TextPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("text"),
text: Schema.String,
synthetic: Schema.optional(Schema.Boolean),
ignored: Schema.optional(Schema.Boolean),
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "TextPartInput" })
export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>
export const FilePartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(FilePartSource),
}).annotate({ identifier: "FilePartInput" })
export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>
export const AgentPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("agent"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "AgentPartInput" })
export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>
export const SubtaskPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("subtask"),
prompt: Schema.String,
description: Schema.String,
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
}),
),
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPartInput" })
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
export const Assistant = Schema.Struct({
...messageBase,
role: Schema.Literal("assistant"),
time: Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(AssistantErrorSchema),
parentID: MessageID,
modelID: ModelV2.ID,
providerID: ProviderV2.ID,
mode: Schema.String,
agent: Schema.String,
path: Schema.Struct({
cwd: Schema.String,
root: Schema.String,
}),
summary: Schema.optional(Schema.Boolean),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
structured: Schema.optional(Schema.Any),
variant: Schema.optional(Schema.String),
finish: Schema.optional(Schema.String),
}).annotate({ identifier: "AssistantMessage" })
export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "error"> & {
error?: AssistantError
}
export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
export type Info = User | Assistant
export const WithParts = Schema.Struct({
info: Info,
parts: Schema.Array(Part),
})
export type WithParts = {
info: Info
parts: Part[]
}
const options = {
durable: {
aggregate: "sessionID",
version: 1,
},
} as const
const SessionSummary = Schema.Struct({
additions: Schema.Finite,
deletions: Schema.Finite,
files: Schema.Finite,
diffs: optionalOmitUndefined(Schema.Array(FileDiff)),
})
const SessionTokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const SessionShare = Schema.Struct({
url: Schema.String,
})
const SessionRevert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
const SessionModel = Schema.Struct({
id: ModelV2.ID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
export const SessionInfo = Schema.Struct({
id: SessionSchema.ID,
slug: Schema.String,
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
directory: Schema.String,
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionSchema.ID),
summary: optionalOmitUndefined(SessionSummary),
cost: optionalOmitUndefined(Schema.Finite),
tokens: optionalOmitUndefined(SessionTokens),
share: optionalOmitUndefined(SessionShare),
title: Schema.String,
agent: optionalOmitUndefined(Schema.String),
model: optionalOmitUndefined(SessionModel),
version: Schema.String,
metadata: optionalOmitUndefined(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV1.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type
export const Event = {
Created: EventV2.define({
type: "session.created",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Updated: EventV2.define({
type: "session.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Deleted: EventV2.define({
type: "session.deleted",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
MessageUpdated: EventV2.define({
type: "message.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: Info,
},
}),
MessageRemoved: EventV2.define({
type: "message.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
},
}),
PartUpdated: EventV2.define({
type: "message.part.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
part: Part,
time: Schema.Finite,
},
}),
PartRemoved: EventV2.define({
type: "message.part.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
partID: PartID,
},
}),
}
-3
View File
@@ -1,3 +0,0 @@
export * as V2Schema from "./v2-schema"
export { DateTimeUtcFromMillis } from "@opencode-ai/schema/schema"
+2 -31
View File
@@ -61,7 +61,7 @@ describe("CatalogV2", () => {
yield* credentials.create({
integrationID,
label: "First",
value: Credential.Key.make({ type: "key", key: "first", metadata: { tenant: "one" } }),
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
@@ -69,42 +69,13 @@ describe("CatalogV2", () => {
yield* credentials.create({
integrationID,
label: "Second",
value: Credential.Key.make({ type: "key", key: "second", metadata: { tenant: "two" } }),
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")])
expect(required(yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({})
}).pipe(Effect.provide(layer))
})
it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = ProviderV2.ID.make("remote")
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Credential.defaultLayer.pipe(Layer.fresh)),
)
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
}),
)
expect(yield* catalog.provider.available()).toEqual([])
yield* (yield* Credential.Service).create({
integrationID,
value: Credential.Key.make({ type: "key", key: "secret" }),
})
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
}).pipe(Effect.provide(layer))
})
it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
+2 -2
View File
@@ -27,7 +27,7 @@ describe("CommandV2", () => {
})
expect(yield* command.get("review")).toEqual(
CommandV2.Info.make({
new CommandV2.Info({
name: "review",
template: "Second",
description: "Review code",
@@ -39,7 +39,7 @@ describe("CommandV2", () => {
}),
)
expect(yield* command.list()).toEqual([
CommandV2.Info.make({
new CommandV2.Info({
name: "review",
template: "Second",
description: "Review code",
+3 -3
View File
@@ -59,7 +59,7 @@ Review files`,
)
expect(yield* command.list()).toEqual([
CommandV2.Info.make({
new CommandV2.Info({
name: "review",
template: "Review files",
description: "File review",
@@ -71,8 +71,8 @@ Review files`,
},
subtask: true,
}),
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
new CommandV2.Info({ name: "empty", template: "" }),
new CommandV2.Info({ name: "nested/docs", template: "Write docs" }),
])
}),
),
+6 -6
View File
@@ -59,21 +59,21 @@ describe("ConfigSkillPlugin.Plugin", () => {
)
expect(sources).toEqual([
SkillV2.DirectorySource.make({
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skill")),
}),
SkillV2.DirectorySource.make({
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.join("/repo/.opencode", "skills")),
}),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
SkillV2.DirectorySource.make({
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make(path.join(directory, "skills")) }),
new SkillV2.DirectorySource({
type: "directory",
path: AbsolutePath.make(path.join("/home/test", "shared-skills")),
}),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
SkillV2.UrlSource.make({ type: "url", url: "https://example.test/skills/" }),
new SkillV2.DirectorySource({ type: "directory", path: AbsolutePath.make("/opt/skills") }),
new SkillV2.UrlSource({ type: "url", url: "https://example.test/skills/" }),
])
}),
)
+2 -2
View File
@@ -14,7 +14,7 @@ describe("Credential", () => {
const created = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
value: new Credential.Key({ type: "key", key: "secret" }),
})
expect(yield* credentials.list(integrationID)).toEqual([created])
@@ -24,7 +24,7 @@ describe("Credential", () => {
const replacement = yield* credentials.create({
integrationID,
label: "Replacement",
value: Credential.Key.make({ type: "key", key: "replacement" }),
value: new Credential.Key({ type: "key", key: "replacement" }),
})
expect(yield* credentials.list(integrationID)).toEqual([replacement])
+26 -15
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
import { Event } from "@opencode-ai/schema/event"
import { Database } from "@opencode-ai/core/database/database"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
@@ -17,6 +16,10 @@ const locationLayer = Layer.succeed(
location({ directory: AbsolutePath.make("project"), workspaceID: WorkspaceV2.ID.make("wrk_test") }),
),
)
const eventLayer = Layer.mergeAll(EventV2.defaultLayer, Database.defaultLayer)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(eventLayer)
const Message = EventV2.define({
type: "test.message",
schema: {
@@ -79,16 +82,20 @@ const SyncTimestamp = EventV2.define({
},
})
const eventLayer = Layer.mergeAll(
EventV2.layerWith({
definitions: [Message, SyncMessage, SyncSent, GlobalMessage, VersionedMessage, SyncTimestamp],
}).pipe(Layer.provide(Database.defaultLayer)),
Database.defaultLayer,
)
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
const itWithoutLocation = testEffect(eventLayer)
describe("EventV2", () => {
it.effect("derives stable namespaced external IDs", () =>
Effect.sync(() => {
const input = { namespace: "opencord.agent-input", key: "input-1" }
expect(EventV2.ID.fromExternal(input)).toBe(EventV2.ID.fromExternal(input))
expect(EventV2.ID.fromExternal(input)).toMatch(/^evt_[a-f0-9]{64}$/)
expect(EventV2.ID.fromExternal({ ...input, namespace: "another-app" })).not.toBe(EventV2.ID.fromExternal(input))
expect(EventV2.ID.fromExternal({ namespace: "a:b", key: "c" })).not.toBe(
EventV2.ID.fromExternal({ namespace: "a", key: "b:c" }),
)
}),
)
it.effect("publishes events with the current location", () =>
Effect.gen(function* () {
const events = yield* EventV2.Service
@@ -128,21 +135,26 @@ describe("EventV2", () => {
}),
)
it.effect("selects the latest durable definition independent of declaration order", () =>
it.effect("stores definitions in the exported registry", () =>
Effect.sync(() => {
expect(EventV2.registry.get(Message.type)).toBe(Message)
}),
)
it.effect("keeps the latest sync definition in the registry", () =>
Effect.sync(() => {
const latest = EventV2.define({
type: "test.out-of-order",
durable: { version: 2, aggregate: "id" },
schema: { id: Schema.String },
})
const historical = EventV2.define({
EventV2.define({
type: "test.out-of-order",
durable: { version: 1, aggregate: "id" },
schema: { id: Schema.String },
})
expect(Event.latest([latest, historical]).get("test.out-of-order")).toBe(latest)
expect(Event.latest([historical, latest]).get("test.out-of-order")).toBe(latest)
expect(EventV2.registry.get("test.out-of-order")).toBe(latest)
}),
)
@@ -408,7 +420,6 @@ describe("EventV2", () => {
const continueRead = yield* Deferred.make<void>()
let pause = true
const eventLayer = EventV2.layerWith({
definitions: [SyncMessage],
beforeAggregateRead: () =>
pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
+7 -7
View File
@@ -125,7 +125,7 @@ describe("Integration", () => {
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
value: new Credential.Key({ type: "key", key: "secret" }),
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
@@ -149,7 +149,7 @@ describe("Integration", () => {
instructions: "Paste the code",
callback: (code: string) =>
Effect.succeed(
Credential.OAuth.make({
new Credential.OAuth({
type: "oauth",
methodID,
access: "access",
@@ -175,7 +175,7 @@ describe("Integration", () => {
expect.objectContaining({
integrationID,
label: "Personal",
value: Credential.OAuth.make({
value: new Credential.OAuth({
type: "oauth",
methodID,
access: "access",
@@ -238,7 +238,7 @@ describe("Integration", () => {
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.succeed(
Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
),
}),
}),
@@ -315,12 +315,12 @@ describe("Integration", () => {
const work = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "a" }),
value: new Credential.Key({ type: "key", key: "a" }),
})
const personal = yield* credentials.create({
integrationID,
label: "Personal",
value: Credential.Key.make({ type: "key", key: "b" }),
value: new Credential.Key({ type: "key", key: "b" }),
})
// Stored credentials and detected env vars appear as connections.
@@ -332,7 +332,7 @@ describe("Integration", () => {
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.active(integrationID)).toEqual({
expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({
type: "credential",
id: personal.id,
label: "Personal",
+6 -10
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/public"
import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
@@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer
const it = testEffect(
Layer.merge(
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
LocationServiceMap.layer.pipe(
LocationServiceMap.layerWithApplicationTools.pipe(
Layer.provide(applicationTools),
Layer.provide(
Layer.mergeAll(
@@ -52,7 +52,7 @@ const it = testEffect(
)
describe("LocationServiceMap", () => {
it.live("reuses cached services for constructed and decoded location refs", () =>
it.live("reuses cached services for equivalent location refs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -62,14 +62,10 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap
const directory = AbsolutePath.make(dir.path)
const constructed = Location.Ref.make({ directory })
const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory })
const first = yield* locations.contextEffect(Location.Ref.make({ directory }))
const second = yield* locations.contextEffect(Location.Ref.make({ directory, workspaceID: undefined }))
expect(constructed).toEqual({ directory, workspaceID: undefined })
expect(decoded).toEqual(constructed)
expect(Equal.equals(constructed, decoded)).toBe(true)
expect(Hash.hash(constructed)).toBe(Hash.hash(decoded))
expect(yield* locations.contextEffect(constructed)).toBe(yield* locations.contextEffect(decoded))
expect(first).toBe(second)
}),
),
),
-2
View File
@@ -9,7 +9,6 @@ import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { tempLocationLayer } from "../fixture/location"
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
@@ -17,7 +16,6 @@ export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2
Layer.mergeAll(
Credential.defaultLayer,
EventV2.defaultLayer,
FetchHttpClient.layer,
FSUtil.defaultLayer,
Global.defaultLayer,
Layer.succeed(
+10 -78
View File
@@ -1,7 +1,6 @@
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -32,10 +31,6 @@ export function host(overrides: Overrides = {}): PluginContext {
integration: overrides.integration ?? {
transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"),
connection: {
active: () => Effect.die("unused integration.connection.active"),
resolve: () => Effect.die("unused integration.connection.resolve"),
},
},
plugin: overrides.plugin ?? {
add: () => Effect.die("unused plugin.add"),
@@ -143,13 +138,6 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
return {
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
resolve: (connection) =>
integration.connection.resolve(
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
),
},
transform: (callback) =>
integration.transform((draft) =>
callback({
@@ -162,72 +150,16 @@ export function integrationHost(integration: Integration.Interface): PluginConte
remove: (id) => draft.remove(Integration.ID.make(id)),
method: {
list: (id) => draft.method.list(Integration.ID.make(id)).map(method),
update: (input) => {
if ("authorize" in input) {
const methodID = Integration.MethodID.make(input.method.id)
const refresh = input.refresh
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, id: methodID },
authorize: (inputs) =>
input.authorize(inputs).pipe(
Effect.map((authorization) => {
if (authorization.mode === "auto") {
return {
...authorization,
callback: authorization.callback.pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}
return {
...authorization,
callback: (code: string) =>
authorization.callback(code).pipe(
Effect.map((credential) =>
Credential.OAuth.make({
...credential,
methodID: Integration.MethodID.make(credential.methodID),
}),
),
),
}
}),
),
...(refresh
? {
refresh: (value: Credential.OAuth) =>
refresh(value).pipe(
Effect.map((next) =>
Credential.OAuth.make({
...next,
methodID: Integration.MethodID.make(next.methodID),
}),
),
),
}
: {}),
...(input.label ? { label: input.label } : {}),
})
return
}
if (input.method.type === "env") {
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, names: [...input.method.names] },
})
return
}
draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: input.method,
})
},
update: (input) =>
input.method.type === "env"
? draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: { ...input.method, names: [...input.method.names] },
})
: draft.method.update({
integrationID: Integration.ID.make(input.integrationID),
method: input.method,
}),
remove: (id, item) => draft.method.remove(Integration.ID.make(id), internalMethod(item)),
},
}),

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