Merge remote-tracking branch 'origin/v2' into mcp-prompts

# Conflicts:
#	packages/core/src/mcp/client.ts
#	packages/core/src/mcp/index.ts
This commit is contained in:
Aiden Cline 2026-06-30 10:32:27 -05:00
commit 118bf05f32
155 changed files with 7843 additions and 2606 deletions

View file

@ -1,6 +1,7 @@
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.

View file

@ -98,6 +98,7 @@
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",
@ -582,6 +583,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@ -933,6 +935,7 @@
"name": "@opencode-ai/tui",
"version": "1.17.11",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/sdk": "workspace:*",

View file

@ -8,7 +8,7 @@
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
@ -23,12 +23,12 @@ termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
## Interactive debugging
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
@ -56,7 +56,7 @@ termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
@ -85,7 +85,7 @@ bun dev api <operationId> --param key=value
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.

View file

@ -17,6 +17,7 @@
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/server": "workspace:*",

View file

@ -44,18 +44,26 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Spec.make("restart", { description: "Restart the background server" }),
Spec.make("status", { description: "Show background server status" }),
Spec.make("stop", { description: "Stop the background server" }),
Spec.make("password", {
description: "Get or set the server password",
params: { value: Argument.string("value").pipe(Argument.optional) },
Spec.make("get", {
description: "Get service configuration",
params: { key: Argument.string("key").pipe(Argument.optional) },
}),
Spec.make("set", {
description: "Set service configuration",
params: { key: Argument.string("key"), value: Argument.string("value") },
}),
Spec.make("unset", {
description: "Unset service configuration",
params: { key: Argument.string("key") },
}),
],
}),
Spec.make("serve", {
description: "Start the v2 API server",
params: {
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
hostname: Flag.string("hostname").pipe(Flag.optional),
port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
},
}),

View file

@ -12,6 +12,7 @@ import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon"
import { Updater } from "../../services/updater"
import { randomBytes } from "crypto"
export default Runtime.handler(
Commands.commands.serve,
@ -21,18 +22,28 @@ export default Runtime.handler(
const daemon = yield* Daemon.Service
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const password = input.stdio ? standalonePassword : yield* daemon.password()
const config = input.service ? yield* daemon.config() : {}
const password = input.service
? yield* daemon.password()
: standalonePassword || randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(input.hostname, input.port, password)
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
const port = Option.isSome(input.port)
? input.port
: config.port === undefined
? Option.none<number>()
: Option.some(config.port)
const address = yield* listen(hostname, port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.location.get(undefined, { throwOnError: true }),
)
if (input.register) yield* daemon.register(address)
if (input.service) yield* daemon.register(address)
const url = HttpServer.formatAddress(address)
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* (input.stdio ? waitForStdinClose() : Effect.never)

View file

@ -6,11 +6,9 @@ import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.password,
Effect.fn("cli.service.password")(function* (input) {
Commands.commands.service.commands.get,
Effect.fn("cli.service.get")(function* (input) {
const daemon = yield* Daemon.Service
const value = Option.getOrUndefined(input.value)
if (value !== undefined) yield* daemon.stop()
process.stdout.write((yield* daemon.password(value)) + EOL)
process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL)
}),
)

View file

@ -0,0 +1,11 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.set,
Effect.fn("cli.service.set")(function* (input) {
yield* (yield* Daemon.Service).set(input.key, input.value)
}),
)

View file

@ -0,0 +1,11 @@
import * as Effect from "effect/Effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { Daemon } from "../../../services/daemon"
export default Runtime.handler(
Commands.commands.service.commands.unset,
Effect.fn("cli.service.unset")(function* (input) {
yield* (yield* Daemon.Service).unset(input.key)
}),
)

View file

@ -30,7 +30,9 @@ const Handlers = Runtime.handlers(Commands, {
restart: () => import("./commands/handlers/service/restart"),
status: () => import("./commands/handlers/service/status"),
stop: () => import("./commands/handlers/service/stop"),
password: () => import("./commands/handlers/service/password"),
get: () => import("./commands/handlers/service/get"),
set: () => import("./commands/handlers/service/set"),
unset: () => import("./commands/handlers/service/unset"),
},
serve: () => import("./commands/handlers/serve"),
})

View file

@ -15,6 +15,10 @@ export interface Interface {
readonly status: () => Effect.Effect<string | undefined>
readonly stop: () => Effect.Effect<void, unknown>
readonly password: (value?: string) => Effect.Effect<string, unknown>
readonly config: () => Effect.Effect<ServiceConfig, unknown>
readonly get: (key?: string) => Effect.Effect<string, unknown>
readonly set: (key: string, value: string) => Effect.Effect<void, unknown>
readonly unset: (key: string) => Effect.Effect<void, unknown>
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
}
@ -28,9 +32,21 @@ const Registration = Schema.Struct({
})
type Registration = typeof Registration.Type
const Config = Schema.Struct({
const ServiceConfig = Schema.Struct({
hostname: Schema.optional(Schema.String),
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
password: Schema.optional(Schema.String),
autostart: Schema.optional(Schema.Boolean),
})
export type ServiceConfig = typeof ServiceConfig.Type
const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const
type ServiceConfigKey = (typeof serviceConfigKeys)[number]
function serviceConfigKey(key: string): ServiceConfigKey {
if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
throw new Error(`Unknown service config key: ${key}`)
}
function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
@ -40,33 +56,117 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state
const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const global = yield* Global.Service
const directory = global.state
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
const file = path.join(directory, filename)
const configFile = path.join(global.config, filename)
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
const config = Effect.fn("cli.daemon.config")(function* () {
return yield* fs.readFileString(configFile).pipe(
Effect.flatMap(decodeServiceConfig),
Effect.catch(() => Effect.succeed({} as ServiceConfig)),
)
})
const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) {
const temp = configFile + ".tmp"
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
})
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const config = yield* fs
.readFileString(configFile)
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && config?.password) return config.password
const legacy = yield* fs
.readFileString(legacyPasswordFile)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
const existing = yield* config()
if (value === undefined && existing.password) return existing.password
const next = value ?? randomBytes(32).toString("base64url")
// Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable.
const temp = configFile + ".tmp"
yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, configFile)
if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
yield* writeConfig({ ...existing, password: next })
return next
})
const get = Effect.fn("cli.daemon.get")(function* (key?: string) {
if (key === undefined) {
const { password: _password, ...safe } = yield* config()
return JSON.stringify(safe, null, 2)
}
switch (serviceConfigKey(key)) {
case "hostname": {
return (yield* config()).hostname ?? ""
}
case "port": {
const port = (yield* config()).port
return port === undefined ? "" : String(port)
}
case "password": {
return yield* password()
}
case "autostart": {
const autostart = (yield* config()).autostart
return autostart === undefined ? "" : String(autostart)
}
}
})
const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) {
switch (serviceConfigKey(key)) {
case "hostname": {
yield* stop()
yield* writeConfig({ ...(yield* config()), hostname: value })
return
}
case "port": {
const port = Number(value)
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
yield* stop()
yield* writeConfig({ ...(yield* config()), port })
return
}
case "password": {
yield* stop()
yield* password(value)
return
}
case "autostart": {
if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false")
yield* writeConfig({ ...(yield* config()), autostart: value === "true" })
return
}
}
})
const unset = Effect.fn("cli.daemon.unset")(function* (key: string) {
switch (serviceConfigKey(key)) {
case "hostname": {
yield* stop()
const { hostname: _hostname, ...next } = yield* config()
yield* writeConfig(next)
return
}
case "port": {
yield* stop()
const { port: _port, ...next } = yield* config()
yield* writeConfig(next)
return
}
case "password": {
yield* stop()
const { password: _password, ...next } = yield* config()
yield* writeConfig(next)
return
}
case "autostart": {
const { autostart: _autostart, ...next } = yield* config()
yield* writeConfig(next)
return
}
}
})
const registration = Effect.fnUntraced(function* () {
return yield* fs.readFileString(file).pipe(Effect.flatMap(decodeRegistration))
})
@ -83,6 +183,16 @@ export const layer = Layer.effect(
return yield* Effect.fail(new Error("Registered server is not healthy"))
})
const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) {
const url = serviceURL(input)
const headers = ServerAuth.headers({ password: input.password })
const response = yield* Effect.tryPromise(() =>
createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }),
)
if (response.data?.healthy === true) return { url, headers }
return yield* Effect.fail(new Error(`Server is not healthy: ${url}`))
})
const compatible = Effect.fnUntraced(function* () {
const info = yield* healthy()
if (info.version === InstallationVersion) return info
@ -131,7 +241,7 @@ export const layer = Layer.effect(
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
yield* Effect.try({
try: () => {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], {
detached: true,
stdio: "ignore",
}).unref()
@ -147,6 +257,8 @@ export const layer = Layer.effect(
})
const transport = Effect.fn("cli.daemon.transport")(function* () {
const current = yield* config()
if (current.autostart === false) return yield* remoteTransport(current)
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
})
@ -197,10 +309,17 @@ export const layer = Layer.effect(
)
})
return Service.of({ client, transport, start, status, stop, password, register })
return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register })
}),
)
export const defaultLayer = layer
export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer))
function serviceURL(config: ServiceConfig) {
const hostname = config.hostname ?? "127.0.0.1"
const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`)
result.port = String(config.port ?? 4096)
return result.toString()
}
export * as Daemon from "./daemon"

View file

@ -3,6 +3,7 @@ import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
type Transport = { url: string; headers: RequestInit["headers"] }
@ -12,23 +13,23 @@ export function runTui(transport: Transport, reload?: () => Promise<Transport>)
let disposeSlots: (() => void) | undefined
return Effect.gen(function* () {
const options = { baseUrl: transport.url, headers: transport.headers }
const client = createOpencodeClient(options)
const directory = yield* Effect.tryPromise(() =>
client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
).pipe(
Effect.map((response) => response.data.location.directory),
const api = OpenCode.make(options)
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
Effect.map((response) => response.location.directory),
Effect.catch(() =>
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
Effect.map((response) => response.data.directory),
),
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
),
)
return yield* run({
client: createOpencodeClient({ ...options, directory }),
api,
reload: reload
? async () => {
const next = await reload()
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
return {
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
}
}
: undefined,
args: {},

View file

@ -0,0 +1,30 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Daemon } from "../src/services/daemon"
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-"))
try {
await Effect.runPromise(
Effect.gen(function* () {
const daemon = yield* Daemon.Service
yield* daemon.set("autostart", "false")
}).pipe(
Effect.provide(Daemon.layer),
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
autostart: false,
})
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})

View file

@ -1,16 +1,17 @@
import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
import { ClientApi, effectOmitEndpoints, endpointNames, groupNames, promiseOmitEndpoints } from "../src/contract"
import { Effect } from "effect"
import { fileURLToPath } from "url"
const contract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
await Effect.runPromise(
Effect.all(
[
write(
emitPromise(contract, {
emitPromise(promiseContract, {
outputTypes: {
"events.subscribe": {
name: "OpenCodeEventEncoded",
@ -21,7 +22,7 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/generated", import.meta.url)),
),
write(
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
emitEffectImported(effectContract, { module: "../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
),
],

View file

@ -19,23 +19,25 @@ export const ClientApi = makeDefaultApi({
export const groupNames = {
"server.health": "health",
"server.location": "location",
"server.agent": "agents",
"server.session": "sessions",
"server.message": "messages",
"server.model": "models",
"server.agent": "agent",
"server.session": "session",
"server.message": "message",
"server.model": "model",
"server.generate": "generate",
"server.provider": "providers",
"server.integration": "integrations",
"server.credential": "credentials",
"server.permission": "permissions",
"server.fs": "files",
"server.command": "commands",
"server.skill": "skills",
"server.event": "events",
"server.pty": "ptys",
"server.question": "questions",
"server.reference": "references",
"server.projectCopy": "projectCopies",
"server.provider": "provider",
"server.integration": "integration",
"server.credential": "credential",
"server.permission": "permission",
"server.fs": "file",
"server.command": "command",
"server.skill": "skill",
"server.event": "event",
"server.pty": "pty",
"server.shell": "shell",
"server.question": "question",
"server.reference": "reference",
"server.project": "project",
"server.projectCopy": "projectCopy",
} as const
export const endpointNames = {
@ -51,4 +53,5 @@ export const endpointNames = {
"question.request.list": "listRequests",
} as const
export const omitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
export const effectOmitEndpoints = new Set(["fs.read", "pty.connect", "pty.connectToken"])

View file

@ -86,45 +86,56 @@ const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Inp
Effect.map((value) => value.data),
)
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.fork"]>[0]
type Endpoint3_4Input = {
readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
readonly agent: Endpoint3_4Request["payload"]["agent"]
readonly messageID?: Endpoint3_4Request["payload"]["messageID"]
}
const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint3_5Input = {
readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
readonly agent: Endpoint3_5Request["payload"]["agent"]
}
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint3_5Input = {
readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
readonly model: Endpoint3_5Request["payload"]["model"]
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint3_6Input = {
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly model: Endpoint3_6Request["payload"]["model"]
}
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
type Endpoint3_6Input = {
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly title: Endpoint3_6Request["payload"]["title"]
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
type Endpoint3_7Input = {
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
readonly title: Endpoint3_7Request["payload"]["title"]
}
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_7Input = {
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
readonly id?: Endpoint3_7Request["payload"]["id"]
readonly prompt: Endpoint3_7Request["payload"]["prompt"]
readonly delivery?: Endpoint3_7Request["payload"]["delivery"]
readonly resume?: Endpoint3_7Request["payload"]["resume"]
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint3_8Input = {
readonly sessionID: Endpoint3_8Request["params"]["sessionID"]
readonly id?: Endpoint3_8Request["payload"]["id"]
readonly prompt: Endpoint3_8Request["payload"]["prompt"]
readonly delivery?: Endpoint3_8Request["payload"]["delivery"]
readonly resume?: Endpoint3_8Request["payload"]["resume"]
}
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
@ -133,23 +144,36 @@ const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Inp
Effect.map((value) => value.data),
)
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint3_9Input = {
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
readonly id?: Endpoint3_9Request["payload"]["id"]
readonly skill: Endpoint3_9Request["payload"]["skill"]
readonly resume?: Endpoint3_9Request["payload"]["resume"]
}
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.skill"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_10Input = {
readonly sessionID: Endpoint3_10Request["params"]["sessionID"]
readonly messageID: Endpoint3_10Request["payload"]["messageID"]
readonly files?: Endpoint3_10Request["payload"]["files"]
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_12Input = {
readonly sessionID: Endpoint3_12Request["params"]["sessionID"]
readonly messageID: Endpoint3_12Request["payload"]["messageID"]
readonly files?: Endpoint3_12Request["payload"]["files"]
}
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@ -158,42 +182,42 @@ const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10I
Effect.map((value) => value.data),
)
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint3_14Input = {
readonly sessionID: Endpoint3_14Request["params"]["sessionID"]
readonly limit?: Endpoint3_14Request["query"]["limit"]
readonly after?: Endpoint3_14Request["query"]["after"]
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint3_16Input = {
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly limit?: Endpoint3_16Request["query"]["limit"]
readonly after?: Endpoint3_16Request["query"]["after"]
}
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
raw["session.history"]({
params: { sessionID: input["sessionID"] },
query: { limit: input["limit"], after: input["after"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_15Input = {
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly after?: Endpoint3_15Request["query"]["after"]
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_17Input = {
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
readonly after?: Endpoint3_17Request["query"]["after"]
}
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError),
@ -201,17 +225,17 @@ const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15I
),
)
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_16Input = { readonly sessionID: Endpoint3_16Request["params"]["sessionID"] }
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
type Endpoint3_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] }
const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_17Input = {
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
readonly messageID: Endpoint3_17Request["params"]["messageID"]
type Endpoint3_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_19Input = {
readonly sessionID: Endpoint3_19Request["params"]["sessionID"]
readonly messageID: Endpoint3_19Request["params"]["messageID"]
}
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
const Endpoint3_19 = (raw: RawClient["server.session"]) => (input: Endpoint3_19Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@ -222,20 +246,22 @@ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
create: Endpoint3_1(raw),
active: Endpoint3_2(raw),
get: Endpoint3_3(raw),
switchAgent: Endpoint3_4(raw),
switchModel: Endpoint3_5(raw),
rename: Endpoint3_6(raw),
prompt: Endpoint3_7(raw),
compact: Endpoint3_8(raw),
wait: Endpoint3_9(raw),
stage: Endpoint3_10(raw),
clear: Endpoint3_11(raw),
commit: Endpoint3_12(raw),
context: Endpoint3_13(raw),
history: Endpoint3_14(raw),
events: Endpoint3_15(raw),
interrupt: Endpoint3_16(raw),
message: Endpoint3_17(raw),
fork: Endpoint3_4(raw),
switchAgent: Endpoint3_5(raw),
switchModel: Endpoint3_6(raw),
rename: Endpoint3_7(raw),
prompt: Endpoint3_8(raw),
skill: Endpoint3_9(raw),
compact: Endpoint3_10(raw),
wait: Endpoint3_11(raw),
stage: Endpoint3_12(raw),
clear: Endpoint3_13(raw),
commit: Endpoint3_14(raw),
context: Endpoint3_15(raw),
history: Endpoint3_16(raw),
events: Endpoint3_17(raw),
interrupt: Endpoint3_18(raw),
message: Endpoint3_19(raw),
})
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@ -384,62 +410,90 @@ const adaptGroup8 = (raw: RawClient["server.integration"]) => ({
attemptCancel: Endpoint8_6(raw),
})
type Endpoint9_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint9_0Input = {
readonly credentialID: Endpoint9_0Request["params"]["credentialID"]
readonly location?: Endpoint9_0Request["query"]["location"]
readonly label: Endpoint9_0Request["payload"]["label"]
type Endpoint9_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint9_0Input) =>
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint9_0(raw) })
type Endpoint10_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint10_0Input = {
readonly credentialID: Endpoint10_0Request["params"]["credentialID"]
readonly location?: Endpoint10_0Request["query"]["location"]
readonly label: Endpoint10_0Request["payload"]["label"]
}
const Endpoint9_0 = (raw: RawClient["server.credential"]) => (input: Endpoint9_0Input) =>
const Endpoint10_0 = (raw: RawClient["server.credential"]) => (input: Endpoint10_0Input) =>
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint9_1Input = {
readonly credentialID: Endpoint9_1Request["params"]["credentialID"]
readonly location?: Endpoint9_1Request["query"]["location"]
type Endpoint10_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint10_1Input = {
readonly credentialID: Endpoint10_1Request["params"]["credentialID"]
readonly location?: Endpoint10_1Request["query"]["location"]
}
const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1Input) =>
const Endpoint10_1 = (raw: RawClient["server.credential"]) => (input: Endpoint10_1Input) =>
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) })
const adaptGroup10 = (raw: RawClient["server.credential"]) => ({ update: Endpoint10_0(raw), remove: Endpoint10_1(raw) })
type Endpoint10_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] }
const Endpoint10_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_0Input) =>
type Endpoint11_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.project"]) => (input?: Endpoint11_0Input) =>
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint11_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint11_1Input = {
readonly projectID: Endpoint11_1Request["params"]["projectID"]
readonly location?: Endpoint11_1Request["query"]["location"]
}
const Endpoint11_1 = (raw: RawClient["server.project"]) => (input: Endpoint11_1Input) =>
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.project"]) => ({
current: Endpoint11_0(raw),
directories: Endpoint11_1(raw),
})
type Endpoint12_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_0Input) =>
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint10_1Input = { readonly projectID?: Endpoint10_1Request["query"]["projectID"] }
const Endpoint10_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint10_1Input) =>
type Endpoint12_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint12_1Input = { readonly projectID?: Endpoint12_1Request["query"]["projectID"] }
const Endpoint12_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint10_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint10_2Input = { readonly id: Endpoint10_2Request["params"]["id"] }
const Endpoint10_2 = (raw: RawClient["server.permission"]) => (input: Endpoint10_2Input) =>
type Endpoint12_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint12_2Input = { readonly id: Endpoint12_2Request["params"]["id"] }
const Endpoint12_2 = (raw: RawClient["server.permission"]) => (input: Endpoint12_2Input) =>
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint10_3Input = {
readonly sessionID: Endpoint10_3Request["params"]["sessionID"]
readonly id?: Endpoint10_3Request["payload"]["id"]
readonly action: Endpoint10_3Request["payload"]["action"]
readonly resources: Endpoint10_3Request["payload"]["resources"]
readonly save?: Endpoint10_3Request["payload"]["save"]
readonly metadata?: Endpoint10_3Request["payload"]["metadata"]
readonly source?: Endpoint10_3Request["payload"]["source"]
readonly agent?: Endpoint10_3Request["payload"]["agent"]
type Endpoint12_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint12_3Input = {
readonly sessionID: Endpoint12_3Request["params"]["sessionID"]
readonly id?: Endpoint12_3Request["payload"]["id"]
readonly action: Endpoint12_3Request["payload"]["action"]
readonly resources: Endpoint12_3Request["payload"]["resources"]
readonly save?: Endpoint12_3Request["payload"]["save"]
readonly metadata?: Endpoint12_3Request["payload"]["metadata"]
readonly source?: Endpoint12_3Request["payload"]["source"]
readonly agent?: Endpoint12_3Request["payload"]["agent"]
}
const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10_3Input) =>
const Endpoint12_3 = (raw: RawClient["server.permission"]) => (input: Endpoint12_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input["sessionID"] },
payload: {
@ -456,87 +510,87 @@ const Endpoint10_3 = (raw: RawClient["server.permission"]) => (input: Endpoint10
Effect.map((value) => value.data),
)
type Endpoint10_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint10_4Input = { readonly sessionID: Endpoint10_4Request["params"]["sessionID"] }
const Endpoint10_4 = (raw: RawClient["server.permission"]) => (input: Endpoint10_4Input) =>
type Endpoint12_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint12_4Input = { readonly sessionID: Endpoint12_4Request["params"]["sessionID"] }
const Endpoint12_4 = (raw: RawClient["server.permission"]) => (input: Endpoint12_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint10_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint10_5Input = {
readonly sessionID: Endpoint10_5Request["params"]["sessionID"]
readonly requestID: Endpoint10_5Request["params"]["requestID"]
type Endpoint12_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint12_5Input = {
readonly sessionID: Endpoint12_5Request["params"]["sessionID"]
readonly requestID: Endpoint12_5Request["params"]["requestID"]
}
const Endpoint10_5 = (raw: RawClient["server.permission"]) => (input: Endpoint10_5Input) =>
const Endpoint12_5 = (raw: RawClient["server.permission"]) => (input: Endpoint12_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint10_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint10_6Input = {
readonly sessionID: Endpoint10_6Request["params"]["sessionID"]
readonly requestID: Endpoint10_6Request["params"]["requestID"]
readonly reply: Endpoint10_6Request["payload"]["reply"]
readonly message?: Endpoint10_6Request["payload"]["message"]
type Endpoint12_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint12_6Input = {
readonly sessionID: Endpoint12_6Request["params"]["sessionID"]
readonly requestID: Endpoint12_6Request["params"]["requestID"]
readonly reply: Endpoint12_6Request["payload"]["reply"]
readonly message?: Endpoint12_6Request["payload"]["message"]
}
const Endpoint10_6 = (raw: RawClient["server.permission"]) => (input: Endpoint10_6Input) =>
const Endpoint12_6 = (raw: RawClient["server.permission"]) => (input: Endpoint12_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint10_0(raw),
listSaved: Endpoint10_1(raw),
removeSaved: Endpoint10_2(raw),
create: Endpoint10_3(raw),
list: Endpoint10_4(raw),
get: Endpoint10_5(raw),
reply: Endpoint10_6(raw),
const adaptGroup12 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint12_0(raw),
listSaved: Endpoint12_1(raw),
removeSaved: Endpoint12_2(raw),
create: Endpoint12_3(raw),
list: Endpoint12_4(raw),
get: Endpoint12_5(raw),
reply: Endpoint12_6(raw),
})
type Endpoint11_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint11_0Input = {
readonly location?: Endpoint11_0Request["query"]["location"]
readonly path?: Endpoint11_0Request["query"]["path"]
type Endpoint13_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint13_0Input = {
readonly location?: Endpoint13_0Request["query"]["location"]
readonly path?: Endpoint13_0Request["query"]["path"]
}
const Endpoint11_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint11_0Input) =>
const Endpoint13_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint13_0Input) =>
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint11_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint11_1Input = {
readonly location?: Endpoint11_1Request["query"]["location"]
readonly query: Endpoint11_1Request["query"]["query"]
readonly type?: Endpoint11_1Request["query"]["type"]
readonly limit?: Endpoint11_1Request["query"]["limit"]
type Endpoint13_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint13_1Input = {
readonly location?: Endpoint13_1Request["query"]["location"]
readonly query: Endpoint13_1Request["query"]["query"]
readonly type?: Endpoint13_1Request["query"]["type"]
readonly limit?: Endpoint13_1Request["query"]["limit"]
}
const Endpoint11_1 = (raw: RawClient["server.fs"]) => (input: Endpoint11_1Input) =>
const Endpoint13_1 = (raw: RawClient["server.fs"]) => (input: Endpoint13_1Input) =>
raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.fs"]) => ({ list: Endpoint11_0(raw), find: Endpoint11_1(raw) })
const adaptGroup13 = (raw: RawClient["server.fs"]) => ({ list: Endpoint13_0(raw), find: Endpoint13_1(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.command"]) => (input?: Endpoint12_0Input) =>
type Endpoint14_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.command"]) => (input?: Endpoint14_0Input) =>
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.command"]) => ({ list: Endpoint12_0(raw) })
const adaptGroup14 = (raw: RawClient["server.command"]) => ({ list: Endpoint14_0(raw) })
type Endpoint13_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
const Endpoint13_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint13_0Input) =>
type Endpoint15_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint15_0Input) =>
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup13 = (raw: RawClient["server.skill"]) => ({ list: Endpoint13_0(raw) })
const adaptGroup15 = (raw: RawClient["server.skill"]) => ({ list: Endpoint15_0(raw) })
const Endpoint14_0 = (raw: RawClient["server.event"]) => () =>
const Endpoint16_0 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap(
raw["event.subscribe"]({}).pipe(
Effect.mapError(mapClientError),
@ -544,23 +598,23 @@ const Endpoint14_0 = (raw: RawClient["server.event"]) => () =>
),
)
const adaptGroup14 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint14_0(raw) })
const adaptGroup16 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint16_0(raw) })
type Endpoint15_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_0Input) =>
type Endpoint17_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_0Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint15_1Input = {
readonly location?: Endpoint15_1Request["query"]["location"]
readonly command?: Endpoint15_1Request["payload"]["command"]
readonly args?: Endpoint15_1Request["payload"]["args"]
readonly cwd?: Endpoint15_1Request["payload"]["cwd"]
readonly title?: Endpoint15_1Request["payload"]["title"]
readonly env?: Endpoint15_1Request["payload"]["env"]
type Endpoint17_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint17_1Input = {
readonly location?: Endpoint17_1Request["query"]["location"]
readonly command?: Endpoint17_1Request["payload"]["command"]
readonly args?: Endpoint17_1Request["payload"]["args"]
readonly cwd?: Endpoint17_1Request["payload"]["cwd"]
readonly title?: Endpoint17_1Request["payload"]["title"]
readonly env?: Endpoint17_1Request["payload"]["env"]
}
const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Input) =>
const Endpoint17_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_1Input) =>
raw["pty.create"]({
query: { location: input?.["location"] },
payload: {
@ -572,224 +626,226 @@ const Endpoint15_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint15_1Inpu
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint15_2Input = {
readonly ptyID: Endpoint15_2Request["params"]["ptyID"]
readonly location?: Endpoint15_2Request["query"]["location"]
type Endpoint17_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint17_2Input = {
readonly ptyID: Endpoint17_2Request["params"]["ptyID"]
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint15_2 = (raw: RawClient["server.pty"]) => (input: Endpoint15_2Input) =>
const Endpoint17_2 = (raw: RawClient["server.pty"]) => (input: Endpoint17_2Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint15_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint15_3Input = {
readonly ptyID: Endpoint15_3Request["params"]["ptyID"]
readonly location?: Endpoint15_3Request["query"]["location"]
readonly title?: Endpoint15_3Request["payload"]["title"]
readonly size?: Endpoint15_3Request["payload"]["size"]
type Endpoint17_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint17_3Input = {
readonly ptyID: Endpoint17_3Request["params"]["ptyID"]
readonly location?: Endpoint17_3Request["query"]["location"]
readonly title?: Endpoint17_3Request["payload"]["title"]
readonly size?: Endpoint17_3Request["payload"]["size"]
}
const Endpoint15_3 = (raw: RawClient["server.pty"]) => (input: Endpoint15_3Input) =>
const Endpoint17_3 = (raw: RawClient["server.pty"]) => (input: Endpoint17_3Input) =>
raw["pty.update"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint15_4Input = {
readonly ptyID: Endpoint15_4Request["params"]["ptyID"]
readonly location?: Endpoint15_4Request["query"]["location"]
type Endpoint17_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint17_4Input = {
readonly ptyID: Endpoint17_4Request["params"]["ptyID"]
readonly location?: Endpoint17_4Request["query"]["location"]
}
const Endpoint15_4 = (raw: RawClient["server.pty"]) => (input: Endpoint15_4Input) =>
const Endpoint17_4 = (raw: RawClient["server.pty"]) => (input: Endpoint17_4Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup15 = (raw: RawClient["server.pty"]) => ({
list: Endpoint15_0(raw),
create: Endpoint15_1(raw),
get: Endpoint15_2(raw),
update: Endpoint15_3(raw),
remove: Endpoint15_4(raw),
const adaptGroup17 = (raw: RawClient["server.pty"]) => ({
list: Endpoint17_0(raw),
create: Endpoint17_1(raw),
get: Endpoint17_2(raw),
update: Endpoint17_3(raw),
remove: Endpoint17_4(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint16_0Input) =>
type Endpoint18_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
const Endpoint18_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint18_0Input) =>
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint16_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
type Endpoint16_1Input = {
readonly location?: Endpoint16_1Request["query"]["location"]
readonly command: Endpoint16_1Request["payload"]["command"]
readonly cwd?: Endpoint16_1Request["payload"]["cwd"]
readonly timeout?: Endpoint16_1Request["payload"]["timeout"]
readonly metadata?: Endpoint16_1Request["payload"]["metadata"]
type Endpoint18_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
type Endpoint18_1Input = {
readonly location?: Endpoint18_1Request["query"]["location"]
readonly command: Endpoint18_1Request["payload"]["command"]
readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
readonly timeout?: Endpoint18_1Request["payload"]["timeout"]
readonly metadata?: Endpoint18_1Request["payload"]["metadata"]
}
const Endpoint16_1 = (raw: RawClient["server.shell"]) => (input: Endpoint16_1Input) =>
const Endpoint18_1 = (raw: RawClient["server.shell"]) => (input: Endpoint18_1Input) =>
raw["shell.create"]({
query: { location: input["location"] },
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint16_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
type Endpoint16_2Input = {
readonly id: Endpoint16_2Request["params"]["id"]
readonly location?: Endpoint16_2Request["query"]["location"]
type Endpoint18_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
type Endpoint18_2Input = {
readonly id: Endpoint18_2Request["params"]["id"]
readonly location?: Endpoint18_2Request["query"]["location"]
}
const Endpoint16_2 = (raw: RawClient["server.shell"]) => (input: Endpoint16_2Input) =>
const Endpoint18_2 = (raw: RawClient["server.shell"]) => (input: Endpoint18_2Input) =>
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint16_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
type Endpoint16_3Input = {
readonly id: Endpoint16_3Request["params"]["id"]
readonly location?: Endpoint16_3Request["query"]["location"]
readonly cursor?: Endpoint16_3Request["query"]["cursor"]
readonly limit?: Endpoint16_3Request["query"]["limit"]
type Endpoint18_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
type Endpoint18_3Input = {
readonly id: Endpoint18_3Request["params"]["id"]
readonly location?: Endpoint18_3Request["query"]["location"]
readonly cursor?: Endpoint18_3Request["query"]["cursor"]
readonly limit?: Endpoint18_3Request["query"]["limit"]
}
const Endpoint16_3 = (raw: RawClient["server.shell"]) => (input: Endpoint16_3Input) =>
const Endpoint18_3 = (raw: RawClient["server.shell"]) => (input: Endpoint18_3Input) =>
raw["shell.output"]({
params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint16_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
type Endpoint16_4Input = {
readonly id: Endpoint16_4Request["params"]["id"]
readonly location?: Endpoint16_4Request["query"]["location"]
type Endpoint18_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
type Endpoint18_4Input = {
readonly id: Endpoint18_4Request["params"]["id"]
readonly location?: Endpoint18_4Request["query"]["location"]
}
const Endpoint16_4 = (raw: RawClient["server.shell"]) => (input: Endpoint16_4Input) =>
const Endpoint18_4 = (raw: RawClient["server.shell"]) => (input: Endpoint18_4Input) =>
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup16 = (raw: RawClient["server.shell"]) => ({
list: Endpoint16_0(raw),
create: Endpoint16_1(raw),
get: Endpoint16_2(raw),
output: Endpoint16_3(raw),
remove: Endpoint16_4(raw),
const adaptGroup18 = (raw: RawClient["server.shell"]) => ({
list: Endpoint18_0(raw),
create: Endpoint18_1(raw),
get: Endpoint18_2(raw),
output: Endpoint18_3(raw),
remove: Endpoint18_4(raw),
})
type Endpoint17_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.question"]) => (input?: Endpoint17_0Input) =>
type Endpoint19_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
const Endpoint19_0 = (raw: RawClient["server.question"]) => (input?: Endpoint19_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint17_1Input = { readonly sessionID: Endpoint17_1Request["params"]["sessionID"] }
const Endpoint17_1 = (raw: RawClient["server.question"]) => (input: Endpoint17_1Input) =>
type Endpoint19_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint19_1Input = { readonly sessionID: Endpoint19_1Request["params"]["sessionID"] }
const Endpoint19_1 = (raw: RawClient["server.question"]) => (input: Endpoint19_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint17_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint17_2Input = {
readonly sessionID: Endpoint17_2Request["params"]["sessionID"]
readonly requestID: Endpoint17_2Request["params"]["requestID"]
readonly answers: Endpoint17_2Request["payload"]["answers"]
type Endpoint19_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint19_2Input = {
readonly sessionID: Endpoint19_2Request["params"]["sessionID"]
readonly requestID: Endpoint19_2Request["params"]["requestID"]
readonly answers: Endpoint19_2Request["payload"]["answers"]
}
const Endpoint17_2 = (raw: RawClient["server.question"]) => (input: Endpoint17_2Input) =>
const Endpoint19_2 = (raw: RawClient["server.question"]) => (input: Endpoint19_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint17_3Input = {
readonly sessionID: Endpoint17_3Request["params"]["sessionID"]
readonly requestID: Endpoint17_3Request["params"]["requestID"]
type Endpoint19_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint19_3Input = {
readonly sessionID: Endpoint19_3Request["params"]["sessionID"]
readonly requestID: Endpoint19_3Request["params"]["requestID"]
}
const Endpoint17_3 = (raw: RawClient["server.question"]) => (input: Endpoint17_3Input) =>
const Endpoint19_3 = (raw: RawClient["server.question"]) => (input: Endpoint19_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup17 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint17_0(raw),
list: Endpoint17_1(raw),
reply: Endpoint17_2(raw),
reject: Endpoint17_3(raw),
const adaptGroup19 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint19_0(raw),
list: Endpoint19_1(raw),
reply: Endpoint19_2(raw),
reject: Endpoint19_3(raw),
})
type Endpoint18_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
const Endpoint18_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint18_0Input) =>
type Endpoint20_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
const Endpoint20_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint20_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup18 = (raw: RawClient["server.reference"]) => ({ list: Endpoint18_0(raw) })
const adaptGroup20 = (raw: RawClient["server.reference"]) => ({ list: Endpoint20_0(raw) })
type Endpoint19_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint19_0Input = {
readonly projectID: Endpoint19_0Request["params"]["projectID"]
readonly location?: Endpoint19_0Request["query"]["location"]
readonly strategy: Endpoint19_0Request["payload"]["strategy"]
readonly directory: Endpoint19_0Request["payload"]["directory"]
readonly name?: Endpoint19_0Request["payload"]["name"]
type Endpoint21_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint21_0Input = {
readonly projectID: Endpoint21_0Request["params"]["projectID"]
readonly location?: Endpoint21_0Request["query"]["location"]
readonly strategy: Endpoint21_0Request["payload"]["strategy"]
readonly directory: Endpoint21_0Request["payload"]["directory"]
readonly name?: Endpoint21_0Request["payload"]["name"]
}
const Endpoint19_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_0Input) =>
const Endpoint21_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint19_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint19_1Input = {
readonly projectID: Endpoint19_1Request["params"]["projectID"]
readonly location?: Endpoint19_1Request["query"]["location"]
readonly directory: Endpoint19_1Request["payload"]["directory"]
readonly force: Endpoint19_1Request["payload"]["force"]
type Endpoint21_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint21_1Input = {
readonly projectID: Endpoint21_1Request["params"]["projectID"]
readonly location?: Endpoint21_1Request["query"]["location"]
readonly directory: Endpoint21_1Request["payload"]["directory"]
readonly force: Endpoint21_1Request["payload"]["force"]
}
const Endpoint19_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_1Input) =>
const Endpoint21_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint19_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint19_2Input = {
readonly projectID: Endpoint19_2Request["params"]["projectID"]
readonly location?: Endpoint19_2Request["query"]["location"]
type Endpoint21_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint21_2Input = {
readonly projectID: Endpoint21_2Request["params"]["projectID"]
readonly location?: Endpoint21_2Request["query"]["location"]
}
const Endpoint19_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint19_2Input) =>
const Endpoint21_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_2Input) =>
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup19 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint19_0(raw),
remove: Endpoint19_1(raw),
refresh: Endpoint19_2(raw),
const adaptGroup21 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint21_0(raw),
remove: Endpoint21_1(raw),
refresh: Endpoint21_2(raw),
})
const adaptClient = (raw: RawClient) => ({
health: adaptGroup0(raw["server.health"]),
location: adaptGroup1(raw["server.location"]),
agents: adaptGroup2(raw["server.agent"]),
sessions: adaptGroup3(raw["server.session"]),
messages: adaptGroup4(raw["server.message"]),
models: adaptGroup5(raw["server.model"]),
agent: adaptGroup2(raw["server.agent"]),
session: adaptGroup3(raw["server.session"]),
message: adaptGroup4(raw["server.message"]),
model: adaptGroup5(raw["server.model"]),
generate: adaptGroup6(raw["server.generate"]),
providers: adaptGroup7(raw["server.provider"]),
integrations: adaptGroup8(raw["server.integration"]),
credentials: adaptGroup9(raw["server.credential"]),
permissions: adaptGroup10(raw["server.permission"]),
files: adaptGroup11(raw["server.fs"]),
commands: adaptGroup12(raw["server.command"]),
skills: adaptGroup13(raw["server.skill"]),
events: adaptGroup14(raw["server.event"]),
ptys: adaptGroup15(raw["server.pty"]),
"server.shell": adaptGroup16(raw["server.shell"]),
questions: adaptGroup17(raw["server.question"]),
references: adaptGroup18(raw["server.reference"]),
projectCopies: adaptGroup19(raw["server.projectCopy"]),
provider: adaptGroup7(raw["server.provider"]),
integration: adaptGroup8(raw["server.integration"]),
"server.mcp": adaptGroup9(raw["server.mcp"]),
credential: adaptGroup10(raw["server.credential"]),
project: adaptGroup11(raw["server.project"]),
permission: adaptGroup12(raw["server.permission"]),
file: adaptGroup13(raw["server.fs"]),
command: adaptGroup14(raw["server.command"]),
skill: adaptGroup15(raw["server.skill"]),
event: adaptGroup16(raw["server.event"]),
pty: adaptGroup17(raw["server.pty"]),
shell: adaptGroup18(raw["server.shell"]),
question: adaptGroup19(raw["server.question"]),
reference: adaptGroup20(raw["server.reference"]),
projectCopy: adaptGroup21(raw["server.projectCopy"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,2 +1,3 @@
export * from "./generated/index"
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>

View file

@ -3,6 +3,7 @@ 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 { ProjectV2 } from "@opencode-ai/core/project"
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"
@ -26,10 +27,14 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreLocation.Ref).toBe(Location.Ref)
expect(ModelV2.Ref).toBe(Model.Ref)
expect(SessionV2.Info).toBe(Session.Info)
expect(ProjectV2.Current).toBe(Project.Current)
expect(ProjectV2.Directory).toBe(Project.Directory)
expect(ProjectV2.Directories).toBe(Project.Directories)
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt)
expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(Api.groups["server.project"].identifier).toBe("server.project")
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global")

View file

@ -3,19 +3,19 @@ import { DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
test("sessions.get returns the decoded Effect projection", async () => {
test("session.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") })
return yield* client.session.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("events.subscribe exposes and decodes the native Effect event stream", async () => {
test("event.subscribe exposes and decodes the native Effect event stream", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
@ -30,7 +30,7 @@ test("events.subscribe exposes and decodes the native Effect event stream", asyn
)
const events = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect)
return yield* client.event.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
@ -40,7 +40,7 @@ test("events.subscribe exposes and decodes the native Effect event stream", asyn
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("events.subscribe terminates on Effect protocol decode failures", async () => {
test("event.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
@ -53,7 +53,7 @@ test("events.subscribe terminates on Effect protocol decode failures", async ()
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
return yield* client.event.subscribe().pipe(Stream.runCollect, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("ClientError")
@ -112,41 +112,41 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const page = yield* client.sessions.list({ limit: 10 })
const active = yield* client.sessions.active()
const created = yield* client.sessions.create({
const page = yield* client.session.list({ limit: 10 })
const active = yield* client.session.active()
const created = yield* client.session.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({
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
})
const admitted = yield* client.sessions.prompt({
const admitted = yield* client.session.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") })
const history = yield* client.sessions.history({
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
const history = yield* client.session.history({
sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.sessions.history({
? yield* client.session.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
const events = yield* client.sessions
const events = yield* client.session
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
.pipe(Stream.runCollect)
yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
const message = yield* client.sessions.message({
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
const message = yield* client.session.message({
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"),
})
@ -171,7 +171,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
})
test("sessions.history retains the typed SessionNotFoundError", async () => {
test("session.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
@ -185,7 +185,7 @@ test("sessions.history retains the typed SessionNotFoundError", async () => {
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions
return yield* client.session
.history({
sessionID: Session.ID.make("ses_missing"),
})

View file

@ -7,26 +7,28 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client)).toEqual([
"health",
"location",
"agents",
"sessions",
"messages",
"models",
"agent",
"session",
"message",
"model",
"generate",
"providers",
"integrations",
"credentials",
"permissions",
"files",
"commands",
"skills",
"events",
"ptys",
"questions",
"references",
"projectCopies",
"provider",
"integration",
"credential",
"project",
"permission",
"file",
"command",
"skill",
"event",
"pty",
"shell",
"question",
"reference",
"projectCopy",
])
expect(Object.keys(client.messages)).toEqual(["list"])
expect(Object.keys(client.integrations)).toEqual([
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual([
"list",
"get",
"connectKey",
@ -35,11 +37,95 @@ test("exposes every standard HTTP API group", () => {
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.files)).toEqual(["list", "find"])
expect(Object.keys(client.ptys)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["current", "directories"])
})
test("sessions.get returns the wire projection", async () => {
test("file.read returns binary content from the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([104, 105]))
},
})
const content = await client.file.read({
path: "src/a b#c.ts",
location: { directory: "/tmp/project" },
})
expect(Array.from(content)).toEqual([104, 105])
expect(request?.url).toBe(
"http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
})
test("project methods use the public HTTP contract", async () => {
const requests: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push(url)
if (url.includes("/directories")) return Response.json([])
return Response.json({ id: "proj_test", directory: "/tmp/project" })
},
})
const current = await client.project.current({ location: { workspace: "wrk_test" } })
const directories = await client.project.directories({
projectID: current.id,
location: { directory: current.directory },
})
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
expect(directories).toEqual([])
expect(requests).toEqual([
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
])
})
test("shell list and remove use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const shell = {
id: "sh_test",
status: "running",
command: "pwd",
cwd: "/tmp/project",
shell: "/bin/zsh",
file: "/tmp/opencode-shell",
metadata: { sessionID: "ses_test" },
time: { started: 1_717_171_717_000 },
}
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
if (request.method === "DELETE") return new Response(null, { status: 204 })
return Response.json({
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
data: [shell],
})
},
})
const result = await client.shell.list({ location: { directory: "/tmp/project" } })
await client.shell.remove({ id: shell.id })
expect(result.data).toEqual([shell])
expect(requests).toEqual([
{ method: "GET", url: "http://localhost:3000/api/shell?location%5Bdirectory%5D=%2Ftmp%2Fproject" },
{ method: "DELETE", url: "http://localhost:3000/api/shell/sh_test" },
])
})
test("session.get returns the wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input) => {
@ -50,12 +136,12 @@ test("sessions.get returns the wire projection", async () => {
},
})
const result = await client.sessions.get({ sessionID: "ses_test" })
const result = await client.session.get({ sessionID: "ses_test" })
expect(result.time.created).toBe(1_717_171_717_000)
})
test("events.subscribe exposes the Promise event stream wire projection", async () => {
test("event.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
@ -66,19 +152,19 @@ test("events.subscribe exposes the Promise event stream wire projection", async
),
})
const events = []
for await (const event of client.events.subscribe()) events.push(event)
for await (const event of client.event.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
})
test("events.subscribe terminates on malformed Promise SSE data", async () => {
test("event.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
})
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
await expect(client.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: "ClientError",
reason: "MalformedResponse",
})
@ -113,31 +199,31 @@ test("session methods use the public HTTP contract", async () => {
},
})
const page = await client.sessions.list({ limit: 10, order: "desc" })
const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.sessions.switchModel({
const page = await client.session.list({ limit: 10, order: "desc" })
const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({
sessionID: "ses_test",
model: { id: "claude", providerID: "anthropic" },
})
const admitted = await client.sessions.prompt({
const admitted = await client.session.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" })
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
await client.session.compact({ sessionID: "ses_test" })
await client.session.wait({ sessionID: "ses_test" })
const context = await client.session.context({ sessionID: "ses_test" })
const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 })
const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
const events = []
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event)
await client.sessions.interrupt({ sessionID: "ses_test" })
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event)
await client.session.interrupt({ sessionID: "ses_test" })
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
expect(active).toEqual({ ses_test: { type: "running" } })
@ -180,14 +266,14 @@ test("middleware errors remain declared client errors", async () => {
})
try {
await client.sessions.create({})
await client.session.create({})
throw new Error("Expected request to fail")
} catch (error) {
expect(isUnauthorizedError(error)).toBe(true)
}
})
test("sessions.history decodes SessionNotFoundError", async () => {
test("session.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
@ -198,7 +284,7 @@ test("sessions.history decodes SessionNotFoundError", async () => {
})
try {
await client.sessions.history({ sessionID: "ses_missing" })
await client.session.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail")
} catch (error) {
expect(isSessionNotFoundError(error)).toBe(true)

View file

@ -3,6 +3,7 @@ export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { EventV2 } from "./event"
import { State } from "./state"
export const ID = Agent.ID
@ -14,6 +15,8 @@ export const Color = Agent.Color
export const Info = Agent.Info
export type Info = Agent.Info
export const Event = Agent.Event
export interface Selection {
readonly id: ID
readonly info: Info | undefined
@ -45,6 +48,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const state = State.create<Data, Draft>({
initial: () => ({ agents: new Map() }),
draft: (draft) => ({
@ -63,6 +67,7 @@ export const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
@ -108,4 +113,4 @@ export const layer = Layer.effect(
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })

View file

@ -76,7 +76,7 @@ export const Plugin = define({
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
@ -86,6 +86,6 @@ export const Plugin = define({
})
}).pipe(Effect.ignoreCause)
}
}).pipe(Effect.forkScoped({ startImmediately: true }))
})
}),
})

View file

@ -3,7 +3,7 @@ export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, 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, inArray } from "drizzle-orm"
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
@ -31,6 +31,22 @@ export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
return row?.seq ?? -1
})
export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
seq: number,
) {
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: sql`max(${EventSequenceTable.seq}, ${seq})` },
})
.run()
.pipe(Effect.orDie)
})
export type SerializedEvent = {
readonly id: ID
readonly type: string
@ -327,7 +343,7 @@ export const layerWith = (options?: LayerOptions) =>
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq,
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})

View file

@ -1,8 +1,9 @@
export * as BackgroundJob from "./background-job"
export * as Job from "./job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id"
import { makeGlobalNode } from "./effect/app-node"
import { Identifier } from "./id/id"
import { SessionSchema } from "./session/schema"
export type Status = "running" | "completed" | "error" | "cancelled"
@ -21,14 +22,11 @@ export type Info = {
type Active = {
info: Info
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
scope: Scope.Closeable
token: object
pending: number
next: number
output?: { sequence: number; text: string }
tail: Deferred.Deferred<void>
promoted: Deferred.Deferred<Info>
onPromote?: Effect.Effect<void>
blockingSessions: Map<SessionSchema.ID, number>
isBackgrounded: boolean
}
type State = {
@ -42,36 +40,29 @@ type FinishResult = {
scope?: Scope.Closeable
}
type PromoteResult = {
type BackgroundResult = {
info?: Info
promoted?: Deferred.Deferred<Info>
onPromote?: Effect.Effect<void>
backgrounded?: Deferred.Deferred<Info>
}
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
type ExtendResult =
| { extended: false }
| {
extended: true
previous: Deferred.Deferred<void>
scope: Scope.Closeable
tail: Deferred.Deferred<void>
token: object
sequence: number
}
type BlockWait = {
done: Deferred.Deferred<Info>
backgrounded: Deferred.Deferred<Info>
}
type BlockStart =
| { type: "missing" }
| { type: "finished"; info: Info }
| { type: "backgrounded"; info: Info }
| { type: "wait"; wait: BlockWait }
export type StartInput = {
id?: string
type: string
title?: string
metadata?: Record<string, unknown>
onPromote?: Effect.Effect<void>
run: Effect.Effect<string, unknown>
}
export type ExtendInput = {
id: string
run: Effect.Effect<string, unknown>
}
@ -85,18 +76,30 @@ export type WaitResult = {
timedOut: boolean
}
export type BlockInput = {
id: string
sessionID: SessionSchema.ID
}
export type BlockResult = { type: "finished"; info: Info } | { type: "backgrounded"; info: Info }
export type BackgroundAllInput = {
sessionID: SessionSchema.ID
type?: string
}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: string) => Effect.Effect<Info | undefined>
readonly start: (input: StartInput) => Effect.Effect<Info>
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
readonly waitForPromotion: (id: string) => Effect.Effect<Info>
readonly promote: (id: string) => Effect.Effect<Info | undefined>
readonly block: (input: BlockInput) => Effect.Effect<BlockResult | undefined>
readonly background: (id: string) => Effect.Effect<Info | undefined>
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
function snapshot(job: Active): Info {
return {
@ -110,6 +113,19 @@ function errorText(error: unknown) {
return String(error)
}
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
return new Map(input).set(sessionID, (input.get(sessionID) ?? 0) + 1)
}
function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
const count = input.get(sessionID)
if (count === undefined) return input
const next = new Map(input)
if (count <= 1) next.delete(sessionID)
else next.set(sessionID, count - 1)
return next
}
/**
* Makes one scoped, process-local registry. Entries are intentionally not
* durable: process restart or owner-scope closure loses status and interrupts
@ -123,26 +139,13 @@ export const make = Effect.gen(function* () {
scope: yield* Scope.Scope,
}
const settle = Effect.fn("BackgroundJob.settle")(function* (
id: string,
token: object,
sequence: number,
exit: Exit.Exit<string, unknown>,
) {
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job) return [{}, jobs]
if (job.token !== token) return [{}, jobs]
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const pending = job.pending - 1
const output =
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
? { sequence, text: exit.value }
: job.output
if (Exit.isSuccess(exit) && pending > 0) {
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
}
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
? "completed"
: Cause.hasInterruptsOnly(exit.cause)
@ -150,14 +153,12 @@ export const make = Effect.gen(function* () {
: "error"
const next = {
...job,
onPromote: undefined,
pending: 0,
output,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status,
completed_at,
...(output ? { output: output.text } : {}),
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
},
}
@ -170,43 +171,41 @@ export const make = Effect.gen(function* () {
return result.info
})
const fork = Effect.fn("BackgroundJob.fork")(function* (
const fork = Effect.fn("Job.fork")(function* (
scope: Scope.Scope,
id: string,
token: object,
sequence: number,
run: Effect.Effect<string, unknown>,
) {
return yield* run.pipe(
Effect.matchCauseEffect({
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
}),
Effect.asVoid,
Effect.forkIn(scope, { startImmediately: true }),
)
})
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
const list: Interface["list"] = Effect.fn("Job.list")(function* () {
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
.map(snapshot)
.toSorted((a, b) => a.started_at - b.started_at)
})
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job) return
if (!job) return undefined
return snapshot(job)
})
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const id = input.id ?? Identifier.ascending("job")
const started_at = yield* Clock.currentTimeMillis
const done = yield* Deferred.make<Info>()
const promoted = yield* Deferred.make<Info>()
const tail = yield* Deferred.make<void>()
const backgrounded = yield* Deferred.make<Info>()
const result = yield* SynchronizedRef.modifyEffect(
state.jobs,
Effect.fnUntraced(function* (jobs) {
@ -226,13 +225,11 @@ export const make = Effect.gen(function* () {
metadata: input.metadata,
},
done,
backgrounded,
scope,
token,
pending: 1,
next: 1,
tail,
promoted,
onPromote: input.onPromote,
blockingSessions: new Map<SessionSchema.ID, number>(),
isBackgrounded: false,
}
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
StartResult,
@ -240,56 +237,13 @@ export const make = Effect.gen(function* () {
]
}),
)
if ("scope" in result)
yield* fork(
result.scope,
id,
result.token,
0,
restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
)
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
return result.info
}),
)
})
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const tail = yield* Deferred.make<void>()
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [ExtendResult, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
return [
{ extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
new Map(jobs).set(input.id, {
...job,
pending: job.pending + 1,
next: job.next + 1,
tail,
}),
]
},
)
if (!result.extended) return false
yield* fork(
result.scope,
input.id,
result.token,
result.sequence,
Deferred.await(result.previous).pipe(
Effect.andThen(restore(input.run)),
Effect.ensuring(Deferred.succeed(result.tail, undefined)),
),
)
return true
}),
)
})
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
if (!job) return { timedOut: false }
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
@ -300,41 +254,91 @@ export const make = Effect.gen(function* () {
return { info: snapshot(job), timedOut: true }
})
const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) {
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
if (!job || job.info.status !== "running") return yield* Effect.never
if (job.info.metadata?.background === true) return snapshot(job)
return yield* Deferred.await(job.promoted)
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
yield* SynchronizedRef.update(state.jobs, (jobs) => {
const job = jobs.get(input.id)
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
return new Map(jobs).set(input.id, {
...job,
blockingSessions: decrementSession(job.blockingSessions, input.sessionID),
})
})
})
const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
const result = yield* SynchronizedRef.modifyEffect(
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
const job = jobs.get(input.id)
if (!job) return [{ type: "missing" }, jobs]
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
return [
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
new Map(jobs).set(input.id, {
...job,
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
}),
]
})
if (result.type === "missing") return undefined
if (result.type === "finished") return { type: "finished", info: result.info }
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
return yield* Effect.raceFirst(
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
).pipe(Effect.ensuring(removeBlock(input)))
})
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
const result = yield* SynchronizedRef.modify(
state.jobs,
Effect.fnUntraced(function* (jobs) {
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
const job = jobs.get(id)
if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>]
if (job.info.metadata?.background === true)
return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
if (!job || job.info.status !== "running") return [{}, jobs]
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
const next = {
...job,
onPromote: undefined,
info: {
...job.info,
metadata: { ...job.info.metadata, background: true },
},
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
return [
{ info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted },
new Map(jobs).set(id, next),
] as readonly [PromoteResult, Map<string, Active>]
}),
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
},
)
if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore)
if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore)
if (result.info && result.backgrounded)
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
return result.info
})
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
const result = yield* SynchronizedRef.modify(
state.jobs,
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
const results: BackgroundResult[] = []
const next = new Map(jobs)
for (const [id, job] of jobs) {
if (job.info.status !== "running") continue
if (job.isBackgrounded) continue
if (input.type !== undefined && job.info.type !== input.type) continue
if (!job.blockingSessions.has(input.sessionID)) continue
const updated = {
...job,
isBackgrounded: true,
blockingSessions: new Map<SessionSchema.ID, number>(),
}
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
next.set(id, updated)
}
return [results, next]
},
)
yield* Effect.forEach(
result,
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
{ discard: true },
)
return result.flatMap((item) => (item.info ? [item.info] : []))
})
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
const completed_at = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
const job = jobs.get(id)
@ -342,8 +346,7 @@ export const make = Effect.gen(function* () {
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
const next = {
...job,
onPromote: undefined,
pending: 0,
blockingSessions: new Map<SessionSchema.ID, number>(),
info: {
...job.info,
status: "cancelled" as const,
@ -357,7 +360,7 @@ export const make = Effect.gen(function* () {
return result.info
})
return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel })
})
export const layer = Layer.effect(Service, make)

View file

@ -21,6 +21,7 @@ import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { Project } from "./project"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
@ -45,6 +46,7 @@ import { ToolOutputStore } from "./tool-output-store"
export { LocationServiceMap } from "./location-service-map"
export const locationServices = LayerNode.group([
Project.node,
Location.node,
Policy.node,
Config.node,

View file

@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url"
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import {
CallToolResultSchema,
ListRootsRequestSchema,
@ -113,6 +113,9 @@ export const connect = Effect.fnUntraced(function* (
server: string,
config: typeof ConfigMCP.Server.Type,
directory: string,
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
authProvider?: OAuthClientProvider,
) {
const transport: Transport = yield* Effect.gen(function* () {
if (config.type === "local") {
@ -132,6 +135,7 @@ export const connect = Effect.fnUntraced(function* (
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
return new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
})
})
const client = new Client(
@ -275,8 +279,7 @@ export const connect = Effect.fnUntraced(function* (
Effect.ignore,
)
const error = Cause.squash(exit.cause)
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes("OAuth")))
return yield* new NeedsAuthError({ server })
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
})

View file

@ -1,52 +1,29 @@
export * as MCP from "./index"
import { Mcp } from "@opencode-ai/schema/mcp"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope } from "effect"
import { createHash } from "node:crypto"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { Config } from "../config"
import { ConfigMCP } from "../config/mcp"
import { Credential } from "../credential"
import { EventV2 } from "../event"
import { Integration } from "../integration"
import { IntegrationConnection } from "../integration/connection"
import { Location } from "../location"
import { MCPClient } from "./client"
import { MCPOAuth } from "./oauth"
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
export type ServerName = typeof ServerName.Type
const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "MCP.Status.Connected",
})
const StatusDisconnected = Schema.Struct({ status: Schema.Literal("disconnected") }).annotate({
identifier: "MCP.Status.Disconnected",
})
const StatusDisabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({
identifier: "MCP.Status.Disabled",
})
const StatusFailed = Schema.Struct({ status: Schema.Literal("failed"), error: Schema.String }).annotate({
identifier: "MCP.Status.Failed",
})
const StatusNeedsAuth = Schema.Struct({ status: Schema.Literal("needs_auth") }).annotate({
identifier: "MCP.Status.NeedsAuth",
})
const StatusNeedsClientRegistration = Schema.Struct({
status: Schema.Literal("needs_client_registration"),
error: Schema.String,
}).annotate({ identifier: "MCP.Status.NeedsClientRegistration" })
export const Status = Schema.Union([
StatusConnected,
StatusDisconnected,
StatusDisabled,
StatusFailed,
StatusNeedsAuth,
StatusNeedsClientRegistration,
]).pipe(Schema.toTaggedUnion("status"))
export type Status = typeof Status.Type
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
export const Status = Mcp.Status
export type Status = Mcp.Status
export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
name: ServerName,
config: ConfigMCP.Server,
status: Status,
integrationID: Integration.ID.pipe(Schema.optional),
connection: IntegrationConnection.Info.pipe(Schema.optional),
@ -162,8 +139,8 @@ type ServerEntry = {
scope?: Scope.Closeable
client?: MCPClient.Connection
tools?: ReadonlyArray<Tool>
readonly integrationID?: Integration.ID
readonly connection?: IntegrationConnection.Info
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
integrationID?: Integration.ID
}
export interface Interface {
@ -196,6 +173,8 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const location = yield* Location.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service
const credentials = yield* Credential.Service
const root = yield* Scope.make()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
@ -218,6 +197,37 @@ export const layer = Layer.effect(
}
}
// Register every remote server as an OAuth integration so credentials live in the global store
// rather than in committed config. Servers that connect anonymously simply never use the method.
const registrations: Array<{
readonly name: ServerName
readonly remote: typeof ConfigMCP.Remote.Type
readonly integrationID: Integration.ID
readonly methodID: Integration.MethodID
}> = []
for (const [name, entry] of runtime) {
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
const remote = entry.config
// Key identity on name + url, not url alone: two configs for the same url under different names are
// distinct logical servers that may hold different accounts, so they must not share a credential row.
const suffix = "mcp_" + createHash("sha1").update(name + "\u0000" + remote.url).digest("hex").slice(0, 16)
entry.integrationID = Integration.ID.make(suffix)
registrations.push({ name, remote, integrationID: entry.integrationID, methodID: Integration.MethodID.make(suffix) })
}
if (registrations.length > 0)
yield* integration.transform((draft) => {
for (const reg of registrations) {
draft.update(reg.integrationID, (ref) => {
ref.name = reg.name
})
draft.method.update({
integrationID: reg.integrationID,
method: { id: reg.methodID, type: "oauth", label: reg.name },
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
})
}
})
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
const name = ServerName.make(server)
const entry = runtime.get(name)
@ -225,15 +235,67 @@ export const layer = Layer.effect(
return { name, entry }
})
const info = (name: ServerName, entry: ServerEntry) =>
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
new ServerInfo({
name,
config: entry.config,
status: entry.status,
integrationID: entry.integrationID,
connection: entry.connection,
connection,
})
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
const remote = entry.config
const oauth = remote.oauth || undefined
const base = {
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
scope: oauth?.scope,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => {},
}
const stored = yield* credentials.list(entry.integrationID)
const found = stored.find((credential) => credential.value.type === "oauth")
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
const credentialID = found.id
const methodID = found.value.methodID
let current: Credential.OAuth | undefined = found.value
return MCPOAuth.provider({
...base,
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
current = undefined
await Effect.runPromise(credentials.remove(credentialID))
},
store: {
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
saveTokens: async (tokens) => {
current = MCPOAuth.toCredential({
methodID,
serverUrl: remote.url,
tokens,
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
})
await Effect.runPromise(credentials.update(credentialID, { value: current }))
},
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
saveClientInformation: async () => {},
codeVerifier: async () => undefined,
saveCodeVerifier: async () => {},
},
})
})
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
@ -265,6 +327,7 @@ export const layer = Layer.effect(
entry.tools = undefined
entry.status = { status: "failed", error: "Connection closed" }
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
})
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
connection.onToolsChanged(() => {
@ -299,9 +362,10 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const scope = yield* Scope.fork(root)
entry.scope = scope
const authProvider = yield* connectProvider(entry)
// List tools as part of connect so a failure here marks the server failed rather than
// leaving it connected with a silently empty tool list and no path to recover.
const result = yield* MCPClient.connect(name, entry.config, location.directory).pipe(
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
Scope.provide(scope),
Effect.exit,
@ -312,6 +376,11 @@ export const layer = Layer.effect(
entry.status = { status: "connected" }
watch(name, entry, result.value.connection)
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
// after the initial registration sweep and emits no list-changed notification would otherwise
// stay invisible to the model.
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
yield* Scope.close(scope, Exit.void)
@ -322,6 +391,7 @@ export const layer = Layer.effect(
? { status: "needs_auth" }
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
// Disabled servers settle their startup immediately so queries never block on them.
@ -334,6 +404,31 @@ export const layer = Layer.effect(
fork(startServer(name, entry))
}
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
const owned = new Set(registrations.map((reg) => reg.integrationID))
const reconnect = (integrationID: Integration.ID) =>
Effect.gen(function* () {
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
if (!match) return
const [name, entry] = match
if (entry.config.disabled) return
if (entry.scope) {
yield* Scope.close(entry.scope, Exit.void)
entry.scope = undefined
entry.client = undefined
entry.tools = undefined
}
yield* startServer(name, entry)
})
fork(
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
),
)
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
concurrency: "unbounded",
discard: true,
@ -345,8 +440,14 @@ export const layer = Layer.effect(
return Service.of({
servers: Effect.fn("MCP.servers")(function* () {
return Array.from(runtime, ([name, entry]) => info(name, entry)).toSorted((a, b) =>
a.name.localeCompare(b.name),
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
return yield* Effect.forEach(entries, ([name, entry]) =>
Effect.gen(function* () {
const connection = entry.integrationID
? yield* integration.connection.active(entry.integrationID)
: undefined
return info(name, entry, connection)
}),
)
}),
tools: Effect.fn("MCP.tools")(function* () {
@ -440,4 +541,8 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Location.node, EventV2.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
})

View file

@ -0,0 +1,238 @@
export * as MCPOAuth from "./oauth"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "../config/mcp"
import { OauthCallbackPage } from "../oauth/page"
import type { Integration } from "../integration"
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
export interface Store {
readonly tokens: () => Promise<OAuthTokens | undefined>
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
readonly codeVerifier: () => Promise<string | undefined>
readonly saveCodeVerifier: (verifier: string) => Promise<void>
}
export interface Options {
/** Loopback URL the authorization server redirects back to after the user approves. */
readonly redirectUrl: string
/** Space-delimited OAuth scopes to request when the server requires specific ones. */
readonly scope?: string
/** CSRF state embedded in the authorization request; required by the spec and enforced by some servers.
* The caller is responsible for validating the value echoed back to the redirect. */
readonly state?: string
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
readonly client?: { readonly id: string; readonly secret?: string }
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
readonly onRedirect: (url: URL) => void | Promise<void>
readonly store: Store
}
/**
* Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and
* token refresh through these callbacks; we only persist whatever it hands back via `store`.
*/
export const provider = (options: Options): OAuthClientProvider => {
const state = options.state
const client = options.client
return {
redirectUrl: options.redirectUrl,
clientMetadata: {
redirect_uris: [options.redirectUrl],
client_name: "opencode",
client_uri: "https://opencode.ai",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none",
...(options.scope ? { scope: options.scope } : {}),
},
// Only advertise state when the caller supplied one (the interactive flow); the connect-time
// provider has no redirect to validate, so it omits it.
...(state !== undefined ? { state: () => state } : {}),
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
clientInformation: () =>
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
saveClientInformation: (info) => options.store.saveClientInformation(info),
tokens: () => options.store.tokens(),
saveTokens: (tokens) => options.store.saveTokens(tokens),
redirectToAuthorization: (url) => options.onRedirect(url),
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
// The SDK only reads the verifier back after saving one earlier in the same flow; a miss means
// the flow was resumed without its session state, which the SDK surfaces as an auth failure.
codeVerifier: async () => {
const verifier = await options.store.codeVerifier()
if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow")
return verifier
},
}
}
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
export const memoryStore = (): Store => {
let tokens: OAuthTokens | undefined
let client: OAuthClientInformationMixed | undefined
let verifier: string | undefined
return {
tokens: async () => tokens,
saveTokens: async (value) => {
tokens = value
},
clientInformation: async () => client,
saveClientInformation: async (value) => {
client = value
},
codeVerifier: async () => verifier,
saveCodeVerifier: async (value) => {
verifier = value
},
}
}
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
export const clientFromCredential = (credential: Credential.OAuth) =>
credential.metadata?.client as OAuthClientInformationMixed | undefined
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
export const toCredential = (input: {
readonly methodID: Integration.MethodID
readonly serverUrl: string
readonly tokens: OAuthTokens
readonly client: OAuthClientInformationMixed | undefined
}) =>
Credential.OAuth.make({
type: "oauth",
methodID: input.methodID,
access: input.tokens.access_token,
refresh: input.tokens.refresh_token ?? "",
// 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh.
expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0,
metadata: {
serverUrl: input.serverUrl,
tokenType: input.tokens.token_type,
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
...(input.client ? { client: input.client } : {}),
},
})
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
const metadata = credential.metadata ?? {}
return {
access_token: credential.access,
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
}
}
/**
* Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server,
* lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback
* exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope.
*/
export const authorize = (input: {
readonly name: string
readonly config: typeof ConfigMCP.Remote.Type
readonly methodID: Integration.MethodID
}) =>
Effect.gen(function* () {
const oauth = input.config.oauth || undefined
const store = memoryStore()
const code = yield* Deferred.make<string, Error>()
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== redirectPath) {
response.writeHead(404).end("Not found")
return
}
const fail = (reason: string) => {
Effect.runFork(Deferred.fail(code, new Error(reason)))
response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name }))
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) return fail(error)
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
// state parameter exists for, so an attacker can't inject their own authorization code.
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
const value = url.searchParams.get("code")
if (!value) return fail("Missing authorization code")
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
})
// Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port
// pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed
// port would send the browser somewhere nothing is listening, hanging the attempt until it expires.
const redirectPort = oauth?.redirect_uri ? Number(new URL(oauth.redirect_uri).port) || undefined : undefined
const port = yield* Effect.callback<number, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => {
const address = server.address()
resume(
address && typeof address === "object"
? Effect.succeed(address.port)
: Effect.fail(new Error("Could not determine MCP OAuth callback port")),
)
})
})
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
let authorizationUrl: URL | undefined
const oauthProvider = provider({
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
scope: oauth?.scope,
state,
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
onRedirect: (url) => {
authorizationUrl = url
},
store,
})
const finalize = Effect.gen(function* () {
const tokens = yield* Effect.promise(() => store.tokens())
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
const client = yield* Effect.promise(() => store.clientInformation())
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
})
const result = yield* Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
if (result === "AUTHORIZED") {
return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize }
}
if (!authorizationUrl)
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
return {
url: authorizationUrl.toString(),
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
mode: "auto" as const,
callback: Deferred.await(code).pipe(
Effect.flatMap((value) =>
Effect.tryPromise({
try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}),
),
Effect.flatMap(() => finalize),
),
}
})

View file

@ -44,6 +44,21 @@ const Cost = Schema.Struct({
),
})
const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.String),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@ -51,6 +66,7 @@ export const Model = Schema.Struct({
release_date: Schema.String,
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
interleaved: Schema.optional(

View file

@ -119,12 +119,12 @@ const layer = Layer.effectDiscard(
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
yield* add(ModelsDevPlugin)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(MCPCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigExternalPlugin.Plugin)
yield* add(ConfigProviderPlugin.Plugin)
yield* add(VariantPlugin.Plugin)
// Embedder-contributed plugins are added last so they layer over config.

View file

@ -6,26 +6,112 @@ import { define } from "./internal"
import { Effect } from "effect"
import { AbsolutePath } from "../schema"
import { SkillV2 } from "../skill"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { Config } from "../config"
import { Location } from "../location"
import { FSUtil } from "../fs-util"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
export const CustomizeOpencodeContent = customizeOpencodeContent
export const ReportContent = reportContent
const 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."
const REPORT_DESCRIPTION =
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
export const Plugin = define({
id: "skill",
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics()
yield* ctx.skill.transform((draft) => {
draft.source(
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
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.",
description: CUSTOMIZE_OPENCODE_DESCRIPTION,
location: AbsolutePath.make("/builtin/customize-opencode.md"),
content: CustomizeOpencodeContent,
}),
}),
)
draft.source(
SkillV2.EmbeddedSource.make({
type: "embedded",
skill: SkillV2.Info.make({
name: "report",
description: REPORT_DESCRIPTION,
slash: true,
location: AbsolutePath.make("/builtin/report.md"),
content: reportContent,
}),
}),
)
})
}),
})
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* () {
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
return [
ReportContent,
"",
"## Runtime Diagnostics Snapshot",
"",
"These values were captured when the built-in report skill was registered. Verify them before publishing.",
"",
`- opencode version: ${InstallationVersion}`,
`- install/channel: ${InstallationChannel}`,
`- OS: ${os.type()} ${os.release()} (${os.platform()} ${os.arch()})`,
`- Terminal: ${terminal()}`,
`- Shell: ${shell()}`,
`- Active plugins: ${plugins.length === 0 ? "None found in config" : plugins.join(", ")}`,
].join("\n")
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") {
const directory = entry.path ? path.dirname(entry.path) : location.directory
return Effect.succeed(
(entry.info.plugins ?? []).map((item) => {
const ref = typeof item === "string" ? { package: item } : item
if (ref.package.startsWith("file://")) return fileURLToPath(ref.package)
if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package)
return ref.package
}),
)
}
return fs
.glob("{plugin,plugins}/*.{ts,js}", {
cwd: entry.path,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.pipe(Effect.orElseSucceed(() => []))
}).pipe(Effect.map((items) => items.flat().toSorted()))
})
function terminal() {
return [
process.env.TERM_PROGRAM ? `TERM_PROGRAM=${process.env.TERM_PROGRAM}` : undefined,
process.env.TERM ? `TERM=${process.env.TERM}` : undefined,
process.env.COLORTERM ? `COLORTERM=${process.env.COLORTERM}` : undefined,
]
.filter((item): item is string => item !== undefined)
.join(", ") || "Unavailable: terminal environment variables are not set"
}
function shell() {
return process.env.SHELL ?? process.env.ComSpec ?? process.env.COMSPEC ?? "Unavailable: shell environment variable is not set"
}

View file

@ -0,0 +1,125 @@
<!--
Built-in skill. Name and description are registered in code at
packages/core/src/plugin/skill.ts. The body below becomes the skill's
content.
-->
# Report an opencode Issue
Use this skill when the user wants to report an opencode issue or bug. Your job
is to turn the user's problem into a useful GitHub issue with standard
diagnostics plus the context needed to reproduce and resolve it.
## Workflow
1. Collect the standard diagnostics below.
2. Ask only for missing details that are necessary to reproduce or understand
impact.
3. Draft the issue in the standard format below.
4. Publish it with GitHub CLI after the user confirms the title and body.
Do not publish an issue without user confirmation. If GitHub CLI is not
installed or not authenticated, explain the blocker and provide the exact issue
title/body for the user.
## Standard Diagnostics
Collect these values when possible:
- opencode version: run `opencode --version` or `opencode2 --version`,
depending on the executable in use.
- Operating system: run `uname -a` on Unix-like systems, or `ver` on Windows.
- Terminal: inspect `$TERM`, `$TERM_PROGRAM`, `$COLORTERM`, and any obvious
terminal app context the user provides.
- Shell: inspect `$SHELL` on Unix-like systems, or `%COMSPEC%`/`$ComSpec` on
Windows when relevant.
- Install/channel context: include whether this appears to be local, dev, beta,
or release if the version output or environment reveals it.
- Active plugins: inspect opencode config for configured plugins when possible.
Check likely config locations such as `opencode.json`, `opencode.jsonc`,
`.opencode/opencode.json`, and `~/.config/opencode/opencode.json`. Record
configured plugin entries, local plugin files under `.opencode/plugin/` or
`.opencode/plugins/`, and note if plugin status could not be determined.
If a diagnostic command fails, include `Unavailable` with the reason instead of
guessing.
## User-Specific Context
Capture the details that make the issue actionable:
- What the user was trying to do.
- What happened.
- What the user expected to happen.
- Reproduction steps, ideally minimal and numbered.
- Relevant logs, stack traces, screenshots, terminal output, or config snippets.
- Whether the issue is reproducible consistently, intermittently, or only once.
- Recent changes that may be related, such as updating opencode, changing
config, installing a plugin, changing terminal, or switching workspace.
- Workarounds tried and whether they helped.
Avoid pasting secrets. Redact tokens, API keys, private URLs, usernames, and
project-specific confidential data unless the user explicitly says it is safe.
## Issue Format
Use this exact structure unless the repository issue template requires
otherwise:
```markdown
## Summary
<!-- One or two sentences describing the bug and impact. -->
## Environment
- opencode version: <!-- value or Unavailable: reason -->
- OS: <!-- value or Unavailable: reason -->
- Terminal: <!-- value or Unavailable: reason -->
- Shell: <!-- value or Unavailable: reason -->
- Install/channel: <!-- value or Unavailable: reason -->
- Active plugins: <!-- list, none found, or Unavailable: reason -->
## Reproduction
1. <!-- step -->
2. <!-- step -->
3. <!-- step -->
## Expected Behavior
<!-- What should have happened. -->
## Actual Behavior
<!-- What happened instead. Include exact errors when available. -->
## Additional Context
<!-- Logs, config snippets, screenshots, frequency, workarounds, related notes. -->
```
Keep the title short and searchable. Prefer the form:
```text
<area>: <specific failure or symptom>
```
Examples: `tui: skills dialog crashes outside location provider`,
`cli: local service config writes release filename`.
## Publishing With GitHub CLI
Use GitHub CLI from the repository checkout when available:
```sh
gh issue create --title "<title>" --body-file <file>
```
Write the body to a temporary markdown file first so quoting, newlines, logs,
and code fences are preserved. If the issue belongs in a specific repository,
use `--repo owner/name`. If labels are obvious and the repo accepts them, add
`--label bug`; otherwise omit labels rather than guessing.
After publishing, report the created issue URL to the user and mention any
diagnostics that were unavailable.

View file

@ -17,14 +17,20 @@ export type ID = ProjectSchema.ID
export const Vcs = ProjectSchema.Vcs
export type Vcs = ProjectSchema.Vcs
export const Current = ProjectSchema.Current
export type Current = ProjectSchema.Current
export const Directory = ProjectSchema.Directory
export type Directory = ProjectSchema.Directory
export class Info extends Schema.Class<Info>("Project.Info")({
id: ID,
}) {}
export const DirectoriesInput = ProjectDirectories.ListInput
export const DirectoriesInput = ProjectSchema.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = ProjectDirectories.ListOutput
export const Directories = ProjectSchema.Directories
export type Directories = typeof Directories.Type
export interface Resolved {

View file

@ -4,15 +4,13 @@ import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "../effect/app-node"
import { AbsolutePath, optional } from "../schema"
import { AbsolutePath } from "../schema"
import { ProjectSchema } from "./schema"
import { ProjectDirectoryTable } from "./sql"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import type { Project } from "../project"
export interface Directory {
readonly directory: AbsolutePath
readonly strategy?: string
}
export type Directory = Project.Directory
export const CreateInput = Schema.Struct({
projectID: ProjectSchema.ID,
@ -31,17 +29,10 @@ export type RemoveInput = typeof RemoveInput.Type
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ListInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Project.DirectoriesInput" })
export const ListInput = ProjectSchema.DirectoriesInput
export type ListInput = typeof ListInput.Type
export const ListOutput = Schema.Array(
Schema.Struct({
directory: AbsolutePath,
strategy: optional(Schema.String),
}),
).annotate({ identifier: "Project.Directories" })
export const ListOutput = ProjectSchema.Directories
export type ListOutput = typeof ListOutput.Type
export interface Interface {

View file

@ -7,6 +7,18 @@ import { AbsolutePath } from "../schema"
export const ID = Project.ID
export type ID = typeof ID.Type
export const Current = Project.Current
export type Current = typeof Current.Type
export const Directory = Project.Directory
export type Directory = typeof Directory.Type
export const DirectoriesInput = Project.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Project.Directories
export type Directories = typeof Directories.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),

View file

@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { DateTime, Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
@ -38,6 +38,7 @@ import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
import { SkillV2 } from "./skill"
export const RevertState = Revert.State
export type RevertState = Revert.State
@ -90,6 +91,11 @@ type CompactInput = {
sessionID: SessionSchema.ID
}
type ForkInput = {
sessionID: SessionSchema.ID
messageID?: SessionMessage.ID
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionSchema.ID,
}) {}
@ -110,14 +116,25 @@ export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictE
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Schema.String,
}) {}
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError | BusyError
export type Error =
| NotFoundError
| MessageDecodeError
| OperationUnavailableError
| PromptConflictError
| BusyError
| SkillNotFoundError
| MessageNotFoundError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
@ -164,11 +181,11 @@ export interface Interface {
resume?: boolean
}) => Effect.Effect<void, OperationUnavailableError>
readonly skill: (input: {
id?: EventV2.ID
id?: SessionMessage.ID
sessionID: SessionSchema.ID
skill: string
resume?: boolean
}) => Effect.Effect<void, OperationUnavailableError>
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
@ -176,6 +193,7 @@ export interface Interface {
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly synthetic: (input: { sessionID: SessionSchema.ID; text: string }) => Effect.Effect<void, NotFoundError>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
@ -199,6 +217,7 @@ export const layer = Layer.effect(
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
@ -272,6 +291,29 @@ export const layer = Layer.effect(
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
fork: Effect.fn("V2Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = input.messageID
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.messageID && !boundary)
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
const sessionID = SessionSchema.ID.create()
yield* events.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
messageID: input.messageID,
timestamp: yield* DateTime.now,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
@ -403,8 +445,20 @@ export const layer = Layer.effect(
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
skill: Effect.fn("V2Session.skill")(function* (input) {
const session = yield* result.get(input.sessionID)
const skills = yield* SkillV2.Service.pipe(Effect.provide(locations.get(session.location)))
const skill = (yield* skills.list()).find((item) => item.name === input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* events.publish(SessionEvent.Skill.Activated, {
sessionID: input.sessionID,
messageID: input.id ?? SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
name: skill.name,
text: skill.content,
})
if (input.resume !== false)
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
@ -462,6 +516,16 @@ export const layer = Layer.effect(
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
synthetic: Effect.fn("V2Session.synthetic")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: input.text,
})
yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),

View file

@ -130,6 +130,7 @@ const serialize = (message: SessionMessage.Message) => {
}
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
return ""
}

View file

@ -124,6 +124,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.moved": () => Effect.void,
"session.next.renamed": () => Effect.void,
"session.next.forked": () => Effect.void,
"session.next.prompted": (event) => {
return adapter.appendMessage(
SessionMessage.User.make({
@ -158,6 +159,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"session.next.skill.activated": (event) => {
return adapter.appendMessage(
SessionMessage.Skill.make({
id: event.data.messageID,
type: "skill",
name: event.data.name,
text: event.data.text,
time: { created: event.data.timestamp },
}),
)
},
"session.next.shell.started": (event) => {
return adapter.appendMessage(
SessionMessage.Shell.make({

View file

@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, desc, eq, gt, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
@ -15,8 +15,10 @@ import { WorkspaceV2 } from "../workspace"
import { SessionContextEpoch } from "./context-epoch"
import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<SessionEvent.Event, typeof SessionEvent.Forked.Type>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
@ -33,6 +35,19 @@ type Usage = {
}
}
const ForkBatchSize = 500
const emptyUsage = (): Usage => ({
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
const forkTitle = (value: string) => {
const match = value.match(/^(.+) \(fork #(\d+)\)$/)
if (match) return `${match[1]} (fork #${Number.parseInt(match[2], 10) + 1})`
return `${value} (fork #1)`
}
function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
if (typeof part !== "object" || part === null) return undefined
const value = part as Record<string, unknown>
@ -41,6 +56,22 @@ function usage(part: (typeof SessionV1.Event.PartUpdated.Type)["data"]["part"] |
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
}
function addUsage(target: Usage, value: Usage) {
target.cost += value.cost
target.tokens.input += value.tokens.input
target.tokens.output += value.tokens.output
target.tokens.reasoning += value.tokens.reasoning
target.tokens.cache.read += value.tokens.cache.read
target.tokens.cache.write += value.tokens.cache.write
}
function messageUsage(row: typeof SessionMessageTable.$inferSelect): Usage | undefined {
if (row.type !== "assistant") return undefined
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
if (message.type !== "assistant" || message.cost === undefined || message.tokens === undefined) return undefined
return { cost: message.cost, tokens: message.tokens }
}
function sessionRow(info: SessionV1.SessionInfo): typeof SessionTable.$inferInsert {
return {
id: info.id,
@ -109,7 +140,175 @@ function applyUsage(
.pipe(Effect.orDie)
}
function run(db: DatabaseService, event: SessionEvent.Event) {
const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
db: DatabaseService,
event: typeof SessionEvent.Forked.Type,
) {
const parent = yield* db
.select()
.from(SessionTable)
.where(eq(SessionTable.id, event.data.parentID))
.get()
.pipe(Effect.orDie)
if (!parent) return yield* Effect.die(`Fork parent session not found: ${event.data.parentID}`)
const boundary = event.data.messageID
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`)
const copied = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const copiedSeq = copied?.seq ?? 0
const stored = yield* db
.insert(SessionTable)
.values({
id: event.data.sessionID,
parent_id: event.data.parentID,
project_id: parent.project_id,
workspace_id: parent.workspace_id,
slug: Slug.create(),
directory: parent.directory,
path: parent.path,
title: forkTitle(parent.title),
agent: parent.agent,
model: parent.model,
version: parent.version,
cost: 0,
tokens_input: 0,
tokens_output: 0,
tokens_reasoning: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
time_created: DateTime.toEpochMillis(event.data.timestamp),
time_updated: DateTime.toEpochMillis(event.data.timestamp),
})
.onConflictDoNothing()
.returning({ sessionID: SessionTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new SessionAlreadyProjected())
const usage = emptyUsage()
let cursor = -1
while (true) {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
),
)
.orderBy(asc(SessionMessageTable.seq))
.limit(ForkBatchSize)
.all()
.pipe(Effect.orDie)
if (rows.length === 0) break
const idMap = new Map(rows.map((row) => [row.id, SessionMessage.ID.create()]))
yield* db
.insert(SessionMessageTable)
.values(
rows.map((row) => {
const id = idMap.get(row.id)
if (!id) throw new Error(`Fork message ID mapping missing: ${row.id}`)
return {
id,
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.type === "synthetic" ? { ...row.data, sessionID: event.data.sessionID } : row.data,
}
}),
)
.run()
.pipe(Effect.orDie)
const inputRows = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, event.data.parentID),
inArray(
SessionInputTable.id,
rows.map((row) => row.id),
),
),
)
.all()
.pipe(Effect.orDie)
if (inputRows.length > 0) {
yield* db
.insert(SessionInputTable)
.values(
inputRows.flatMap((row) => {
const id = idMap.get(row.id)
return id
? [
{
id,
session_id: event.data.sessionID,
prompt: row.prompt,
delivery: row.delivery,
admitted_seq: row.admitted_seq,
promoted_seq: row.promoted_seq,
time_created: row.time_created,
},
]
: []
}),
)
.run()
.pipe(Effect.orDie)
}
for (const row of rows) {
const value = messageUsage(row)
if (value) addUsage(usage, value)
}
cursor = rows.at(-1)!.seq
}
yield* db
.update(SessionTable)
.set({
cost: usage.cost,
tokens_input: usage.tokens.input,
tokens_output: usage.tokens.output,
tokens_reasoning: usage.tokens.reasoning,
tokens_cache_read: usage.tokens.cache.read,
tokens_cache_write: usage.tokens.cache.write,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
if (copiedSeq > 0) yield* EventV2.reserveSequence(db, event.data.sessionID, copiedSeq)
})
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
@ -355,6 +554,7 @@ export const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* events.project(SessionEvent.Prompted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("Durable Session event is missing aggregate sequence")
@ -384,6 +584,15 @@ export const layer = Layer.effectDiscard(
)
yield* events.project(SessionEvent.ContextUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) =>
insertMessage(db, event, {
id: event.data.messageID,
type: "skill",
name: event.data.name,
text: event.data.text,
time: { created: event.data.timestamp },
}),
)
yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))

View file

@ -196,7 +196,9 @@ export const layer = Layer.effect(
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const toolMaterialization = isLastStep
? undefined
: yield* tools.materialize({ permissions: agent.info?.permissions, model })
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,

View file

@ -9,20 +9,20 @@ If the user asks for help or wants to give feedback inform them of the following
- To give feedback, users should report the issue at
https://github.com/anomalyco/opencode
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the webfetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs
# Tone and style
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
You have access to the todowrite tool to help you manage and plan tasks. Use it VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
This tool is also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
@ -30,13 +30,13 @@ Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
assistant: I'm going to use the todowrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
I'm now going to run the build using the shell tool.
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
Looks like I found 10 type errors. I'm going to use the todowrite tool to write 10 items to the todo list.
marking the first todo as in_progress
@ -50,7 +50,7 @@ In the above example, the assistant completes all the tasks, including the 10 er
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the todowrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
@ -70,30 +70,30 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
-
- Use the TodoWrite tool to plan the task if required
- Use the todowrite tool to plan the task if required
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
- You should proactively use the subagent tool with specialized agents when the task at hand matches the agent's description.
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- When webfetch returns a message about a redirect to a different host, you should immediately make a new webfetch request with the redirect URL provided in the response.
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple subagent tool calls.
- Use specialized tools instead of shell commands when possible, as this provides a better user experience. For file operations, use dedicated tools: read for reading files instead of cat/head/tail, edit for editing instead of sed/awk, and write for creating files instead of cat with heredoc or echo redirection. Reserve the shell tool exclusively for actual system commands and terminal operations that require shell execution. NEVER use shell echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the subagent tool instead of running search commands directly.
<example>
user: Where are errors from the client handled?
assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly]
assistant: [Uses the subagent tool to find the files that handle client errors instead of using glob or grep directly]
</example>
<example>
user: What is the codebase structure?
assistant: [Uses the Task tool]
assistant: [Uses the subagent tool]
</example>
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
IMPORTANT: Always use the todowrite tool to plan and track tasks throughout the conversation.
# Code References

View file

@ -9,9 +9,9 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Tool usage
- Prefer specialized tools over shell for file operations:
- Use Read to view files, Edit to modify files, and Write only when needed.
- Use Glob to find files by name and Grep to search file contents.
- Use Bash for terminal operations (git, bun, builds, tests, running scripts).
- Use read to view files, edit to modify files, and write only when needed.
- Use glob to find files by name and grep to search file contents.
- Use the shell tool for terminal operations (git, bun, builds, tests, running scripts).
- Run tool calls in parallel when neither call needs the others output; otherwise run sequentially.
## Git and workspace hygiene

View file

@ -6,12 +6,12 @@ If the user asks for help or wants to give feedback inform them of the following
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the WebFetch tool to gather information to answer the question from opencode docs at https://opencode.ai
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the webfetch tool to gather information to answer the question from opencode docs at https://opencode.ai
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
@ -72,14 +72,14 @@ The user will primarily request you perform software engineering tasks. This inc
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.

View file

@ -19,18 +19,18 @@ You are opencode, an interactive CLI agent specializing in software engineering
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'grep' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use 'read' to understand context and validate any assumptions you may have.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'bash' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'shell' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit' and 'bash'.
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit', and 'shell'.
1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions.
2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner.
3. **User Approval:** Obtain user approval for the proposed plan.
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using 'bash' for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using the 'shell' tool for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors.
6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype.
@ -46,13 +46,13 @@ When requested to perform tasks like fixing bugs, adding features, refactoring,
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
## Security and Safety Rules
- **Explain Critical Commands:** Before executing commands with 'bash' that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
- **Explain Critical Commands:** Before executing commands with the 'shell' tool that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **File Paths:** Always use absolute paths when referring to files with tools like 'read' or 'write'. Relative paths are not supported. You must provide an absolute path.
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'bash' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Command Execution:** Use the 'shell' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** Use background processes (via \`&\`) for commands that are unlikely to stop on their own, e.g. \`node server.js &\`. If unsure, ask the user.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
@ -79,7 +79,7 @@ model: [tool_call: ls for path '/path/to/project']
<example>
user: start the server implemented in server.js
model: [tool_call: bash for 'node server.js &' because it must run in the background]
model: [tool_call: shell for 'node server.js &' because it must run in the background]
</example>
<example>
@ -106,7 +106,7 @@ user: Yes
model:
[tool_call: write or edit to apply the refactoring to 'src/auth.py']
Refactoring complete. Running verification...
[tool_call: bash for 'ruff check src/auth.py && pytest']
[tool_call: shell for 'ruff check src/auth.py && pytest']
(After verification passes)
All checks passed. This is a stable checkpoint.
@ -125,7 +125,7 @@ Now I'll look for existing or related test files to understand current testing c
(After reviewing existing tests and the file content)
[tool_call: write to create /path/to/someFile.test.ts with the test code]
I've written the tests. Now I'll run the project's test command to verify them.
[tool_call: bash for 'npm run test']
[tool_call: shell for 'npm run test']
</example>
<example>

View file

@ -2,8 +2,8 @@ You are OpenCode, You and the user share the same workspace and collaborate to a
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
- When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`)
- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly.
- When searching for text or files, prefer using glob and grep tools (they are powered by `rg`)
- Parallelize tool calls whenever possible - especially file reads. When independent tool calls have no dependencies, issue them together in the same assistant message. Never chain together shell commands with separators like `echo "====";` as this renders to the user poorly.
## Editing Approach

View file

@ -30,8 +30,8 @@ When building something from scratch, you should:
Always use tools to implement your code changes:
- Use `write`/`edit` to create or modify source files. Code that only appears in your text response is NOT saved to the file system and will not take effect.
- Use `bash` to run and test your code after writing it.
- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `bash`.
- Use `shell` to run and test your code after writing it.
- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `shell`.
When working on an existing codebase, you should:

View file

@ -1,9 +1,9 @@
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
@ -74,13 +74,13 @@ The user will primarily request you perform software engineering tasks. This inc
- Use the available search tools to understand the codebase and the user's query. Use one tool per message; after each result, decide the next step and call one tool again.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with Bash if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
- Use exactly one tool per assistant message. After each tool call, wait for the result before continuing.
- When the user's request is vague, use the question tool to clarify before reading files or making changes.
- Avoid repeating the same tool with the same parameters once you have useful results. Use the result to take the next step (e.g. pick one match, read that file, then act); do not search again in a loop.

View file

@ -131,6 +131,8 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
]
case "synthetic":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
case "skill":
return [Message.make({ id: message.id, role: "user", content: message.text, metadata: message.metadata })]
case "system":
return [Message.system(message.text)]
case "shell":

View file

@ -31,7 +31,7 @@ type Active = {
// Resolves with the terminal Info once the command exits, times out, or is killed. A wait
// started after termination resolves immediately from the already-completed deferred.
done: Deferred.Deferred<Info, NotFoundError>
timeoutFiber?: Fiber.Fiber<void, never>
timeoutFiber?: Fiber.Fiber<void>
}
/**
@ -159,7 +159,7 @@ export const layer = Layer.effect(
const cwd = input.cwd ?? location.directory
const configShell = Config.latest(yield* config.entries(), "shell")
const shell = ShellSelect.preferred(configShell)
const args = ShellSelect.args(shell, input.command, cwd)
const args = ShellSelect.args(shell, input.command)
const file = path.join(outputDir, `${id}.out`)
const env = {
...process.env,
@ -181,7 +181,7 @@ export const layer = Layer.effect(
// Spawn via AppProcess and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active, never>()
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
@ -205,14 +205,7 @@ export const layer = Layer.effect(
sessions.set(id, session)
const stream = createWriteStream(file)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
@ -221,9 +214,27 @@ export const layer = Layer.effect(
}),
),
)
runFork(pump.pipe(Effect.catch(() => Effect.void)))
runFork(
Effect.gen(function* () {
yield* pump.pipe(Effect.catch(() => Effect.void))
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
}),
)
yield* Deferred.succeed(outputDone, undefined)
}).pipe(Effect.catch(() => Deferred.succeed(outputDone, undefined))),
)
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.once("open", () => resolve())
stream.once("error", () => resolve())
}),
)
const finish = (status: Info["status"], exit?: number) =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
@ -231,7 +242,8 @@ export const layer = Layer.effect(
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
stream.end()
yield* beforeWait
yield* Deferred.await(outputDone)
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
@ -257,10 +269,7 @@ export const layer = Layer.effect(
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(input.timeout)).pipe(
Effect.flatMap(() =>
Effect.gen(function* () {
yield* finish("timeout")
yield* handle.kill().pipe(Effect.catch(() => Effect.void))
}),
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),

View file

@ -163,37 +163,10 @@ function info(file: string): Item {
}
}
export function args(file: string, command: string, cwd: string) {
export function args(file: string, command: string) {
const n = name(file)
if (n === "nu" || n === "fish") return ["-c", command]
if (n === "zsh") {
return [
"-l",
"-c",
`
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "bash") {
return [
"-l",
"-c",
`
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd -- "$1"
eval ${JSON.stringify(command)}
`,
"opencode",
cwd,
]
}
if (n === "zsh" || n === "bash") return ["-c", command]
if (n === "cmd") return ["/c", command]
if (ps(file)) return ["-NoProfile", "-Command", command]
return ["-c", command]

View file

@ -41,9 +41,8 @@ export const layer = Layer.effect(
return Service.of({
register: Effect.fn("ApplicationTools.register")(function* (tools) {
const entries = Object.entries(tools)
const entries = Tool.registrationEntries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
yield* state.transform((draft) => {
for (const [name, entry] of registrations) draft.set(name, entry)

View file

@ -1,8 +1,7 @@
export * as BuiltInTools from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Layer } from "effect"
import { ShellTool } from "./shell"
import { Context, Layer } from "effect"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@ -16,7 +15,6 @@ import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
import { FSUtil } from "../fs-util"
import { Shell } from "../shell"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { FileMutation } from "../file-mutation"
@ -29,6 +27,8 @@ import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { httpClient } from "../effect/app-node-platform"
export class Service extends Context.Service<Service, Record<string, never>>()("@opencode/v2/BuiltInTools") {}
/**
* Composes only the shipped Location-scoped built-in tool transforms.
* Each tool retains its implementation and focused tests independently. Dynamic
@ -42,9 +42,8 @@ import { httpClient } from "../effect/app-node-platform"
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
* transforms separate from this static built-in list.
*/
export const locationLayer = Layer.mergeAll(
const registrations = Layer.mergeAll(
ApplyPatchTool.layer,
ShellTool.layer,
EditTool.layer,
GlobTool.layer,
GrepTool.layer,
@ -57,13 +56,14 @@ export const locationLayer = Layer.mergeAll(
WriteTool.layer,
)
export const locationLayer = Layer.succeed(Service, Service.of({})).pipe(Layer.provideMerge(registrations))
export const node = makeLocationNode({
name: "built-in-tools",
service: Service,
layer: locationLayer,
deps: [
ToolRegistry.toolsNode,
FSUtil.node,
Shell.node,
Location.node,
LocationMutation.node,
FileMutation.node,

View file

@ -9,7 +9,7 @@ import { SessionSchema } from "../session/schema"
import { ToolOutputStore } from "../tool-output-store"
import { Wildcard } from "../util/wildcard"
import { ApplicationTools } from "./application-tools"
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
import { definition, permission, registrationEntries, settle, type AnyTool, type RegistrationError } from "./tool"
import { Tools } from "./tools"
import { makeLocationNode } from "../effect/app-node"
@ -21,11 +21,16 @@ export type ExecuteInput = {
}
export interface Interface {
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
readonly materialize: (input: MaterializeInput) => Effect.Effect<Materialization>
/** Internal registration capability exposed publicly only through Tools.Service. */
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
}
export interface MaterializeInput {
readonly model: { readonly id: string; readonly provider: string }
readonly permissions?: PermissionV2.Ruleset
}
export interface Materialization {
readonly definitions: ReadonlyArray<ToolDefinition>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
@ -83,9 +88,8 @@ const registryLayer = Layer.effect(
return Service.of({
register: Effect.fn("ToolRegistry.register")(function* (tools) {
const entries = Object.entries(tools)
const entries = registrationEntries(tools)
if (entries.length === 0) return
yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
@ -103,14 +107,19 @@ const registryLayer = Layer.effect(
}),
)
}),
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) {
materialize: Effect.fn("ToolRegistry.materialize")(function* (input) {
const registrations = new Map(applications.entries())
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (registration) registrations.set(name, registration)
}
for (const [name, registration] of registrations)
if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name)
// OpenAI/GPT models use apply_patch; every other model uses edit and write.
const usePatch = input.model.provider.toLowerCase() === "openai" || input.model.id.toLowerCase().includes("gpt")
for (const [name, registration] of registrations) {
const wrongEditTool = name === "apply_patch" ? !usePatch : (name === "edit" || name === "write") && usePatch
if (wrongEditTool || whollyDisabled(permission(registration.tool, name), input.permissions ?? []))
registrations.delete(name)
}
return {
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
settle: (input) => {

View file

@ -2,20 +2,28 @@ export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { Effect, Layer, Schema, Scope } from "effect"
import { FSUtil } from "../fs-util"
import { Job } from "../job"
import { LocationMutation } from "../location-mutation"
import { LocationServiceMap } from "../location-service-map"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema"
import { SessionV2 } from "../session"
import { SessionSchema } from "../session/schema"
import { Shell } from "../shell"
import { Tool } from "./tool"
import { Tools } from "./tools"
import { Tool, type Content } from "./tool"
import { ApplicationTools } from "./application-tools"
import { makeGlobalNode } from "../effect/app-node"
export const name = "shell"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED =
"The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress."
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({
@ -26,6 +34,10 @@ export const Input = Schema.Struct({
.annotate({
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
}),
background: Schema.Boolean.pipe(Schema.optional).annotate({
description:
"Run the command in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
}),
})
const StructuredOutput = Schema.Struct({
@ -37,12 +49,14 @@ const StructuredOutput = Schema.Struct({
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
status: Schema.Literals(["completed", "running"]).pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
type Output = typeof Output.Type
const modelOutput = (output: Output) => {
const modelOutput = (output: Output): string | undefined => {
if (output.status === "running") return undefined
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
@ -60,9 +74,8 @@ const modelOutput = (output: Output) => {
// TODO: Restore PowerShell and cmd-specific invocation/path handling on Windows.
// TODO: Add plugin shell.env environment augmentation once V2 plugin hooks exist.
// TODO: Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.
// TODO: Persist background job status and define restart recovery before exposing remote observation.
// TODO: Re-add model-facing background launch only with owner-bound get/wait/cancel tools and completion delivery.
// TODO: Add HTTP background-job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Persist job status and define restart recovery before exposing remote observation.
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
// TODO: Stream full shell output into managed storage while retaining only a bounded in-memory preview.
@ -83,16 +96,48 @@ const externalCommandDirectories = (command: string, cwd: string) => {
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service
const shell = yield* Shell.Service
const permission = yield* PermissionV2.Service
const tools = yield* ApplicationTools.Service
const sessions = yield* SessionV2.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const fsUtil = yield* FSUtil.Service
const notifyWhenDone = Effect.fn("ShellTool.notifyWhenDone")(function* (
sessionID: SessionSchema.ID,
callID: string,
command: string,
) {
yield* jobs.wait({ id: callID }).pipe(
Effect.flatMap((result) => {
const state =
result.info?.status === "completed"
? "completed"
: result.info?.status === "error"
? "error"
: result.info?.status === "cancelled"
? "cancelled"
: undefined
if (state === undefined) return Effect.void
const text =
state === "completed"
? (result.info!.output ?? "")
: state === "error"
? (result.info!.error ?? "Command failed")
: "Command cancelled"
return sessions.synthetic({
sessionID,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
})
}),
Effect.forkIn(scope, { startImmediately: true }),
)
})
yield* tools
.register({
[name]: Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
structured: StructuredOutput,
@ -101,76 +146,132 @@ export const layer = Layer.effectDiscard(
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.output },
{ type: "text", text: modelOutput(output) },
],
toModelOutput: ({ output }) => {
const parts: Content[] = [{ type: "text", text: output.output }]
const model = modelOutput(output)
if (model) parts.push({ type: "text", text: model })
return parts
},
execute: (input, context) =>
Effect.gen(function* () {
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
const parent = yield* sessions
.get(context.sessionID)
.pipe(Effect.mapError(() => new ToolFailure({ message: `Session not found: ${context.sessionID}` })))
return yield* Effect.gen(function* () {
const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service
const permission = yield* PermissionV2.Service
const source = {
type: "tool" as const,
messageID: context.assistantMessageID,
callID: context.toolCallID,
}
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
const external = target.externalDirectory
if (external)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID,
agent: context.agent,
source,
})
const warnings = externalCommandDirectories(input.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({
...LocationMutation.externalDirectoryPermission(external),
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
const warnings = externalCommandDirectories(input.command, target.canonical).map(
(directory) =>
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
)
yield* permission.assert({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fs.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
// Delegate spawning, combined-output capture, timeout, and exit tracking to the Shell
// service. The full output is captured to a file; we read a bounded page for the model
// and point the agent at the file when it overflows the model cap.
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
if (final.status === "timeout") {
if (input.background === true) {
const run = Effect.fn("ShellTool.run")(function* () {
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
return yield* Effect.gen(function* () {
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout")
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return `${body}${notice}`
}).pipe(Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)))
})
const info = yield* jobs.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID },
run: run(),
})
yield* jobs.background(info.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
return {
exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
...(warnings.length ? { warnings } : {}),
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
exit: final.exit,
output: `${body}${notice}`,
truncated,
status: "completed" as const,
...(warnings.length ? { warnings } : {}),
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
exit: final.exit,
output: `${body}${notice}`,
truncated,
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.provide(locations.get(parent.location)))
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
})
.pipe(Effect.orDie)
}),
)
export const node = makeGlobalNode({
name: "shell-tool",
layer,
deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node, FSUtil.node],
})

View file

@ -1,14 +1,11 @@
export * as SubagentTool from "./subagent"
import { ToolFailure } from "@opencode-ai/llm"
import { DateTime, Effect, Layer, Schema, Scope } from "effect"
import { Effect, Layer, Schema, Scope } from "effect"
import { AgentV2 } from "../agent"
import { BackgroundJob } from "../background-job"
import { EventV2 } from "../event"
import { Job } from "../job"
import { LocationServiceMap } from "../location-service-map"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { makeGlobalNode } from "../effect/app-node"
import { ApplicationTools } from "./application-tools"
@ -47,8 +44,7 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* ApplicationTools.Service
const sessions = yield* SessionV2.Service
const jobs = yield* BackgroundJob.Service
const events = yield* EventV2.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
@ -75,15 +71,13 @@ export const layer = Layer.effectDiscard(
state: "completed" | "error" | "cancelled",
text: string,
) {
yield* events.publish(SessionEvent.Synthetic, {
yield* sessions.synthetic({
sessionID: parentID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
})
})
const injectWhenDone = Effect.fn("SubagentTool.injectWhenDone")(function* (
const notifyWhenDone = Effect.fn("SubagentTool.notifyWhenDone")(function* (
parentID: SessionSchema.ID,
childID: SessionSchema.ID,
description: string,
@ -144,37 +138,37 @@ export const layer = Layer.effectDiscard(
yield* sessions.prompt({ sessionID: child.id, prompt: { text: input.prompt }, resume: false })
yield* sessions.resume(child.id)
return yield* latestAssistantText(child.id)
})
}).pipe(Effect.onInterrupt(() => sessions.interrupt(child.id)))
const info = yield* jobs.start({
id: child.id,
type: name,
title: input.description,
metadata: {},
onPromote: injectWhenDone(context.sessionID, child.id, input.description),
run,
})
if (background) {
if ((yield* jobs.promote(info.id)) === undefined)
yield* injectWhenDone(context.sessionID, child.id, input.description)
yield* jobs.background(info.id)
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
}
const result = yield* Effect.raceFirst(
jobs.wait({ id: child.id }).pipe(Effect.map((waited) => waited.info)),
jobs.waitForPromotion(child.id),
).pipe(
Effect.onInterrupt(() =>
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
),
)
if (result?.metadata?.background === true)
const result = yield* jobs
.block({ id: child.id, sessionID: context.sessionID })
.pipe(
Effect.onInterrupt(() =>
Effect.all([sessions.interrupt(child.id), jobs.cancel(child.id)], { discard: true }),
),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, child.id, input.description)
return { sessionID: child.id, status: "running" as const, output: BACKGROUND_STARTED }
if (result?.status === "error")
return yield* new ToolFailure({ message: result.error ?? "Subagent failed" })
if (result?.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.output ?? NO_TEXT }
}
if (result?.info.status === "error")
return yield* new ToolFailure({ message: result.info.error ?? "Subagent failed" })
if (result?.info.status === "cancelled") return yield* new ToolFailure({ message: "Subagent cancelled" })
return { sessionID: child.id, status: "completed" as const, output: result?.info.output ?? NO_TEXT }
}),
}),
})
@ -188,5 +182,5 @@ export const layer = Layer.effectDiscard(
export const node = makeGlobalNode({
name: "subagent-tool",
layer,
deps: [ApplicationTools.node, SessionV2.node, BackgroundJob.node, EventV2.node, LocationServiceMap.node],
deps: [ApplicationTools.node, SessionV2.node, Job.node, LocationServiceMap.node],
})

View file

@ -185,6 +185,9 @@ export const validateName = (name: string) =>
? Effect.void
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
export const registrationEntries = (tools: Readonly<Record<string, AnyTool>>) =>
Object.entries(tools).map(([name, tool]) => [name.replace(/[^a-zA-Z0-9_-]/g, "_"), tool] as const)
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
tool: Definition<Input, Output>,
permission: string,

View file

@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect"
import { Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -8,9 +9,32 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host"
const it = testEffect(AgentV2.locationLayer)
const testLocation = location({ directory: AbsolutePath.make("/project") })
const locationLayer = Layer.succeed(Location.Service, Location.Service.of(testLocation))
const it = testEffect(
AgentV2.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
),
)
describe("AgentV2", () => {
it.effect("publishes an updated event after agent changes", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const events = yield* EventV2.Service
const updated = yield* events
.subscribe(AgentV2.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* agent.transform((editor) => editor.update(AgentV2.ID.make("reviewer"), () => {}))
expect(yield* Fiber.join(updated)).toMatchObject([{ location: { directory: testLocation.directory } }])
}),
)
it.effect("starts without agents", () =>
Effect.gen(function* () {
const agent = yield* AgentV2.Service

View file

@ -70,17 +70,14 @@ describe("ApplicationTools", () => {
}),
)
it.effect("exposes narrow scoped Location registration and validates names", () =>
it.effect("exposes narrow scoped Location registration and sanitizes names", () =>
Effect.gen(function* () {
const tools: Tools.Interface = yield* Tools.Service
const registry = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"])
expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf(
Tool.RegistrationError,
)
yield* tools.register({ "location.tool/search": contextual([]) }).pipe(Scope.provide(scope))
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool_search"])
yield* Scope.close(scope, Exit.void)
expect(yield* toolDefinitions(registry)).toEqual([])

View file

@ -1,103 +0,0 @@
import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { Deferred, Effect, Exit, Scope } from "effect"
import { it } from "./lib/effect"
describe("BackgroundJob", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { durable: false },
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
timedOut: true,
info: { status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("publishes jobs before starting immediately settling work", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
const id = `job_immediate_start_${index}`
return Effect.gen(function* () {
const job = yield* jobs.start({
id,
type: "test",
run: jobs
.get(id)
.pipe(
Effect.flatMap((info) =>
info?.status === "running"
? Effect.succeed(`done-${index}`)
: Effect.fail("job started before publish"),
),
),
})
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `done-${index}` },
})
})
})
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("increments pending work before starting immediately settling extensions", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) =>
Effect.gen(function* () {
const first = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as(`first-${index}`)),
})
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed(`second-${index}`) })).toBe(true)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(first, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `second-${index}` },
})
}),
)
}).pipe(Effect.provide(BackgroundJob.layer)),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const interrupted = yield* Deferred.make<void>()
const jobs = yield* BackgroundJob.make.pipe(Scope.provide(scope))
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* Scope.close(scope, Exit.void)
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
// The abandoned in-memory registry is not a durable observation channel.
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)
})

View file

@ -5,6 +5,7 @@ import { Effect, Layer, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
@ -12,7 +13,9 @@ import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { agentHost, host } from "../plugin/host"
const it = testEffect(Layer.mergeAll(AgentV2.locationLayer, FSUtil.defaultLayer))
const it = testEffect(
Layer.mergeAll(AgentV2.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer)), FSUtil.defaultLayer),
)
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigAgentPlugin.Plugin", () => {
@ -74,6 +77,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
const buildAgent = yield* agents.get(build)
if (!buildAgent) throw new Error("expected configured build agent")
expect(buildAgent.permissions).toEqual([
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "bash", resource: "*", effect: "allow" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
@ -91,6 +96,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
})
expect(reviewer.permissions).toEqual([
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
@ -98,6 +105,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
])
expect(PermissionV2.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
expect((yield* agents.get(AgentV2.ID.make("late")))?.permissions).toEqual([
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "bash", resource: "*", effect: "ask" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "allow" },
@ -255,13 +264,21 @@ Use native v2 fields.`,
system: "Review carefully.",
description: "Markdown description",
request: { body: { temperature: 0.5 } },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
],
})
expect(yield* agents.get(AgentV2.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
expect(yield* agents.get(AgentV2.ID.make("native"))).toMatchObject({
system: "Use native v2 fields.",
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "external_directory", resource: "*", effect: "ask" },
{ action: "edit", resource: "*", effect: "deny" },
],
})
expect(yield* agents.get(AgentV2.ID.make("disabled"))).toBeUndefined()
expect(yield* agents.get(AgentV2.ID.make("plan"))).toMatchObject({ system: "Make a plan.", mode: "primary" })

View file

@ -0,0 +1,164 @@
import { describe, expect } from "bun:test"
import { Job } from "@opencode-ai/core/job"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(Job.layer)
describe("Job", () => {
it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { durable: false },
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job).toMatchObject({ type: "test", status: "running", metadata: { durable: false } })
expect(yield* jobs.wait({ id: job.id, timeout: 0 })).toMatchObject({
timedOut: true,
info: { status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}),
)
it.live("publishes jobs before starting immediately settling work", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
yield* Effect.forEach(Array.from({ length: 100 }), (_, index) => {
const id = `job_immediate_start_${index}`
return Effect.gen(function* () {
const job = yield* jobs.start({
id,
type: "test",
run: jobs
.get(id)
.pipe(
Effect.flatMap((info) =>
info?.status === "running"
? Effect.succeed(`done-${index}`)
: Effect.fail("job started before publish"),
),
),
})
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: `done-${index}` },
})
})
})
}),
)
it.live("returns finished from a blocking wait when completion wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
yield* Deferred.succeed(latch, undefined)
expect(yield* Fiber.join(waiting)).toMatchObject({
type: "finished",
info: { status: "completed", output: "done" },
})
expect(yield* jobs.background(job.id)).toBeUndefined()
}),
)
it.live("returns backgrounded from a blocking wait when background wins", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
.pipe(Effect.forkIn(yield* Scope.Scope, { startImmediately: true }))
expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" })
expect(yield* Fiber.join(waiting)).toMatchObject({
type: "backgrounded",
info: { id: job.id, status: "running" },
})
yield* Deferred.succeed(latch, undefined)
expect(yield* jobs.wait({ id: job.id })).toMatchObject({
timedOut: false,
info: { status: "completed", output: "done" },
})
}),
)
it.live("backgrounds only jobs actively blocking a session", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const parent = SessionSchema.ID.make("ses_parent")
const other = SessionSchema.ID.make("ses_other")
const latch = yield* Deferred.make<void>()
const first = yield* jobs.start({
id: "job_first",
type: "test",
run: Deferred.await(latch).pipe(Effect.as("first")),
})
const second = yield* jobs.start({
id: "job_second",
type: "test",
run: Deferred.await(latch).pipe(Effect.as("second")),
})
const third = yield* jobs.start({
id: "job_third",
type: "other",
run: Deferred.await(latch).pipe(Effect.as("third")),
})
const scope = yield* Scope.Scope
const firstWait = yield* jobs
.block({ id: first.id, sessionID: parent })
.pipe(Effect.forkIn(scope, { startImmediately: true }))
const secondWait = yield* jobs
.block({ id: second.id, sessionID: other })
.pipe(Effect.forkIn(scope, { startImmediately: true }))
const thirdWait = yield* jobs
.block({ id: third.id, sessionID: parent })
.pipe(Effect.forkIn(scope, { startImmediately: true }))
expect(yield* jobs.backgroundAll({ sessionID: parent, type: "test" })).toMatchObject([{ id: first.id }])
expect(yield* Fiber.join(firstWait)).toMatchObject({ type: "backgrounded", info: { id: first.id } })
yield* Deferred.succeed(latch, undefined)
expect(yield* Fiber.join(secondWait)).toMatchObject({ type: "finished", info: { id: second.id } })
expect(yield* Fiber.join(thirdWait)).toMatchObject({ type: "finished", info: { id: third.id } })
}),
)
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
Effect.gen(function* () {
const scope = yield* Scope.make()
const interrupted = yield* Deferred.make<void>()
const jobs = yield* Job.make.pipe(Scope.provide(scope))
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* Scope.close(scope, Exit.void)
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
// The abandoned in-memory registry is not a durable observation channel.
expect((yield* jobs.get(job.id))?.status).toBe("running")
}),
)
})

View file

@ -1,4 +1,5 @@
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { Effect } from "effect"
@ -8,13 +9,17 @@ export const toolIdentity = {
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
}
// Default fixture model: a non-OpenAI provider, so edit and write are the materialized edit tools.
export const testModel: ToolRegistry.MaterializeInput["model"] = { id: "claude-test", provider: "anthropic" }
export const toolDefinitions = (
registry: ToolRegistry.Interface,
permissions?: Parameters<typeof registry.materialize>[0],
) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
permissions?: PermissionV2.Ruleset,
model = testModel,
) => registry.materialize({ permissions, model }).pipe(Effect.map((materialized) => materialized.definitions))
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
registry.materialize({ model }).pipe(Effect.flatMap((materialized) => materialized.settle(input)))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput, model = testModel) =>
settleTool(registry, input, model).pipe(Effect.map((settlement) => settlement.result))

View file

@ -120,8 +120,6 @@ describe("LocationServiceMap", () => {
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"application_context",
"apply_patch",
"bash",
"edit",
"glob",
"grep",
@ -137,8 +135,6 @@ describe("LocationServiceMap", () => {
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"application_context",
"apply_patch",
"bash",
"edit",
"glob",
"grep",

View file

@ -1,5 +1,5 @@
import { describe, expect, beforeAll, beforeEach, afterAll } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Effect, Layer, Ref, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flag } from "@opencode-ai/core/flag/flag"
@ -126,6 +126,28 @@ const initialState: MockState = {
}
describe("ModelsDev Service", () => {
it.effect("decodes known reasoning options", () =>
Effect.sync(() => {
const result = Schema.decodeUnknownSync(ModelsDev.Model)({
id: "reasoning-model",
name: "Reasoning Model",
release_date: "2026-01-01",
attachment: false,
reasoning: true,
reasoning_options: [
{ type: "effort", values: ["low", "high"] },
{ type: "budget_tokens", min: 1024, max: 8192 },
{ type: "toggle" },
],
temperature: true,
tool_call: true,
limit: { context: 128000, output: 8192 },
})
expect(result.reasoning_options?.map((item) => item.type)).toEqual(["effort", "budget_tokens", "toggle"])
}),
)
it.live("get() returns providers from disk when cache file exists", () =>
Effect.gen(function* () {
yield* writeCache(fixture)

View file

@ -1,25 +1,50 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { Config } from "@opencode-ai/core/config"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Location } from "@opencode-ai/core/location"
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Effect } from "effect"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
import { host } from "./host"
const it = testEffect(AppNodeBuilder.build(SkillV2.node))
describe("SkillPlugin.Plugin", () => {
it.effect("registers the built-in customize-opencode skill", () =>
it.effect("registers built-in skills", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } }))
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
Effect.provideService(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
),
Effect.provide(FSUtil.defaultLayer),
Effect.provide(NodeFileSystem.layer),
)
const skills = yield* skill.list()
const report = skills.find((item) => item.name === "report")
expect(yield* skill.list()).toContainEqual(
expect(skills).toContainEqual(
expect.objectContaining({
name: "customize-opencode",
description: expect.stringContaining("opencode's own configuration"),
}),
)
expect(skills).toContainEqual(
expect.objectContaining({
name: "report",
description: expect.stringContaining("opencode issue"),
}),
)
expect(report?.slash).toBe(true)
expect(report?.content).toContain(`- opencode version: ${InstallationVersion}`)
}),
)
})

View file

@ -44,6 +44,14 @@ describe("ProjectDirectories", () => {
}),
)
it.effect("returns an empty list for missing projects", () =>
Effect.gen(function* () {
const service = yield* ProjectDirectories.Service
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
}),
)
it.effect("replaces the strategy when requested", () =>
Effect.gen(function* () {
yield* setup()

View file

@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer, Stream } from "effect"
import { DateTime, Effect, Layer, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
@ -20,6 +20,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
@ -131,6 +132,94 @@ describe("SessionV2.create", () => {
}),
)
it.effect("forks a session by replaying a durable fork event into copied projected rows", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location, title: "Parent" })
const admitted = yield* session.prompt({
sessionID: parent.id,
prompt: Prompt.make({ text: "First" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* events.publish(SessionEvent.Synthetic, {
sessionID: parent.id,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
text: "parent note",
})
const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id)
const forkContext = yield* session.context(forked.id)
const history = yield* session.history({ sessionID: forked.id, limit: 10 })
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" })
expect(forkContext).toMatchObject([
{ type: "user", text: "First" },
{ type: "synthetic", text: "parent note", sessionID: forked.id },
])
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history.events).toHaveLength(1)
expect(history.events[0]).toMatchObject({
type: "session.next.forked",
durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id },
})
expect(yield* SessionInput.find(db, forkContext[0]!.id)).toMatchObject({
sessionID: forked.id,
prompt: { text: "First" },
promotedSeq: 2,
})
yield* session.prompt({ sessionID: parent.id, prompt: Prompt.make({ text: "Parent changed" }), resume: false })
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
yield* session.prompt({ sessionID: forked.id, prompt: Prompt.make({ text: "Child continues" }), resume: false })
yield* SessionInput.promoteSteers(db, events, forked.id, Number.MAX_SAFE_INTEGER)
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).at(-1)).toMatchObject({ text: "Child continues" })
expect((yield* session.history({ sessionID: forked.id, limit: 10 })).events.map((event) => event.durable?.seq)).toEqual([
0,
4,
5,
])
expect(yield* SessionInput.find(db, admitted.id)).toMatchObject({ sessionID: parent.id })
}),
)
it.effect("forks before the selected boundary message", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
const first = yield* session.prompt({
sessionID: parent.id,
prompt: Prompt.make({ text: "First" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
const second = yield* session.prompt({
sessionID: parent.id,
prompt: Prompt.make({ text: "Second" }),
resume: false,
})
yield* SessionInput.promoteSteers(db, events, parent.id, Number.MAX_SAFE_INTEGER)
const forked = yield* session.fork({ sessionID: parent.id, messageID: second.id })
const context = yield* session.context(forked.id)
const history = yield* session.history({ sessionID: forked.id, limit: 10 })
expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id)
expect(history.events[0]).toMatchObject({ data: { messageID: second.id } })
}),
)
it.effect("returns the existing Session when one ID is reused with different create arguments", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
@ -355,7 +444,6 @@ describe("SessionV2.create", () => {
)
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
}),
)

View file

@ -60,7 +60,7 @@ const permission = Layer.succeed(
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const agents = AgentV2.layer
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const model = OpenAIChat.route
.with({
endpoint: { baseURL: "https://api.openai.com/v1" },

View file

@ -1,12 +1,13 @@
import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent"
import type { PermissionV2 } from "@opencode-ai/core/permission"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
import { executeTool, settleTool, testModel, toolDefinitions } from "./lib/tool"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, SchemaGetter, SchemaIssue, Scope } from "effect"
import { testEffect } from "./lib/effect"
@ -61,17 +62,11 @@ describe("ToolRegistry", () => {
bash: make(),
edit: make("edit"),
write: make("edit"),
apply_patch: make("edit"),
})
const names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
const names = (permissions: PermissionV2.Ruleset) =>
toolDefinitions(service, permissions).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
"edit",
"write",
"apply_patch",
])
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["bash", "edit", "write"])
expect(
yield* names([
{ action: "*", resource: "*", effect: "deny" },
@ -88,6 +83,27 @@ describe("ToolRegistry", () => {
}),
)
it.effect("selects one edit tool family for each model", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({
read: make(),
edit: make("edit"),
write: make("edit"),
apply_patch: make("edit"),
})
const names = (model: ToolRegistry.MaterializeInput["model"]) =>
service
.materialize({ model })
.pipe(Effect.map((materialized) => materialized.definitions.map((tool) => tool.name)))
expect(yield* names({ id: "gpt-5", provider: "openai" })).toEqual(["read", "apply_patch"])
expect(yield* names({ id: "gpt-4o", provider: "opencode" })).toEqual(["read", "apply_patch"])
expect(yield* names({ id: "computer-use-preview", provider: "openai" })).toEqual(["read", "apply_patch"])
expect(yield* names({ id: "claude-sonnet-4", provider: "anthropic" })).toEqual(["read", "edit", "write"])
}),
)
it.effect("keeps permission decoration isolated between registrations", () =>
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
@ -183,7 +199,7 @@ describe("ToolRegistry", () => {
}),
})
expect(
yield* service.materialize().pipe(
yield* service.materialize({ model: testModel }).pipe(
Effect.flatMap((materialized) =>
materialized.settle({
sessionID,
@ -201,7 +217,7 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
const exit = yield* materialized.settle(call("echo", "call-retention-failure")).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
@ -331,7 +347,7 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ echo: make() })
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
expect((yield* materialized.settle(call("echo"))).result).toEqual({ type: "text", value: "echo" })
}),
@ -342,7 +358,7 @@ describe("ToolRegistry", () => {
const service = yield* ToolRegistry.Service
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
@ -356,7 +372,7 @@ describe("ToolRegistry", () => {
Effect.gen(function* () {
const service = yield* ToolRegistry.Service
yield* service.register({ first: make(), second: make() })
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
yield* service.register({ first: make() })
expect((yield* materialized.settle(call("first"))).result).toEqual({
@ -373,7 +389,7 @@ describe("ToolRegistry", () => {
yield* service.register({ echo: make() })
const overlay = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(overlay))
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(overlay, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
@ -388,7 +404,7 @@ describe("ToolRegistry", () => {
const applications = yield* ApplicationTools.Service
const service = yield* ToolRegistry.Service
yield* applications.register({ echo: make() })
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
yield* service.register({ echo: make() })
expect((yield* materialized.settle(call("echo"))).result).toEqual({
@ -405,7 +421,7 @@ describe("ToolRegistry", () => {
yield* applications.register({ echo: make() })
const scope = yield* Scope.make()
yield* service.register({ echo: make() }).pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
yield* Scope.close(scope, Exit.void)
expect((yield* materialized.settle(call("echo"))).result).toEqual({
@ -433,7 +449,7 @@ describe("ToolRegistry", () => {
}),
})
.pipe(Scope.provide(scope))
const materialized = yield* service.materialize()
const materialized = yield* service.materialize({ model: testModel })
const settlement = yield* materialized.settle(call("echo")).pipe(Effect.forkChild)
yield* Deferred.await(started)
yield* Scope.close(scope, Exit.void)

View file

@ -130,7 +130,7 @@ const registry = ToolRegistry.layer.pipe(
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const agents = AgentV2.layer
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
registry.register({

View file

@ -134,6 +134,10 @@ test("Core reuses the canonical shared schemas", async () => {
[corePty.Info, Pty.Info],
[corePty.Event, Pty.Event],
[coreProject.ID, Project.ID],
[coreProject.Current, Project.Current],
[coreProject.Directory, Project.Directory],
[coreProject.DirectoriesInput, Project.DirectoriesInput],
[coreProject.Directories, Project.Directories],
[coreReference.LocalSource, Reference.LocalSource],
[coreReference.GitSource, Reference.GitSource],
[coreReference.Source, Reference.Source],

View file

@ -55,12 +55,10 @@ describe("shell", () => {
})
test("builds command args per shell family", () => {
expect(ShellSelect.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(ShellSelect.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = ShellSelect.args("/bin/zsh", "echo hi", "/tmp")
expect(zsh[0]).toBe("-l")
expect(zsh[1]).toBe("-c")
expect(zsh.at(-1)).toBe("/tmp")
expect(ShellSelect.args("/bin/sh", "echo hi")).toEqual(["-c", "echo hi"])
expect(ShellSelect.args("/usr/bin/fish", "echo hi")).toEqual(["-c", "echo hi"])
expect(ShellSelect.args("/bin/zsh", "echo hi")).toEqual(["-c", "echo hi"])
expect(ShellSelect.args("/bin/bash", "echo hi")).toEqual(["-c", "echo hi"])
})
if (process.platform === "win32") {

View file

@ -109,6 +109,9 @@ const call = (patchText: string, id = "call-apply-patch") => ({
call: { type: "tool-call" as const, id, name: "apply_patch", input: { patchText } },
})
// apply_patch is only materialized for OpenAI/GPT models.
const model = { id: "gpt-5", provider: "openai" }
const exists = (target: string) =>
Effect.promise(() =>
fs.stat(target).then(
@ -132,12 +135,15 @@ describe("ApplyPatchTool", () => {
Effect.andThen(
withTool(tmp.path, (registry) =>
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["apply_patch"])
expect((yield* toolDefinitions(registry, undefined, model)).map((tool) => tool.name)).toEqual([
"apply_patch",
])
const settled = yield* settleTool(
registry,
call(
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Update File: update.txt\n@@\n-before\n+after\n*** Delete File: remove.txt\n*** End Patch",
),
model,
)
expect(settled.result).toEqual({
type: "text",
@ -207,6 +213,7 @@ describe("ApplyPatchTool", () => {
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: old.txt\n*** Move to: moved.txt\n@@\n-before\n+after\n*** End Patch",
),
model,
),
).toEqual({ type: "error", value: "apply_patch moves are not supported yet" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
@ -234,6 +241,7 @@ describe("ApplyPatchTool", () => {
yield* executeTool(
registry,
call(`*** Begin Patch\n*** Update File: ${target}\n@@\n-before\n+after\n*** End Patch`),
model,
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
@ -270,6 +278,7 @@ describe("ApplyPatchTool", () => {
call(
`*** Begin Patch\n*** Update File: ${first}\n@@\n-before\n+after\n*** Update File: ${second}\n@@\n-before\n+after\n*** End Patch`,
),
model,
),
).toMatchObject({ type: "text" })
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
@ -301,6 +310,7 @@ describe("ApplyPatchTool", () => {
call(
"*** Begin Patch\n*** Add File: created.txt\n+created\n*** Update File: missing.txt\n@@\n-before\n+after\n*** End Patch",
),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at missing.txt" })
expect(yield* exists(path.join(tmp.path, "created.txt"))).toBe(false)
@ -325,6 +335,7 @@ describe("ApplyPatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: existing.txt\n+replacement\n*** End Patch"),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at existing.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("sentinel\n")
@ -350,6 +361,7 @@ describe("ApplyPatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Add File: appeared.txt\n+replacement\n*** End Patch"),
model,
),
).toEqual({ type: "error", value: "Unable to apply patch at appeared.txt" })
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("winner\n")
@ -377,6 +389,7 @@ describe("ApplyPatchTool", () => {
yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
model,
).pipe(Effect.exit),
),
).toBe(true)
@ -408,6 +421,7 @@ describe("ApplyPatchTool", () => {
const run = yield* executeTool(
registry,
call("*** Begin Patch\n*** Delete File: first.txt\n*** Delete File: second.txt\n*** End Patch"),
model,
).pipe(Effect.forkChild)
yield* Deferred.await(removeStarted!)
const interrupt = yield* Fiber.interrupt(run).pipe(Effect.forkChild)

View file

@ -2,26 +2,37 @@ import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { Config } from "@opencode-ai/core/config"
import { DateTime, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
import { filesystem } from "@opencode-ai/core/effect/app-node-platform"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { Project } from "@opencode-ai/core/project"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Job } from "@opencode-ai/core/job"
import { SessionV2 } from "@opencode-ai/core/session"
import { Shell } from "@opencode-ai/core/shell"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionStore } from "@opencode-ai/core/session/store"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { ShellTool } from "@opencode-ai/core/tool/shell"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_shell_tool_test")
const sessionModel = ModelV2.Ref.make({ id: ModelV2.ID.make("test"), providerID: ProviderV2.ID.make("test") })
const assertions: PermissionV2.AssertInput[] = []
let denyAction: string | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
@ -50,37 +61,80 @@ const reset = () => {
afterPermission = () => Effect.void
}
const withTool = <A, E, R>(
data: string,
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
) => {
const filesystem = FSUtil.defaultLayer
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
Layer.provide(Project.defaultLayer),
)
const global = Global.layerWith({ data, config: path.join(data, "config") })
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(location))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const shellService = Shell.layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(location),
Layer.provide(Config.locationLayer.pipe(Layer.provide(location), Layer.provide(filesystem), Layer.provide(global))),
Layer.provide(global),
Layer.provide(filesystem),
Layer.provide(AppProcess.defaultLayer),
)
const shell = ShellTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(shellService),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, shell, filesystem)))
}
const executionNode = makeGlobalNode({
service: SessionExecution.Service,
layer: Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const store = yield* SessionStore.Service
const complete = Effect.fn("ShellTest.complete")(function* (id: SessionV2.ID) {
const session = yield* store.get(id)
if (!session) return
const assistantMessageID = SessionMessage.ID.create()
const textID = "text_shell_test"
yield* events.publish(SessionEvent.Step.Started, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
agent: session.agent ?? AgentV2.ID.make("code"),
model: sessionModel,
})
yield* events.publish(SessionEvent.Text.Started, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
})
yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
textID,
text: "ok",
})
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: id,
assistantMessageID,
timestamp: yield* DateTime.now,
finish: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
return SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
})
}),
),
deps: [EventV2.node, SessionStore.node],
})
const layer = AppNodeBuilder.build(
LayerNode.bind(
LayerNode.group([
Database.node,
EventV2.node,
Job.node,
ToolOutputStore.cleanupNode,
SessionV2.node,
ShellTool.node,
LocationServiceMap.node,
filesystem,
FSUtil.node,
Global.node,
]),
SessionExecution.node,
executionNode,
),
[LayerNode.replace(PermissionV2.layer, permission)],
)
const it = testEffect(layer)
const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
sessionID,
@ -88,47 +142,75 @@ const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
call: { type: "tool-call" as const, id, name: "shell", input },
})
const it = testEffect(Layer.empty)
const isWindows = process.platform === "win32"
const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
const bodyExitCommand = isWindows
? "[Console]::Out.Write('body'); Start-Sleep -Milliseconds 100; exit 7"
: "printf body && exit 7"
const overflowCommand = (bytes: number) =>
isWindows
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const location = Location.Ref.make({ directory: AbsolutePath.make(directory) })
yield* sessions.create({
id: sessionID,
title: "shell test",
location,
model: sessionModel,
})
const locations = yield* LocationServiceMap.Service
const locationLayer = locations.get(location)
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locationLayer))
return yield* body(registry).pipe(Effect.provide(locationLayer))
})
describe("ShellTool", () => {
it.live("registers and returns real successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(data.path, tmp.path, (registry) =>
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["shell"])
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
expect(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).toEqual([])
const shell = definitions.find((tool) => tool.name === "shell")
expect(shell).toBeDefined()
expect(shell?.outputSchema).not.toHaveProperty("properties.output")
expect(
(yield* toolDefinitions(registry, [{ action: "shell", resource: "*", effect: "deny" }])).map(
(tool) => tool.name,
),
).not.toContain("shell")
const settled = yield* settleTool(registry, call({ command: "printf hello" }))
const settled = yield* settleTool(registry, call({ command: helloCommand }))
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "hello" })
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 0."),
})
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: ["printf hello"] }])
expect(assertions).toMatchObject([{ sessionID, action: "shell", resources: [helloCommand] }])
}),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withTool(data.path, tmp.path, (registry) => settleTool(registry, call({ command: "pwd", workdir: "src" }))),
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() =>
@ -140,17 +222,14 @@ describe("ShellTool", () => {
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("rejects a workdir that stops being a directory during approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const workdir = path.join(tmp.path, "src")
afterPermission = (input) =>
@ -162,27 +241,22 @@ describe("ShellTool", () => {
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(
withTool(data.path, tmp.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: "src" })),
),
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("approves an explicit external workdir before shell execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
([data, active, outside]) => {
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withTool(data.path, active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
return withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
@ -194,55 +268,45 @@ describe("ShellTool", () => {
),
)
},
([data, active, outside]) =>
([active, outside]) =>
Effect.promise(() =>
Promise.all([
data[Symbol.asyncDispose](),
active[Symbol.asyncDispose](),
outside[Symbol.asyncDispose](),
]).then(() => undefined),
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("does not execute after external-directory or shell denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
([data, active, outside]) =>
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(data.path, active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
yield* withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
reset()
denyAction = "shell"
yield* withTool(data.path, active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
yield* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([data, active, outside]) =>
([active, outside]) =>
Effect.promise(() =>
Promise.all([
data[Symbol.asyncDispose](),
active[Symbol.asyncDispose](),
outside[Symbol.asyncDispose](),
]).then(() => undefined),
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("reports external command arguments as advisory warnings without enforcing approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir(), tmpdir()])),
([data, active, outside]) => {
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(data.path, active.path, (registry) =>
settleTool(registry, call({ command: `cat ${target}` })),
).pipe(
return withSession(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["shell"])
@ -255,24 +319,20 @@ describe("ShellTool", () => {
),
)
},
([data, active, outside]) =>
([active, outside]) =>
Effect.promise(() =>
Promise.all([
data[Symbol.asyncDispose](),
active[Symbol.asyncDispose](),
outside[Symbol.asyncDispose](),
]).then(() => undefined),
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(data.path, tmp.path, (registry) =>
settleTool(registry, call({ command: "printf body && exit 7" }, "call-nonzero")),
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
@ -286,21 +346,18 @@ describe("ShellTool", () => {
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("truncates the model view and points at the saved output file when output overflows", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withTool(data.path, tmp.path, (registry) =>
settleTool(registry, call({ command: `head -c ${bytes} /dev/zero | tr '\\0' 'x'` }, "call-overflow")),
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
@ -313,20 +370,17 @@ describe("ShellTool", () => {
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([data, tmp]) => {
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(data.path, tmp.path, (registry) =>
settleTool(registry, call({ command: "sleep 60", timeout: 50 })),
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
@ -339,10 +393,7 @@ describe("ShellTool", () => {
),
)
},
([data, tmp]) =>
Effect.promise(() =>
Promise.all([data[Symbol.asyncDispose](), tmp[Symbol.asyncDispose]()]).then(() => undefined),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
})
@ -356,7 +407,7 @@ test("keeps locked deferred parity TODOs visible", async () => {
"Restore PowerShell and cmd-specific invocation/path handling on Windows.",
"Add plugin shell.env environment augmentation once V2 plugin hooks exist.",
"Add durable/live progress metadata streaming for long-running commands once V2 tool invocation progress context is wired.",
"Persist background job status and define restart recovery before exposing remote observation.",
"Persist job status and define restart recovery before exposing remote observation.",
"Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.",
"Revisit binary output handling if stdout/stderr decoding is text-only.",
"Stream full shell output into managed storage while retaining only a bounded in-memory preview.",

View file

@ -10,7 +10,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { AgentV2 } from "@opencode-ai/core/agent"
import { BackgroundJob } from "@opencode-ai/core/background-job"
import { Job } from "@opencode-ai/core/job"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
@ -23,7 +23,7 @@ import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { executeTool, settleTool, toolIdentity } from "./lib/tool"
import { executeTool, settleTool, testModel, toolIdentity } from "./lib/tool"
const childText = "child final response"
const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") })
@ -95,7 +95,7 @@ const layer = AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
BackgroundJob.node,
Job.node,
ToolOutputStore.cleanupNode,
SessionV2.node,
SubagentTool.node,
@ -142,7 +142,9 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location)))
expect((yield* registry.materialize()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
expect((yield* registry.materialize({ model: testModel })).definitions.map((tool) => tool.name)).toContain(
SubagentTool.name,
)
expect(
yield* executeTool(registry, {
sessionID: parent.id,
@ -242,7 +244,7 @@ describe("SubagentTool", () => {
),
)
it.live("promotes background work and injects one synthetic parent completion", () =>
it.live("notifies once when background work completes", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@ -251,7 +253,6 @@ describe("SubagentTool", () => {
Effect.gen(function* () {
const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
const sessions = yield* SessionV2.Service
const jobs = yield* BackgroundJob.Service
const parent = yield* sessions.create({ location })
yield* withSubagent(parent.location)
const locations = yield* LocationServiceMap.Service
@ -270,7 +271,6 @@ describe("SubagentTool", () => {
const childID = outputSessionID(settled.output?.structured)
expect(settled.output?.structured).toMatchObject({ status: "running" })
yield* jobs.promote(childID)
yield* Effect.yieldNow
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1)

View file

@ -9,10 +9,15 @@ export type InputField = {
readonly source: "params" | "query" | "headers" | "payload"
}
export type OperationInputField = {
readonly name: string
readonly source: InputField["source"] | "wildcard"
}
export type Operation = {
readonly group: string
readonly name: string
readonly input: ReadonlyArray<InputField>
readonly input: ReadonlyArray<OperationInputField>
readonly inputMode: "none" | "optional" | "required"
readonly success: "value" | "void" | "stream"
readonly errors: ReadonlyArray<string>
@ -67,6 +72,10 @@ type Slot = {
readonly schema: Schema.Top
}
type PromiseInputField =
| (InputField & { readonly optional: boolean })
| { readonly name: string; readonly source: "wildcard"; readonly optional: false }
const resolveHttpApiStatus = SchemaAST.resolveAt<number>("httpApiStatus")
const resolveHttpApiEncoding = SchemaAST.resolveAt<HttpApiSchema.Encoding>("~httpApiEncoding")
const resolveContentSchema = SchemaAST.resolveAt<SchemaAST.AST>("contentSchema")
@ -246,7 +255,7 @@ export function emitPromise(
for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint)
}
return {
operations: operations(groups),
operations: promiseOperations(groups),
files: [
{ path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) },
{
@ -255,7 +264,7 @@ export function emitPromise(
},
{
path: "client.ts",
content: renderPromiseClient(groups).replace("let next: ReadableStreamReadResult<Uint8Array>", "let next"),
content: normalizePromiseClientContent(renderPromiseClient(groups), groups),
},
{
path: "index.ts",
@ -285,11 +294,11 @@ function assertPromiseEndpoint(endpoint: Endpoint) {
) {
throw new GenerationError({ reason: `Unsupported Promise stream: ${name}` })
}
} else if (
!HttpApiSchema.isNoContent(success.ast) &&
(resolveHttpApiEncoding(success.ast)?._tag ?? "Json") !== "Json"
) {
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
} else if (!HttpApiSchema.isNoContent(success.ast)) {
const encoding = resolveHttpApiEncoding(success.ast)?._tag ?? "Json"
if (encoding !== "Json" && encoding !== "Uint8Array") {
throw new GenerationError({ reason: `Unsupported Promise success encoding: ${name}` })
}
}
for (const error of endpoint.errors) {
if (declaredErrorFields(error.schema) === undefined) {
@ -305,6 +314,16 @@ function operations(groups: ReadonlyArray<Group>) {
return groups.flatMap((group) => group.endpoints.map((endpoint) => endpoint.operation))
}
function promiseOperations(groups: ReadonlyArray<Group>) {
return groups.flatMap((group) =>
group.endpoints.map((endpoint) => ({
...endpoint.operation,
input: promiseInput(endpoint).map(({ name, source }) => ({ name, source })),
inputMode: promiseInputMode(endpoint),
})),
)
}
function renderEffectFiles(groups: ReadonlyArray<Group>): Output["files"] {
return [
...groups.map((group, index) => ({ path: `${group.module}.ts`, content: renderGroup(group, index) })),
@ -457,8 +476,9 @@ function renderPromiseTypes(
headers: endpoint.headers,
payload: endpoint.payloads[0],
}
const input = endpoint.input
const input = promiseInput(endpoint)
.map((field) => {
if (field.source === "wildcard") return `readonly ${JSON.stringify(field.name)}: string`
const schema = schemas[field.source]
if (schema === undefined)
throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` })
@ -476,7 +496,7 @@ function renderPromiseTypes(
: successSchema,
)
return [
...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
...(promiseInputMode(endpoint) === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]),
`export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`,
]
}),
@ -493,19 +513,19 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
const imports = groups.flatMap((group) =>
group.endpoints.flatMap((endpoint) => {
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
return [...(endpoint.operation.inputMode === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`]
return [...(promiseInputMode(endpoint) === "none" ? [] : [`${prefix}Input`]), `${prefix}Output`]
}),
)
const fields = groups.map((group) => {
const methods = group.endpoints.map((endpoint) => {
const prefix = promiseTypePrefix(group.identifier, endpoint.operation.name)
const inputMode = promiseInputMode(endpoint)
const argument =
endpoint.operation.inputMode === "none"
inputMode === "none"
? "requestOptions?: RequestOptions"
: `input${endpoint.operation.inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions`
const path = promisePath(endpoint.endpoint.path, endpoint.input)
const access = (name: string) =>
`input${endpoint.operation.inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
: `input${inputMode === "optional" ? "?" : ""}: ${prefix}Input, requestOptions?: RequestOptions`
const path = promisePath(endpoint.endpoint.path, endpoint.input, promiseWildcardInput(endpoint))
const access = (name: string) => `input${inputMode === "optional" ? "?." : ""}[${JSON.stringify(name)}]`
const part = (source: InputField["source"]) => {
const inputs = endpoint.input.filter((field) => field.source === source)
return inputs.length === 0
@ -518,7 +538,7 @@ function renderPromiseClient(groups: ReadonlyArray<Group>) {
endpoint.payloads.length === 0 ? undefined : `body: ${part("payload")}`,
].filter((value): value is string => value !== undefined)
const declaredStatuses = [...new Set(endpoint.errors.map((error) => error.status))]
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"} }`
const descriptor = `{ method: ${JSON.stringify(endpoint.endpoint.method)}, path: ${path}${parts.length === 0 ? "" : `, ${parts.join(", ")}`}, successStatus: ${resolveHttpApiStatus(endpoint.successes[0].ast) ?? 200}, declaredStatuses: [${declaredStatuses.join(", ")}], empty: ${endpoint.operation.success === "void"}${isBinarySchema(endpoint.successes[0]) ? ", binary: true" : ""} }`
if (endpoint.operation.success === "stream") {
const success = endpoint.successes[0]
if (!isStreamSchema(success) || success._tag !== "StreamSse" || success.sseMode !== "data") {
@ -583,10 +603,67 @@ function structuralType(schema: Schema.Top) {
.replaceAll("Schema.Json", "JsonValue")
}
function promisePath(path: string, input: ReadonlyArray<InputField>) {
if (path.includes("*")) throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${path}` })
function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Group>) {
const endpoints = groups.flatMap((group) => group.endpoints)
const usesBinary = endpoints.some((endpoint) => isBinarySchema(endpoint.successes[0]))
const usesWildcard = endpoints.some((endpoint) => promiseWildcardInput(endpoint) !== undefined)
const sseReady = replaceOne(content, "let next: ReadableStreamReadResult<Uint8Array>", "let next")
const binaryReady = usesBinary
? replaceOne(
replaceOne(sseReady, "readonly empty: boolean\n}", "readonly empty: boolean\n readonly binary?: true\n}"),
"if (descriptor.empty) {",
"if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A\n if (descriptor.empty) {",
)
: sseReady
return usesWildcard
? replaceOne(
binaryReady,
"function appendQuery(params: URLSearchParams, key: string, value: unknown): void {",
'function encodePath(value: string): string {\n return value.split("/").map(encodeURIComponent).join("/")\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {',
)
: binaryReady
}
function replaceOne(input: string, search: string, replacement: string) {
if (!input.includes(search))
throw new GenerationError({ reason: `Missing Promise client template marker: ${search}` })
return input.replace(search, replacement)
}
function promiseInput(endpoint: Endpoint): ReadonlyArray<PromiseInputField> {
const wildcard = promiseWildcardInput(endpoint)
if (wildcard === undefined) return endpoint.input
return [...endpoint.input, wildcard]
}
function promiseInputMode(endpoint: Endpoint): Operation["inputMode"] {
const input = promiseInput(endpoint)
if (input.length === 0) return "none"
return input.every((field) => field.optional) ? "optional" : "required"
}
function promiseWildcardInput(endpoint: Endpoint): PromiseInputField | undefined {
if (!endpoint.endpoint.path.includes("*")) return undefined
if (endpoint.endpoint.path.indexOf("*") !== endpoint.endpoint.path.lastIndexOf("*")) {
throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` })
}
if (!endpoint.endpoint.path.endsWith("*")) {
throw new GenerationError({ reason: `Unsupported Promise path wildcard: ${endpoint.endpoint.path}` })
}
const name = endpoint.input.some((field) => field.name === "path") ? "wildcard" : "path"
return { name, source: "wildcard", optional: false }
}
function isBinarySchema(schema: Schema.Top) {
return (resolveHttpApiEncoding(schema.ast)?._tag ?? "Json") === "Uint8Array"
}
function promisePath(path: string, input: ReadonlyArray<InputField>, wildcard?: PromiseInputField) {
const fields = new Set(input.filter((field) => field.source === "params").map((field) => field.name))
const segments = path.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g).filter(Boolean)
const segments = (wildcard === undefined ? path : path.slice(0, -1))
.split(/(:[A-Za-z_][A-Za-z0-9_]*)/g)
.filter(Boolean)
const template = segments
.map((segment) => {
if (!segment.startsWith(":")) return segment.replaceAll("`", "\\`")
@ -595,7 +672,7 @@ function promisePath(path: string, input: ReadonlyArray<InputField>) {
return `\${encodeURIComponent(input.${name})}`
})
.join("")
return `\`${template}\``
return `\`${template}${wildcard === undefined ? "" : `\${encodePath(input.${wildcard.name})}`}\``
}
function uniqueModule(base: string, index: number, modules: ReadonlySet<string>) {

View file

@ -355,20 +355,8 @@ describe("HttpApiCodegen.generate", () => {
).toThrow("Unsupported Promise success encoding: session.text")
expect(() =>
emitPromise(
compileContract(
api(
HttpApiEndpoint.get("binary", "/binary", {
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
),
),
),
).toThrow("Unsupported Promise success encoding: session.binary")
expect(() =>
emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*", { success: Schema.String })))),
).toThrow("Unsupported Promise path wildcard: /file/*")
emitPromise(compileContract(api(HttpApiEndpoint.get("read", "/file/*/tail", { success: Schema.String })))),
).toThrow("Unsupported Promise path wildcard: /file/*/tail")
expect(() =>
emitPromise(
@ -443,6 +431,41 @@ describe("HttpApiCodegen.generate", () => {
}
})
test("executes an emitted binary wildcard GET through fetch", async () => {
const output = emitPromise(
compileContract(
api(
HttpApiEndpoint.get("read", "/file/*", {
query: { token: Schema.optional(Schema.String) },
success: Schema.Uint8Array.pipe(HttpApiSchema.asUint8Array()),
}),
),
),
)
const directory = await mkdtemp(join(tmpdir(), "opencode-httpapi-codegen-"))
try {
await Promise.all(output.files.map((file) => Bun.write(join(directory, file.path), file.content)))
const generated = await import(`${join(directory, "index.ts")}?t=${crypto.randomUUID()}`)
let request: Request | undefined
const client = generated.OpenCode.make({
baseUrl: "https://example.com",
fetch: async (input: RequestInfo | URL) => {
request = input instanceof Request ? input : new Request(input)
return new Response(new Uint8Array([1, 2, 3]))
},
})
const result = await client.session.read({ path: "src/a b#c.ts", token: "x/y" })
expect(result).toBeInstanceOf(Uint8Array)
expect(Array.from(result)).toEqual([1, 2, 3])
expect(request?.method).toBe("GET")
expect(request?.url).toBe("https://example.com/file/src/a%20b%23c.ts?token=x%2Fy")
} finally {
await rm(directory, { recursive: true, force: true })
}
})
test("serializes flattened query, header, and JSON payload inputs", async () => {
const output = emitPromise(
compileContract(

View file

@ -84,6 +84,7 @@
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/protocol": "workspace:*",

View file

@ -3,6 +3,7 @@ import { UI } from "@/cli/ui"
import { errorMessage } from "@opencode-ai/tui/util/error"
import { validateSession } from "../tui/validate-session"
import { ServerAuth } from "@/server/auth"
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
export const AttachCommand = cmd({
@ -134,6 +135,7 @@ export const AttachCommand = cmd({
await Effect.runPromise(
run({
client: createOpencodeClient({ baseUrl: args.url, headers, directory }),
api: OpenCode.make({ baseUrl: args.url, headers }),
config,
pluginHost: createLegacyTuiPluginHost(),
args: {

View file

@ -8,6 +8,7 @@ import { errorMessage } from "@opencode-ai/tui/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
import { Filesystem } from "@/util/filesystem"
import { OpenCode } from "@opencode-ai/client"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { writeHeapSnapshot } from "v8"
import { validateSession } from "../tui/validate-session"
@ -205,6 +206,7 @@ export const TuiThreadCommand = cmd({
await Effect.runPromise(
run({
client: createOpencodeClient({ baseUrl: url, directory: cwd }),
api: OpenCode.make({ baseUrl: url }),
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)

View file

@ -48,7 +48,7 @@ import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
@ -74,7 +74,7 @@ export const AppLayer = Layer.mergeAll(
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
BackgroundJob.defaultLayer,
Job.defaultLayer,
RuntimeFlags.defaultLayer,
EventV2Bridge.defaultLayer,
SessionRunState.defaultLayer,

View file

@ -1,32 +1,34 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { BackgroundJob as CoreBackgroundJob } from "@opencode-ai/core/background-job"
import { Service, make } from "@opencode-ai/core/job"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer } from "effect"
export {
Service,
type ExtendInput,
type BackgroundAllInput,
type BlockInput,
type BlockResult,
type Info,
type Interface,
type StartInput,
type Status,
type WaitInput,
type WaitResult,
} from "@opencode-ai/core/background-job"
} from "@opencode-ai/core/job"
/** Keeps the legacy service instance-scoped while sharing the core registry engine. */
export const layer = Layer.effect(
CoreBackgroundJob.Service,
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make(() => CoreBackgroundJob.make)
return CoreBackgroundJob.Service.of({
const state = yield* InstanceState.make(() => make)
return Service.of({
list: () => InstanceState.useEffect(state, (jobs) => jobs.list()),
get: (id) => InstanceState.useEffect(state, (jobs) => jobs.get(id)),
start: (input) => InstanceState.useEffect(state, (jobs) => jobs.start(input)),
extend: (input) => InstanceState.useEffect(state, (jobs) => jobs.extend(input)),
wait: (input) => InstanceState.useEffect(state, (jobs) => jobs.wait(input)),
waitForPromotion: (id) => InstanceState.useEffect(state, (jobs) => jobs.waitForPromotion(id)),
promote: (id) => InstanceState.useEffect(state, (jobs) => jobs.promote(id)),
block: (input) => InstanceState.useEffect(state, (jobs) => jobs.block(input)),
background: (id) => InstanceState.useEffect(state, (jobs) => jobs.background(id)),
backgroundAll: (input) => InstanceState.useEffect(state, (jobs) => jobs.backgroundAll(input)),
cancel: (id) => InstanceState.useEffect(state, (jobs) => jobs.cancel(id)),
})
}),
@ -34,6 +36,6 @@ export const layer = Layer.effect(
export const defaultLayer = layer
export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] })
export const node = LayerNode.make({ service: Service, layer, deps: [] })
export * as BackgroundJob from "./job"
export * as Job from "./job"

View file

@ -1,6 +1,6 @@
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { RuntimeFlags } from "@/effect/runtime-flags"
@ -33,7 +33,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const sessions = yield* Session.Service
const background = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const flags = yield* RuntimeFlags.Service
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
@ -159,15 +159,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
params: { sessionID: SessionID }
}) {
if (!flags.experimentalBackgroundSubagents) return false
const jobs = (yield* background.list()).filter(
(job) =>
job.type === "task" &&
job.status === "running" &&
job.metadata?.parentSessionId === ctx.params.sessionID &&
job.metadata.background !== true,
)
const promoted = yield* Effect.forEach(jobs, (job) => background.promote(job.id), { concurrency: "unbounded" })
return promoted.some((job) => job !== undefined)
return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0
})
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {

View file

@ -7,7 +7,7 @@ import * as Observability from "@opencode-ai/core/observability"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Command } from "@/command"
import { Config } from "@/config/config"
import { Workspace } from "@/control-plane/workspace"
@ -233,7 +233,7 @@ const app = LayerNode.group([
Session.node,
SessionProjector.node,
SessionStatus.node,
BackgroundJob.node,
Job.node,
RuntimeFlags.node,
EventV2Bridge.node,
SessionRunState.node,

View file

@ -521,7 +521,7 @@ export const layer = Layer.effect(
const cfg = yield* config.get()
const sh = ShellSelect.preferred(cfg.shell)
const args = ShellSelect.args(sh, input.command, cwd)
const args = ShellSelect.args(sh, input.command)
let output = ""
let aborted = false

View file

@ -2,7 +2,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceState } from "@/effect/instance-state"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Runner } from "@/effect/runner"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Effect, Latch, Layer, Scope, Context } from "effect"
import { Session } from "./session"
import { SessionID } from "./schema"
@ -29,7 +29,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const background = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const status = yield* SessionStatus.Service
const state = yield* InstanceState.make(
@ -75,7 +75,7 @@ export const layer = Layer.effect(
})
const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) {
yield* cancelBackgroundJobs(background, sessionID)
yield* cancelJobs(jobs, sessionID)
const data = yield* InstanceState.get(state)
const existing = data.runners.get(sessionID)
if (!existing) {
@ -108,31 +108,25 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(Job.defaultLayer), Layer.provide(SessionStatus.defaultLayer))
const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* (
background: BackgroundJob.Interface,
sessionID: SessionID,
) {
const jobs = yield* background.list()
const cancelJobs = Effect.fn("SessionRunState.cancelJobs")(function* (jobs: Job.Interface, sessionID: SessionID) {
const running = yield* jobs.list()
const pending = new Set<string>([sessionID])
const cancelled = new Set<string>()
const matches = (job: BackgroundJob.Info) => {
const matches = (job: Job.Info) => {
if (job.status !== "running") return false
if (cancelled.has(job.id)) return false
if (pending.has(job.id)) return true
if (typeof job.metadata?.sessionId === "string" && pending.has(job.metadata.sessionId)) return true
return typeof job.metadata?.parentSessionId === "string" && pending.has(job.metadata.parentSessionId)
}
let batch = jobs.filter(matches)
let batch = running.filter(matches)
while (batch.length > 0) {
yield* Effect.forEach(
batch,
(job) =>
background.cancel(job.id).pipe(
jobs.cancel(job.id).pipe(
Effect.tap(() =>
Effect.sync(() => {
cancelled.add(job.id)
@ -143,7 +137,7 @@ const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(f
),
{ concurrency: "unbounded", discard: true },
)
batch = jobs.filter(matches)
batch = running.filter(matches)
}
})
@ -151,6 +145,6 @@ function busyError(sessionID: SessionID) {
return new Session.BusyError({ sessionID })
}
export const node = LayerNode.make({ service: Service, layer: layer, deps: [BackgroundJob.node, SessionStatus.node] })
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Job.node, SessionStatus.node] })
export * as SessionRunState from "./run-state"

View file

@ -4,7 +4,7 @@ import { Slug } from "@opencode-ai/core/util/slug"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Decimal } from "decimal.js"
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@ -491,13 +491,13 @@ export type Patch = Omit<Partial<Info>, "time" | "share" | "summary" | "revert"
export const layer: Layer.Layer<
Service,
never,
BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
Job.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service
> = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const database = yield* Database.Service
const background = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
@ -618,7 +618,7 @@ export const layer: Layer.Layer<
Effect.catchCause(() => Effect.succeed(false)),
)
if (hasInstance) yield* cancelBackgroundJobs(background, sessionID)
if (hasInstance) yield* cancelJobs(jobs, sessionID)
const kids = yield* children(sessionID)
for (const child of kids) {
yield* remove(child.id)
@ -941,7 +941,7 @@ export const layer: Layer.Layer<
)
export const defaultLayer = layer.pipe(
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Job.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(
@ -953,19 +953,16 @@ export const defaultLayer = layer.pipe(
Layer.provide(RuntimeFlags.defaultLayer),
)
const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* (
background: BackgroundJob.Interface,
sessionID: SessionID,
) {
const jobs = yield* background.list()
const cancelJobs = Effect.fn("Session.cancelJobs")(function* (jobs: Job.Interface, sessionID: SessionID) {
const running = yield* jobs.list()
yield* Effect.forEach(
jobs.filter((job) => {
running.filter((job) => {
if (job.status !== "running") return false
if (job.id === sessionID) return true
if (job.metadata?.sessionId === sessionID) return true
return job.metadata?.parentSessionId === sessionID
}),
(job) => background.cancel(job.id),
(job) => jobs.cancel(job.id),
{ concurrency: "unbounded", discard: true },
)
})
@ -1098,7 +1095,7 @@ export function* listGlobal(input?: {
export const node = LayerNode.make({
service: Service,
layer: layer,
deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
deps: [Job.node, RuntimeFlags.node, Database.node, EventV2Bridge.node],
})
export * as Session from "./session"

View file

@ -48,7 +48,7 @@ import { EventV2Bridge } from "@/event-v2-bridge"
import { Agent } from "../agent/agent"
import { Skill } from "../skill"
import { Permission } from "@/permission"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@ -325,7 +325,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Job.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(Instruction.defaultLayer),
@ -426,7 +426,7 @@ export const node = LayerNode.make({
Agent.node,
Skill.node,
Session.node,
BackgroundJob.node,
Job.node,
Provider.node,
LSP.node,
Instruction.node,

View file

@ -2,7 +2,7 @@ import * as Tool from "./tool"
import DESCRIPTION from "./task.txt"
import { ToolJsonSchema } from "./json-schema"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Session } from "@/session/session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
@ -33,11 +33,11 @@ const BACKGROUND_STARTED = [
"DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
].join("\n")
const BACKGROUND_UPDATED = [
"Additional context sent to the running background task.",
const BACKGROUND_ALREADY_RUNNING = [
"The task is already working in the background.",
"The task is still working in the background. You will be notified automatically when it finishes.",
"DO NOT sleep, poll for progress, ask the task for status, or duplicate this task's work — avoid working with the same files or topics it is using.",
"Work on non-overlapping tasks, or briefly tell the user what you sent and end your response.",
"Work on non-overlapping tasks, or briefly tell the user it is still running and end your response.",
].join("\n")
const BaseParameterFields = {
@ -82,7 +82,7 @@ export const TaskTool = Tool.define(
id,
Effect.gen(function* () {
const agent = yield* Agent.Service
const background = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const scope = yield* Scope.Scope
@ -229,7 +229,7 @@ export const TaskTool = Tool.define(
})
const notify = Effect.fn("TaskTool.notifyBackgroundResult")(function* (jobID: string) {
yield* background.wait({ id: jobID }).pipe(
yield* jobs.wait({ id: jobID }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed") return inject("completed", result.info.output ?? "")
if (result.info?.status === "error") return inject("error", result.info.error ?? "")
@ -239,7 +239,8 @@ export const TaskTool = Tool.define(
)
})
if (yield* background.extend({ id: nextSession.id, run: runTask() })) {
const existing = yield* jobs.get(nextSession.id)
if (existing?.status === "running") {
return {
title: params.description,
metadata: {
@ -250,24 +251,17 @@ export const TaskTool = Tool.define(
output: renderOutput({
sessionID: nextSession.id,
state: "running",
summary: "Background task updated",
text: BACKGROUND_UPDATED,
summary: "Background task already running",
text: BACKGROUND_ALREADY_RUNNING,
}),
}
}
const info = yield* background.start({
const info = yield* jobs.start({
id: nextSession.id,
type: id,
title: params.description,
metadata,
onPromote: Effect.all([
ctx.metadata({
title: params.description,
metadata: { ...metadata, background: true, jobId: nextSession.id },
}),
notify(nextSession.id),
]),
run: runTask().pipe(Effect.onInterrupt(() => ops.cancel(nextSession.id))),
})
@ -289,6 +283,7 @@ export const TaskTool = Tool.define(
}
if (runInBackground) {
yield* jobs.background(info.id)
yield* notify(info.id)
return backgroundResult()
}
@ -306,23 +301,27 @@ export const TaskTool = Tool.define(
}),
() =>
Effect.gen(function* () {
const result = yield* Effect.raceFirst(
background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)),
background.waitForPromotion(nextSession.id),
)
if (result?.metadata?.background === true) return backgroundResult()
if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed"))
if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled"))
const result = yield* jobs.block({ id: nextSession.id, sessionID: ctx.sessionID })
if (result?.type === "backgrounded") {
yield* ctx.metadata({
title: params.description,
metadata: { ...metadata, background: true, jobId: nextSession.id },
})
yield* notify(nextSession.id)
return backgroundResult()
}
if (result?.info.status === "error")
return yield* Effect.fail(new Error(result.info.error ?? "Task failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled"))
return {
title: params.description,
metadata,
output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }),
output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.info.output ?? "" }),
}
}),
(_, exit) =>
Effect.gen(function* () {
if (Exit.hasInterrupts(exit))
yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true })
if (Exit.hasInterrupts(exit)) yield* Effect.all([cancel, jobs.cancel(nextSession.id)], { discard: true })
}).pipe(
Effect.ensuring(
Effect.sync(() => {

View file

@ -172,7 +172,7 @@ Wait on a **published readiness signal**, not wall-clock time. Available afforda
- `awaitWithTimeout(effect, message, duration?)` from `test/lib/effect.ts` — wrap any effect with `Effect.timeoutOrElse` and a custom error message.
- `llm.wait(n)` from `test/lib/llm-server.ts` — wait until the mock LLM has received `n` HTTP calls.
- `SessionStatus.Service` `.get(sessionID)` — observable per-session state (`{ type: "busy" | "idle" | ... }`).
- `BackgroundJob.wait({ id, timeout })` from `src/background/job.ts` — wait for a background job to complete.
- `Job.wait({ id, timeout })` from `src/job.ts` — wait for a job to complete.
- Bus subscriptions — fork `Stream.runForEach(bus.subscribe(Event), ...)` and open a `Latch` inside the callback to signal first-event readiness.
- `Deferred.await(deferred).pipe(Effect.timeoutOrElse(...))` for one-shot signals.

View file

@ -1,243 +0,0 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect } from "effect"
import { BackgroundJob } from "@/background/job"
import { testEffect } from "../lib/effect"
const it = testEffect(BackgroundJob.defaultLayer)
describe("background.job", () => {
it.instance("tracks started jobs through completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
title: "test job",
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job.id.startsWith("job_")).toBe(true)
expect(job.status).toBe("running")
expect(job.title).toBe("test job")
yield* Deferred.succeed(latch, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.timedOut).toBe(false)
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("done")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id])
}),
)
it.instance("returns a running snapshot when wait times out", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
run: Effect.never,
})
const result = yield* jobs.wait({ id: job.id, timeout: 1 })
expect(result.timedOut).toBe(true)
expect(result.info?.status).toBe("running")
}),
)
it.instance("deduplicates concurrent starts for a running id", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const started = yield* Deferred.make<void>()
const id = "job_test"
const [first, second] = yield* Effect.all(
[
jobs.start({
id,
type: "test",
run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
jobs.start({
id,
type: "test",
run: Effect.fail(new Error("duplicate started")),
}),
],
{ concurrency: "unbounded" },
)
yield* Deferred.await(started)
expect(first.id).toBe(id)
expect(second.id).toBe(id)
expect(first.status).toBe("running")
expect(second.status).toBe("running")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([id])
yield* jobs.cancel(id)
}),
)
it.instance("waits for extensions before completing a running job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const second = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Deferred.await(first).pipe(Effect.as("first")),
})
expect(yield* jobs.extend({ id: job.id, run: Deferred.await(second).pipe(Effect.as("second")) })).toBe(true)
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(second, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("second")
}),
)
it.instance("runs extensions after earlier work completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const order: string[] = []
const job = yield* jobs.start({
type: "test",
run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
})
expect(
yield* jobs.extend({
id: job.id,
run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
}),
).toBe(true)
yield* Effect.yieldNow
expect(order).toEqual(["start"])
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
expect(order).toEqual(["start", "extend"])
}),
)
it.instance("rejects extensions after a job completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({ type: "test", run: Effect.succeed("done") })
yield* jobs.wait({ id: job.id })
expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("late") })).toBe(false)
expect((yield* jobs.get(job.id))?.output).toBe("done")
}),
)
it.instance("records failed jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
run: Effect.fail(new Error("boom")),
})
const result = yield* jobs.wait({ id: job.id })
expect(result.info?.status).toBe("error")
expect(result.info?.error).toBe("boom")
}),
)
it.instance("ignores stale settlements after restarting a failed job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const fail = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const id = "job_test"
yield* jobs.start({
id,
type: "test",
run: Deferred.await(fail).pipe(Effect.andThen(Effect.fail(new Error("boom")))),
})
yield* jobs.extend({
id,
run: Effect.never.pipe(
Effect.ensuring(Deferred.succeed(interrupted, undefined).pipe(Effect.andThen(Deferred.await(release)))),
),
})
yield* Deferred.succeed(fail, undefined)
expect((yield* jobs.wait({ id })).info?.status).toBe("error")
yield* Deferred.await(interrupted)
yield* jobs.start({ id, type: "test", run: Effect.never })
yield* Deferred.succeed(release, undefined)
yield* Effect.yieldNow
expect((yield* jobs.get(id))?.status).toBe("running")
yield* jobs.cancel(id)
}),
)
it.instance("can cancel running jobs", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* jobs.extend({
id: job.id,
run: Effect.never,
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("promotes running jobs without interrupting them", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const promoted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { parentSessionId: "parent" },
onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
run: Deferred.await(latch).pipe(Effect.as("done")),
})
const info = yield* jobs.promote(job.id)
expect(info?.status).toBe("running")
expect(info?.metadata?.background).toBe(true)
yield* Deferred.await(promoted)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const job = yield* jobs.start({
type: "test",
metadata: { value: "initial" },
run: Effect.succeed("done"),
})
if (job.metadata) job.metadata.value = "changed"
expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial")
}),
)
})

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { Agent } from "@opencode-ai/schema"
import { EventManifest as SchemaEventManifest } from "@opencode-ai/schema/event-manifest"
import { Todo } from "@/session/todo"
import { EventManifest } from "@/event-manifest"
@ -9,8 +10,9 @@ describe("public event manifest", () => {
expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
expect(EventManifest.Latest.size).toBe(88)
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
expect(EventManifest.Latest.has("server.connected")).toBe(true)

View file

@ -0,0 +1,131 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber } from "effect"
import { Job } from "@/job"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(Job.defaultLayer)
describe("job", () => {
it.instance("tracks started jobs through completion", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
title: "test job",
run: Deferred.await(latch).pipe(Effect.as("done")),
})
expect(job.id.startsWith("job_")).toBe(true)
expect(job.status).toBe("running")
expect(job.title).toBe("test job")
yield* Deferred.succeed(latch, undefined)
const done = yield* jobs.wait({ id: job.id })
expect(done.timedOut).toBe(false)
expect(done.info?.status).toBe("completed")
expect(done.info?.output).toBe("done")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([job.id])
}),
)
it.instance("returns a running snapshot when wait times out", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "test", run: Effect.never })
const result = yield* jobs.wait({ id: job.id, timeout: 1 })
expect(result.timedOut).toBe(true)
expect(result.info?.status).toBe("running")
}),
)
it.instance("deduplicates concurrent starts for a running id", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const started = yield* Deferred.make<void>()
const id = "job_test"
const [first, second] = yield* Effect.all(
[
jobs.start({
id,
type: "test",
run: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
jobs.start({ id, type: "test", run: Effect.fail(new Error("duplicate started")) }),
],
{ concurrency: "unbounded" },
)
yield* Deferred.await(started)
expect(first.id).toBe(id)
expect(second.id).toBe(id)
expect(first.status).toBe("running")
expect(second.status).toBe("running")
expect((yield* jobs.list()).map((item) => item.id)).toEqual([id])
yield* jobs.cancel(id)
}),
)
it.instance("records failed jobs", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "test", run: Effect.fail(new Error("boom")) })
const result = yield* jobs.wait({ id: job.id })
expect(result.info?.status).toBe("error")
expect(result.info?.error).toBe("boom")
}),
)
it.instance("can cancel running jobs", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const interrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("releases blocking waits when backgrounded", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const latch = yield* Deferred.make<void>()
const job = yield* jobs.start({ type: "test", run: Deferred.await(latch).pipe(Effect.as("done")) })
const waiting = yield* jobs
.block({ id: job.id, sessionID: SessionSchema.ID.make("ses_parent") })
.pipe(Effect.forkChild)
expect(yield* jobs.background(job.id)).toMatchObject({ id: job.id, status: "running" })
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const job = yield* jobs.start({ type: "test", metadata: { value: "initial" }, run: Effect.succeed("done") })
if (job.metadata) job.metadata.value = "changed"
expect((yield* jobs.get(job.id))?.metadata?.value).toBe("initial")
}),
)
})

View file

@ -12,7 +12,7 @@ import { testEffect } from "../lib/effect"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
const layer = (experimentalWorkspaces: boolean) =>
Layer.mergeAll(
@ -24,7 +24,7 @@ const layer = (experimentalWorkspaces: boolean) =>
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Job.defaultLayer),
),
)
const it = testEffect(layer(false))

View file

@ -11,7 +11,7 @@ import path from "path"
import { fileURLToPath } from "url"
import { NamedError } from "@opencode-ai/core/util/error"
import { Agent as AgentSvc } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { Command } from "../../src/command"
import { Config } from "@/config/config"
import { LSP } from "@/lsp/lsp"
@ -183,7 +183,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces
lsp,
makeMcp(input?.mcpInstructions),
FSUtil.defaultLayer,
BackgroundJob.defaultLayer,
Job.defaultLayer,
status,
Database.defaultLayer,
EventV2Bridge.defaultLayer,

View file

@ -12,7 +12,7 @@ import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixtur
import { testEffect } from "../lib/effect"
import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { GlobalBus } from "@/bus/global"
@ -24,7 +24,7 @@ const it = testEffect(
Layer.provideMerge(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Job.defaultLayer),
),
CrossSpawnSpawner.defaultLayer,
testInstanceStoreLayer,

View file

@ -3,7 +3,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Job } from "@/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@ -19,7 +19,7 @@ import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { pollWithTimeout, testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@ -35,7 +35,7 @@ const ref = {
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
BackgroundJob.defaultLayer,
Job.defaultLayer,
EventV2Bridge.defaultLayer,
Config.defaultLayer,
CrossSpawnSpawner.defaultLayer,
@ -480,9 +480,9 @@ describe("tool.task", () => {
}),
)
it.instance("promotes a running foreground task without restarting it", () =>
it.instance("backgrounds a running foreground task without restarting it", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@ -531,7 +531,12 @@ describe("tool.task", () => {
expect(job).toBeDefined()
if (!job) throw new Error("task job not found")
expect(job.metadata?.parentSessionId).toBe(chat.id)
yield* jobs.promote(job.id)
yield* pollWithTimeout(
jobs
.backgroundAll({ sessionID: chat.id, type: "task" })
.pipe(Effect.map((backgrounded) => (backgrounded.length > 0 ? backgrounded : undefined))),
"task never blocked the parent session",
)
const result = yield* Fiber.join(fiber)
expect(result.metadata.background).toBe(true)
@ -548,7 +553,7 @@ describe("tool.task", () => {
background.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@ -584,15 +589,13 @@ describe("tool.task", () => {
}),
)
background.instance("background task completion waits for running updates", () =>
background.instance("running task_id reports the existing background task", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const first = defer<void>()
const second = defer<void>()
const updated = defer<SessionPrompt.PromptInput>()
const injected = defer<SessionPrompt.PromptInput>()
let prompts = 0
const promptOps: TaskPromptOps = {
@ -603,9 +606,7 @@ describe("tool.task", () => {
return Effect.succeed(reply(input, "done"))
}
prompts++
if (prompts === 1) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
updated.resolve(input)
return Effect.promise(() => second.promise).pipe(Effect.as(reply(input, "second done")))
return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
},
}
const context = {
@ -640,27 +641,22 @@ describe("tool.task", () => {
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain("Background task updated")
expect(result.output).toContain("Background task already running")
expect(prompts).toBe(1)
first.resolve()
expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
second.resolve()
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
expect(waited.info?.status).toBe("completed")
expect(waited.info?.output).toBe("second done")
expect(waited.info?.output).toBe("first done")
const notification = yield* Effect.promise(() => injected.promise)
expect(notification.variant).toBe("xhigh")
expect(notification.parts[0]?.type).toBe("text")
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done")
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("first done")
}),
)
background.instance("background tasks complete through the background job service", () =>
background.instance("background tasks complete through the job service", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@ -693,7 +689,7 @@ describe("tool.task", () => {
background.instance("background task completion does not wait for the parent async prompt", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@ -731,7 +727,7 @@ describe("tool.task", () => {
background.instance("removing the parent session cancels running background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
@ -770,7 +766,7 @@ describe("tool.task", () => {
background.instance("removing the child task session cancels its running background task", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
@ -809,7 +805,7 @@ describe("tool.task", () => {
background.instance("cancelling the parent run cancels running background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
@ -848,7 +844,7 @@ describe("tool.task", () => {
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()
@ -869,7 +865,7 @@ describe("tool.task", () => {
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const jobs = yield* Job.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()

View file

@ -21,7 +21,9 @@ import { ReferenceGroup } from "./groups/reference"
import { Authorization } from "./middleware/authorization"
import { LocationGroup } from "./groups/location"
import { IntegrationGroup } from "./groups/integration"
import { McpGroup } from "./groups/mcp"
import { CredentialGroup } from "./groups/credential"
import { ProjectGroup } from "./groups/project"
import { ProjectCopyGroup } from "./groups/project-copy"
// Protocol owns middleware placement, while Server injects concrete keys so Core service identities stay downstream.
@ -46,7 +48,9 @@ const makeApiFromGroup = <
.add(GenerateGroup.middleware(locationMiddleware))
.add(ProviderGroup.middleware(locationMiddleware))
.add(IntegrationGroup.middleware(locationMiddleware))
.add(McpGroup.middleware(locationMiddleware))
.add(CredentialGroup.middleware(locationMiddleware))
.add(ProjectGroup.middleware(locationMiddleware))
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
.add(FileSystemGroup.middleware(locationMiddleware))
.add(CommandGroup.middleware(locationMiddleware))

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