Compare commits

...

30 Commits

Author SHA1 Message Date
Aiden Cline b934e6f3c0 fix(core): expect universal default permissions in agent tests
Agent.Info.empty() seeds every agent with baseline { *: allow } and { external_directory: ask } permissions (matching v1, where defaults are merged into every native and custom agent). Update ConfigAgentPlugin tests to expect those defaults, remove a stray debug log left in the config agent plugin, and regenerate the client SDK for the agent.updated event.
2026-06-30 00:32:50 -05:00
opencode-agent[bot] 524ee8fc03 fix(core): gate v2 edit tools by model (#34558)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-29 23:53:41 -05:00
Dax Raad 0f9719a7b5 fix(cli): support service autostart setting 2026-06-30 00:28:11 -04:00
Dax Raad 1b83c08b8a Update service configuration CLI 2026-06-30 00:18:26 -04:00
Dax Raad 02cb350880 feat(tui): refresh agents after update events 2026-06-30 00:18:26 -04:00
opencode-agent[bot] 4ce830a919 fix(core): align v2 prompt tool names (#34557)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-29 22:53:56 -05:00
Kit Langton 461a1c3ab4 refactor(core): replace background job service (#34559) 2026-06-29 23:53:35 -04:00
Kit Langton 6846542115 fix(client): singularize generated api groups (#34534) 2026-06-29 21:07:01 -04:00
Aiden Cline f928b5be07 fix(core): sanitize registered tool names (#34512) 2026-06-29 18:30:40 -05:00
Kit Langton ecfa918760 feat(client): expose fs read in promise client (#34504) 2026-06-29 19:21:18 -04:00
Aiden Cline f80624cf17 fix(tui): surface provider error in assistant footer (#34511) 2026-06-29 16:46:50 -05:00
Dax Raad fe59174c23 fix(core): resume after synthetic session message 2026-06-29 17:32:54 -04:00
Dax Raad 2fe057324f fix(core): provide filesystem to shell tool 2026-06-29 17:27:24 -04:00
Dax Raad 19a5b5a05d feat(core): support background shell tool 2026-06-29 17:01:04 -04:00
Dax Raad ff4cab03c1 refactor(core): simplify session fork event 2026-06-29 16:16:13 -04:00
Dax Raad b2d46ecd7e feat(core): add durable session fork event 2026-06-29 16:13:51 -04:00
Dax Raad 360d85a521 fix(tui): indent subagent empty state 2026-06-29 15:46:51 -04:00
Dax Raad c65a7d50c1 feat(tui): integrate composer picker 2026-06-29 15:37:09 -04:00
Dax Raad fa73546a86 feat(tui): add composer tabs 2026-06-29 14:20:43 -04:00
Kit Langton 935ac2db91 feat(client): expose v2 project APIs (#34456) 2026-06-29 14:10:11 -04:00
Dax Raad 01edae4a7f fix(core): spawn shell non-interactively without sourcing rc files
ShellSelect.args() ran zsh/bash with -l and explicitly sourced .zshrc/.bashrc,
loading user functions and aliases that can shadow builtins and return non-zero
exit codes, breaking && chains. Match the old tool behavior: plain non-login
non-interactive shell -c command with cwd passed via spawn options.
2026-06-29 12:05:56 -04:00
Kit Langton 381d67572e refactor(tui): wire generated client reads (#34381) 2026-06-29 11:36:10 -04:00
Dax Raad e8ac44430b feat(cli): poll for updates every 10 minutes in serve
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-06-29 00:59:01 -04:00
Dax Raad 5ae93092aa refactor(core): replace bash tool with shell tool 2026-06-29 00:44:17 -04:00
Kit Langton 595c6bd4a7 feat: add v2 generate text endpoint (#34371) 2026-06-28 22:43:58 -04:00
Kit Langton 7073e8797f fix(core): remove per-prompt system option (#34361) 2026-06-28 21:56:59 -04:00
Kit Langton f7034a35a8 feat(core): add v2 manual compaction (#34336) 2026-06-28 21:18:19 -04:00
Kit Langton 53b93b6991 feat(sdk-next): let embedders contribute plugins via opencode.plugin (#34356) 2026-06-28 20:30:21 -04:00
opencode-agent[bot] 6067019434 fix(core): restore v2 model prompts (#34275)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-28 14:47:20 -05:00
Dax 42a3cf9645 fix(core): commit staged revert before admitting new prompt (#34338) 2026-06-28 14:50:06 -04:00
165 changed files with 9927 additions and 3227 deletions
+3
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:*",
@@ -581,6 +582,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:*",
@@ -932,6 +934,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:*",
+5 -5
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.
+1
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:*",
+13 -5
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)),
},
}),
+16 -5
View File
@@ -1,7 +1,7 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect"
import { Context, Layer, Option, Schedule } from "effect"
import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http"
@@ -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,20 +22,30 @@ 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.forkScoped)
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
return yield* (input.stdio ? waitForStdinClose() : Effect.never)
}).pipe(Effect.annotateLogs({ role: "server" })),
)
@@ -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)
}),
)
@@ -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)
}),
)
@@ -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)
}),
)
+3 -1
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"),
})
+136 -19
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
@@ -41,32 +57,114 @@ export const layer = Layer.effect(
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 file = path.join(directory, InstallationChannel === "local" ? "service-local.json" : "service.json")
const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
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 +181,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 +239,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 +255,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 +307,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
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"
+10 -9
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: {},
+5 -4
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)),
),
],
+21 -17
View File
@@ -19,22 +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.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.agent": "agent",
"server.session": "session",
"server.message": "message",
"server.model": "model",
"server.generate": "generate",
"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 = {
@@ -50,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"])
+421 -308
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,23 @@ 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) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint3_9Input = { readonly sessionID: Endpoint3_9Request["params"]["sessionID"] }
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
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_11Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint3_11Input = {
readonly sessionID: Endpoint3_11Request["params"]["sessionID"]
readonly messageID: Endpoint3_11Request["payload"]["messageID"]
readonly files?: Endpoint3_11Request["payload"]["files"]
}
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] },
@@ -158,42 +169,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_12Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[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))
raw["session.revert.clear"]({ 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.commit"]>[0]
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
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_15Request = Parameters<RawClient["server.session"]["session.history"]>[0]
type Endpoint3_15Input = {
readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly limit?: Endpoint3_15Request["query"]["limit"]
readonly after?: Endpoint3_15Request["query"]["after"]
}
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
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_16Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint3_16Input = {
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
readonly after?: Endpoint3_16Request["query"]["after"]
}
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
Stream.unwrap(
raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe(
Effect.mapError(mapClientError),
@@ -201,17 +212,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_17Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint3_17Input = { readonly sessionID: Endpoint3_17Request["params"]["sessionID"] }
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
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_18Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint3_18Input = {
readonly sessionID: Endpoint3_18Request["params"]["sessionID"]
readonly messageID: Endpoint3_18Request["params"]["messageID"]
}
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -222,20 +233,21 @@ 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),
compact: Endpoint3_9(raw),
wait: Endpoint3_10(raw),
stage: Endpoint3_11(raw),
clear: Endpoint3_12(raw),
commit: Endpoint3_13(raw),
context: Endpoint3_14(raw),
history: Endpoint3_15(raw),
events: Endpoint3_16(raw),
interrupt: Endpoint3_17(raw),
message: Endpoint3_18(raw),
})
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@@ -260,169 +272,207 @@ const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Inpu
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
type Endpoint6_0Request = Parameters<RawClient["server.generate"]["generate.text"]>[0]
type Endpoint6_0Input = {
readonly location?: Endpoint6_0Request["query"]["location"]
readonly prompt: Endpoint6_0Request["payload"]["prompt"]
readonly model?: Endpoint6_0Request["payload"]["model"]
}
const Endpoint6_0 = (raw: RawClient["server.generate"]) => (input: Endpoint6_0Input) =>
raw["generate.text"]({
query: { location: input["location"] },
payload: { prompt: input["prompt"], model: input["model"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
const adaptGroup6 = (raw: RawClient["server.generate"]) => ({ text: Endpoint6_0(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint7_0Input) =>
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint6_1Input = {
readonly providerID: Endpoint6_1Request["params"]["providerID"]
readonly location?: Endpoint6_1Request["query"]["location"]
type Endpoint7_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint7_1Input = {
readonly providerID: Endpoint7_1Request["params"]["providerID"]
readonly location?: Endpoint7_1Request["query"]["location"]
}
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
const Endpoint7_1 = (raw: RawClient["server.provider"]) => (input: Endpoint7_1Input) =>
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
const adaptGroup7 = (raw: RawClient["server.provider"]) => ({ list: Endpoint7_0(raw), get: Endpoint7_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
type Endpoint8_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] }
const Endpoint8_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint8_0Input) =>
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint7_1Input = {
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
readonly location?: Endpoint7_1Request["query"]["location"]
type Endpoint8_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint8_1Input = {
readonly integrationID: Endpoint8_1Request["params"]["integrationID"]
readonly location?: Endpoint8_1Request["query"]["location"]
}
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
const Endpoint8_1 = (raw: RawClient["server.integration"]) => (input: Endpoint8_1Input) =>
raw["integration.get"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint7_2Input = {
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
readonly location?: Endpoint7_2Request["query"]["location"]
readonly key: Endpoint7_2Request["payload"]["key"]
readonly label?: Endpoint7_2Request["payload"]["label"]
type Endpoint8_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint8_2Input = {
readonly integrationID: Endpoint8_2Request["params"]["integrationID"]
readonly location?: Endpoint8_2Request["query"]["location"]
readonly key: Endpoint8_2Request["payload"]["key"]
readonly label?: Endpoint8_2Request["payload"]["label"]
}
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
const Endpoint8_2 = (raw: RawClient["server.integration"]) => (input: Endpoint8_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { key: input["key"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
type Endpoint7_3Input = {
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
readonly location?: Endpoint7_3Request["query"]["location"]
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
readonly label?: Endpoint7_3Request["payload"]["label"]
type Endpoint8_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
type Endpoint8_3Input = {
readonly integrationID: Endpoint8_3Request["params"]["integrationID"]
readonly location?: Endpoint8_3Request["query"]["location"]
readonly methodID: Endpoint8_3Request["payload"]["methodID"]
readonly inputs: Endpoint8_3Request["payload"]["inputs"]
readonly label?: Endpoint8_3Request["payload"]["label"]
}
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
const Endpoint8_3 = (raw: RawClient["server.integration"]) => (input: Endpoint8_3Input) =>
raw["integration.connect.oauth"]({
params: { integrationID: input["integrationID"] },
query: { location: input["location"] },
payload: { methodID: input["methodID"], inputs: input["inputs"], label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint7_4Input = {
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
readonly location?: Endpoint7_4Request["query"]["location"]
type Endpoint8_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint8_4Input = {
readonly attemptID: Endpoint8_4Request["params"]["attemptID"]
readonly location?: Endpoint8_4Request["query"]["location"]
}
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
const Endpoint8_4 = (raw: RawClient["server.integration"]) => (input: Endpoint8_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint7_5Input = {
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
readonly location?: Endpoint7_5Request["query"]["location"]
readonly code?: Endpoint7_5Request["payload"]["code"]
type Endpoint8_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint8_5Input = {
readonly attemptID: Endpoint8_5Request["params"]["attemptID"]
readonly location?: Endpoint8_5Request["query"]["location"]
readonly code?: Endpoint8_5Request["payload"]["code"]
}
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
const Endpoint8_5 = (raw: RawClient["server.integration"]) => (input: Endpoint8_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
payload: { code: input["code"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
type Endpoint7_6Input = {
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
readonly location?: Endpoint7_6Request["query"]["location"]
type Endpoint8_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
type Endpoint8_6Input = {
readonly attemptID: Endpoint8_6Request["params"]["attemptID"]
readonly location?: Endpoint8_6Request["query"]["location"]
}
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
const Endpoint8_6 = (raw: RawClient["server.integration"]) => (input: Endpoint8_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input["attemptID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
list: Endpoint7_0(raw),
get: Endpoint7_1(raw),
connectKey: Endpoint7_2(raw),
connectOauth: Endpoint7_3(raw),
attemptStatus: Endpoint7_4(raw),
attemptComplete: Endpoint7_5(raw),
attemptCancel: Endpoint7_6(raw),
const adaptGroup8 = (raw: RawClient["server.integration"]) => ({
list: Endpoint8_0(raw),
get: Endpoint8_1(raw),
connectKey: Endpoint8_2(raw),
connectOauth: Endpoint8_3(raw),
attemptStatus: Endpoint8_4(raw),
attemptComplete: Endpoint8_5(raw),
attemptCancel: Endpoint8_6(raw),
})
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint8_0Input = {
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
readonly location?: Endpoint8_0Request["query"]["location"]
readonly label: Endpoint8_0Request["payload"]["label"]
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"]
}
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
const Endpoint9_0 = (raw: RawClient["server.credential"]) => (input: Endpoint9_0Input) =>
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint8_1Input = {
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
readonly location?: Endpoint8_1Request["query"]["location"]
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"]
}
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
const Endpoint9_1 = (raw: RawClient["server.credential"]) => (input: Endpoint9_1Input) =>
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
const adaptGroup9 = (raw: RawClient["server.credential"]) => ({ update: Endpoint9_0(raw), remove: Endpoint9_1(raw) })
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
type Endpoint10_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] }
const Endpoint10_0 = (raw: RawClient["server.project"]) => (input?: Endpoint10_0Input) =>
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
type Endpoint10_1Input = {
readonly projectID: Endpoint10_1Request["params"]["projectID"]
readonly location?: Endpoint10_1Request["query"]["location"]
}
const Endpoint10_1 = (raw: RawClient["server.project"]) => (input: Endpoint10_1Input) =>
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.project"]) => ({
current: Endpoint10_0(raw),
directories: Endpoint10_1(raw),
})
type Endpoint11_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_0Input) =>
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
type Endpoint11_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint11_1Input = { readonly projectID?: Endpoint11_1Request["query"]["projectID"] }
const Endpoint11_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint11_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
type Endpoint11_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint11_2Input = { readonly id: Endpoint11_2Request["params"]["id"] }
const Endpoint11_2 = (raw: RawClient["server.permission"]) => (input: Endpoint11_2Input) =>
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint9_3Input = {
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
readonly id?: Endpoint9_3Request["payload"]["id"]
readonly action: Endpoint9_3Request["payload"]["action"]
readonly resources: Endpoint9_3Request["payload"]["resources"]
readonly save?: Endpoint9_3Request["payload"]["save"]
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
readonly source?: Endpoint9_3Request["payload"]["source"]
readonly agent?: Endpoint9_3Request["payload"]["agent"]
type Endpoint11_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint11_3Input = {
readonly sessionID: Endpoint11_3Request["params"]["sessionID"]
readonly id?: Endpoint11_3Request["payload"]["id"]
readonly action: Endpoint11_3Request["payload"]["action"]
readonly resources: Endpoint11_3Request["payload"]["resources"]
readonly save?: Endpoint11_3Request["payload"]["save"]
readonly metadata?: Endpoint11_3Request["payload"]["metadata"]
readonly source?: Endpoint11_3Request["payload"]["source"]
readonly agent?: Endpoint11_3Request["payload"]["agent"]
}
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
const Endpoint11_3 = (raw: RawClient["server.permission"]) => (input: Endpoint11_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input["sessionID"] },
payload: {
@@ -439,87 +489,87 @@ const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3
Effect.map((value) => value.data),
)
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
type Endpoint11_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint11_4Input = { readonly sessionID: Endpoint11_4Request["params"]["sessionID"] }
const Endpoint11_4 = (raw: RawClient["server.permission"]) => (input: Endpoint11_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint9_5Input = {
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
readonly requestID: Endpoint9_5Request["params"]["requestID"]
type Endpoint11_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint11_5Input = {
readonly sessionID: Endpoint11_5Request["params"]["sessionID"]
readonly requestID: Endpoint11_5Request["params"]["requestID"]
}
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
const Endpoint11_5 = (raw: RawClient["server.permission"]) => (input: Endpoint11_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint9_6Input = {
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
readonly requestID: Endpoint9_6Request["params"]["requestID"]
readonly reply: Endpoint9_6Request["payload"]["reply"]
readonly message?: Endpoint9_6Request["payload"]["message"]
type Endpoint11_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint11_6Input = {
readonly sessionID: Endpoint11_6Request["params"]["sessionID"]
readonly requestID: Endpoint11_6Request["params"]["requestID"]
readonly reply: Endpoint11_6Request["payload"]["reply"]
readonly message?: Endpoint11_6Request["payload"]["message"]
}
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
const Endpoint11_6 = (raw: RawClient["server.permission"]) => (input: Endpoint11_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint9_0(raw),
listSaved: Endpoint9_1(raw),
removeSaved: Endpoint9_2(raw),
create: Endpoint9_3(raw),
list: Endpoint9_4(raw),
get: Endpoint9_5(raw),
reply: Endpoint9_6(raw),
const adaptGroup11 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint11_0(raw),
listSaved: Endpoint11_1(raw),
removeSaved: Endpoint11_2(raw),
create: Endpoint11_3(raw),
list: Endpoint11_4(raw),
get: Endpoint11_5(raw),
reply: Endpoint11_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_0Input = {
readonly location?: Endpoint10_0Request["query"]["location"]
readonly path?: Endpoint10_0Request["query"]["path"]
type Endpoint12_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint12_0Input = {
readonly location?: Endpoint12_0Request["query"]["location"]
readonly path?: Endpoint12_0Request["query"]["path"]
}
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
const Endpoint12_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint12_0Input) =>
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_1Input = {
readonly location?: Endpoint10_1Request["query"]["location"]
readonly query: Endpoint10_1Request["query"]["query"]
readonly type?: Endpoint10_1Request["query"]["type"]
readonly limit?: Endpoint10_1Request["query"]["limit"]
type Endpoint12_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint12_1Input = {
readonly location?: Endpoint12_1Request["query"]["location"]
readonly query: Endpoint12_1Request["query"]["query"]
readonly type?: Endpoint12_1Request["query"]["type"]
readonly limit?: Endpoint12_1Request["query"]["limit"]
}
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input: Endpoint10_1Input) =>
const Endpoint12_1 = (raw: RawClient["server.fs"]) => (input: Endpoint12_1Input) =>
raw["fs.find"]({
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({ list: Endpoint10_0(raw), find: Endpoint10_1(raw) })
const adaptGroup12 = (raw: RawClient["server.fs"]) => ({ list: Endpoint12_0(raw), find: Endpoint12_1(raw) })
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
type Endpoint13_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
const Endpoint13_0 = (raw: RawClient["server.command"]) => (input?: Endpoint13_0Input) =>
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
const adaptGroup13 = (raw: RawClient["server.command"]) => ({ list: Endpoint13_0(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
type Endpoint14_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint14_0Input) =>
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
const adaptGroup14 = (raw: RawClient["server.skill"]) => ({ list: Endpoint14_0(raw) })
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
const Endpoint15_0 = (raw: RawClient["server.event"]) => () =>
Stream.unwrap(
raw["event.subscribe"]({}).pipe(
Effect.mapError(mapClientError),
@@ -527,23 +577,23 @@ const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
),
)
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
const adaptGroup15 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint15_0(raw) })
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
type Endpoint16_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_0Input) =>
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint14_1Input = {
readonly location?: Endpoint14_1Request["query"]["location"]
readonly command?: Endpoint14_1Request["payload"]["command"]
readonly args?: Endpoint14_1Request["payload"]["args"]
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
readonly title?: Endpoint14_1Request["payload"]["title"]
readonly env?: Endpoint14_1Request["payload"]["env"]
type Endpoint16_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint16_1Input = {
readonly location?: Endpoint16_1Request["query"]["location"]
readonly command?: Endpoint16_1Request["payload"]["command"]
readonly args?: Endpoint16_1Request["payload"]["args"]
readonly cwd?: Endpoint16_1Request["payload"]["cwd"]
readonly title?: Endpoint16_1Request["payload"]["title"]
readonly env?: Endpoint16_1Request["payload"]["env"]
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
const Endpoint16_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint16_1Input) =>
raw["pty.create"]({
query: { location: input?.["location"] },
payload: {
@@ -555,162 +605,225 @@ const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Inpu
},
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint14_2Input = {
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
readonly location?: Endpoint14_2Request["query"]["location"]
type Endpoint16_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint16_2Input = {
readonly ptyID: Endpoint16_2Request["params"]["ptyID"]
readonly location?: Endpoint16_2Request["query"]["location"]
}
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
const Endpoint16_2 = (raw: RawClient["server.pty"]) => (input: Endpoint16_2Input) =>
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint14_3Input = {
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
readonly location?: Endpoint14_3Request["query"]["location"]
readonly title?: Endpoint14_3Request["payload"]["title"]
readonly size?: Endpoint14_3Request["payload"]["size"]
type Endpoint16_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint16_3Input = {
readonly ptyID: Endpoint16_3Request["params"]["ptyID"]
readonly location?: Endpoint16_3Request["query"]["location"]
readonly title?: Endpoint16_3Request["payload"]["title"]
readonly size?: Endpoint16_3Request["payload"]["size"]
}
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
const Endpoint16_3 = (raw: RawClient["server.pty"]) => (input: Endpoint16_3Input) =>
raw["pty.update"]({
params: { ptyID: input["ptyID"] },
query: { location: input["location"] },
payload: { title: input["title"], size: input["size"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint14_4Input = {
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
readonly location?: Endpoint14_4Request["query"]["location"]
type Endpoint16_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint16_4Input = {
readonly ptyID: Endpoint16_4Request["params"]["ptyID"]
readonly location?: Endpoint16_4Request["query"]["location"]
}
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
const Endpoint16_4 = (raw: RawClient["server.pty"]) => (input: Endpoint16_4Input) =>
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
list: Endpoint14_0(raw),
create: Endpoint14_1(raw),
get: Endpoint14_2(raw),
update: Endpoint14_3(raw),
remove: Endpoint14_4(raw),
const adaptGroup16 = (raw: RawClient["server.pty"]) => ({
list: Endpoint16_0(raw),
create: Endpoint16_1(raw),
get: Endpoint16_2(raw),
update: Endpoint16_3(raw),
remove: Endpoint16_4(raw),
})
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
type Endpoint17_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
const Endpoint17_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint17_0Input) =>
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
type Endpoint17_1Input = {
readonly location?: Endpoint17_1Request["query"]["location"]
readonly command: Endpoint17_1Request["payload"]["command"]
readonly cwd?: Endpoint17_1Request["payload"]["cwd"]
readonly timeout?: Endpoint17_1Request["payload"]["timeout"]
readonly metadata?: Endpoint17_1Request["payload"]["metadata"]
}
const Endpoint17_1 = (raw: RawClient["server.shell"]) => (input: Endpoint17_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 Endpoint17_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
type Endpoint17_2Input = {
readonly id: Endpoint17_2Request["params"]["id"]
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.shell"]) => (input: Endpoint17_2Input) =>
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint17_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
type Endpoint17_3Input = {
readonly id: Endpoint17_3Request["params"]["id"]
readonly location?: Endpoint17_3Request["query"]["location"]
readonly cursor?: Endpoint17_3Request["query"]["cursor"]
readonly limit?: Endpoint17_3Request["query"]["limit"]
}
const Endpoint17_3 = (raw: RawClient["server.shell"]) => (input: Endpoint17_3Input) =>
raw["shell.output"]({
params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
type Endpoint17_4Input = {
readonly id: Endpoint17_4Request["params"]["id"]
readonly location?: Endpoint17_4Request["query"]["location"]
}
const Endpoint17_4 = (raw: RawClient["server.shell"]) => (input: Endpoint17_4Input) =>
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup17 = (raw: RawClient["server.shell"]) => ({
list: Endpoint17_0(raw),
create: Endpoint17_1(raw),
get: Endpoint17_2(raw),
output: Endpoint17_3(raw),
remove: Endpoint17_4(raw),
})
type Endpoint18_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
const Endpoint18_0 = (raw: RawClient["server.question"]) => (input?: Endpoint18_0Input) =>
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
type Endpoint18_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint18_1Input = { readonly sessionID: Endpoint18_1Request["params"]["sessionID"] }
const Endpoint18_1 = (raw: RawClient["server.question"]) => (input: Endpoint18_1Input) =>
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint15_2Input = {
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
readonly requestID: Endpoint15_2Request["params"]["requestID"]
readonly answers: Endpoint15_2Request["payload"]["answers"]
type Endpoint18_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint18_2Input = {
readonly sessionID: Endpoint18_2Request["params"]["sessionID"]
readonly requestID: Endpoint18_2Request["params"]["requestID"]
readonly answers: Endpoint18_2Request["payload"]["answers"]
}
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
const Endpoint18_2 = (raw: RawClient["server.question"]) => (input: Endpoint18_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { answers: input["answers"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint15_3Input = {
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
readonly requestID: Endpoint15_3Request["params"]["requestID"]
type Endpoint18_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint18_3Input = {
readonly sessionID: Endpoint18_3Request["params"]["sessionID"]
readonly requestID: Endpoint18_3Request["params"]["requestID"]
}
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
const Endpoint18_3 = (raw: RawClient["server.question"]) => (input: Endpoint18_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint15_0(raw),
list: Endpoint15_1(raw),
reply: Endpoint15_2(raw),
reject: Endpoint15_3(raw),
const adaptGroup18 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint18_0(raw),
list: Endpoint18_1(raw),
reply: Endpoint18_2(raw),
reject: Endpoint18_3(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
type Endpoint19_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
const Endpoint19_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint19_0Input) =>
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
const adaptGroup19 = (raw: RawClient["server.reference"]) => ({ list: Endpoint19_0(raw) })
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint17_0Input = {
readonly projectID: Endpoint17_0Request["params"]["projectID"]
readonly location?: Endpoint17_0Request["query"]["location"]
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
readonly directory: Endpoint17_0Request["payload"]["directory"]
readonly name?: Endpoint17_0Request["payload"]["name"]
type Endpoint20_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint20_0Input = {
readonly projectID: Endpoint20_0Request["params"]["projectID"]
readonly location?: Endpoint20_0Request["query"]["location"]
readonly strategy: Endpoint20_0Request["payload"]["strategy"]
readonly directory: Endpoint20_0Request["payload"]["directory"]
readonly name?: Endpoint20_0Request["payload"]["name"]
}
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
const Endpoint20_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_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 Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint17_1Input = {
readonly projectID: Endpoint17_1Request["params"]["projectID"]
readonly location?: Endpoint17_1Request["query"]["location"]
readonly directory: Endpoint17_1Request["payload"]["directory"]
readonly force: Endpoint17_1Request["payload"]["force"]
type Endpoint20_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint20_1Input = {
readonly projectID: Endpoint20_1Request["params"]["projectID"]
readonly location?: Endpoint20_1Request["query"]["location"]
readonly directory: Endpoint20_1Request["payload"]["directory"]
readonly force: Endpoint20_1Request["payload"]["force"]
}
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
const Endpoint20_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint17_2Input = {
readonly projectID: Endpoint17_2Request["params"]["projectID"]
readonly location?: Endpoint17_2Request["query"]["location"]
type Endpoint20_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint20_2Input = {
readonly projectID: Endpoint20_2Request["params"]["projectID"]
readonly location?: Endpoint20_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
const Endpoint20_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint20_2Input) =>
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint17_0(raw),
remove: Endpoint17_1(raw),
refresh: Endpoint17_2(raw),
const adaptGroup20 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint20_0(raw),
remove: Endpoint20_1(raw),
refresh: Endpoint20_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"]),
providers: adaptGroup6(raw["server.provider"]),
integrations: adaptGroup7(raw["server.integration"]),
credentials: adaptGroup8(raw["server.credential"]),
permissions: adaptGroup9(raw["server.permission"]),
files: adaptGroup10(raw["server.fs"]),
commands: adaptGroup11(raw["server.command"]),
skills: adaptGroup12(raw["server.skill"]),
events: adaptGroup13(raw["server.event"]),
ptys: adaptGroup14(raw["server.pty"]),
questions: adaptGroup15(raw["server.question"]),
references: adaptGroup16(raw["server.reference"]),
projectCopies: adaptGroup17(raw["server.projectCopy"]),
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"]),
provider: adaptGroup7(raw["server.provider"]),
integration: adaptGroup8(raw["server.integration"]),
credential: adaptGroup9(raw["server.credential"]),
project: adaptGroup10(raw["server.project"]),
permission: adaptGroup11(raw["server.permission"]),
file: adaptGroup12(raw["server.fs"]),
command: adaptGroup13(raw["server.command"]),
skill: adaptGroup14(raw["server.skill"]),
event: adaptGroup15(raw["server.event"]),
pty: adaptGroup16(raw["server.pty"]),
shell: adaptGroup17(raw["server.shell"]),
question: adaptGroup18(raw["server.question"]),
reference: adaptGroup19(raw["server.reference"]),
projectCopy: adaptGroup20(raw["server.projectCopy"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
+402 -242
View File
@@ -2,118 +2,138 @@ import type {
HealthGetOutput,
LocationGetInput,
LocationGetOutput,
AgentsListInput,
AgentsListOutput,
SessionsListInput,
SessionsListOutput,
SessionsCreateInput,
SessionsCreateOutput,
SessionsActiveOutput,
SessionsGetInput,
SessionsGetOutput,
SessionsSwitchAgentInput,
SessionsSwitchAgentOutput,
SessionsSwitchModelInput,
SessionsSwitchModelOutput,
SessionsRenameInput,
SessionsRenameOutput,
SessionsPromptInput,
SessionsPromptOutput,
SessionsCompactInput,
SessionsCompactOutput,
SessionsWaitInput,
SessionsWaitOutput,
SessionsStageInput,
SessionsStageOutput,
SessionsClearInput,
SessionsClearOutput,
SessionsCommitInput,
SessionsCommitOutput,
SessionsContextInput,
SessionsContextOutput,
SessionsHistoryInput,
SessionsHistoryOutput,
SessionsEventsInput,
SessionsEventsOutput,
SessionsInterruptInput,
SessionsInterruptOutput,
SessionsMessageInput,
SessionsMessageOutput,
MessagesListInput,
MessagesListOutput,
ModelsListInput,
ModelsListOutput,
ProvidersListInput,
ProvidersListOutput,
ProvidersGetInput,
ProvidersGetOutput,
IntegrationsListInput,
IntegrationsListOutput,
IntegrationsGetInput,
IntegrationsGetOutput,
IntegrationsConnectKeyInput,
IntegrationsConnectKeyOutput,
IntegrationsConnectOauthInput,
IntegrationsConnectOauthOutput,
IntegrationsAttemptStatusInput,
IntegrationsAttemptStatusOutput,
IntegrationsAttemptCompleteInput,
IntegrationsAttemptCompleteOutput,
IntegrationsAttemptCancelInput,
IntegrationsAttemptCancelOutput,
CredentialsUpdateInput,
CredentialsUpdateOutput,
CredentialsRemoveInput,
CredentialsRemoveOutput,
PermissionsListRequestsInput,
PermissionsListRequestsOutput,
PermissionsListSavedInput,
PermissionsListSavedOutput,
PermissionsRemoveSavedInput,
PermissionsRemoveSavedOutput,
PermissionsCreateInput,
PermissionsCreateOutput,
PermissionsListInput,
PermissionsListOutput,
PermissionsGetInput,
PermissionsGetOutput,
PermissionsReplyInput,
PermissionsReplyOutput,
FilesListInput,
FilesListOutput,
FilesFindInput,
FilesFindOutput,
CommandsListInput,
CommandsListOutput,
SkillsListInput,
SkillsListOutput,
EventsSubscribeOutput,
PtysListInput,
PtysListOutput,
PtysCreateInput,
PtysCreateOutput,
PtysGetInput,
PtysGetOutput,
PtysUpdateInput,
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
QuestionsListOutput,
QuestionsReplyInput,
QuestionsReplyOutput,
QuestionsRejectInput,
QuestionsRejectOutput,
ReferencesListInput,
ReferencesListOutput,
ProjectCopiesCreateInput,
ProjectCopiesCreateOutput,
ProjectCopiesRemoveInput,
ProjectCopiesRemoveOutput,
ProjectCopiesRefreshInput,
ProjectCopiesRefreshOutput,
AgentListInput,
AgentListOutput,
SessionListInput,
SessionListOutput,
SessionCreateInput,
SessionCreateOutput,
SessionActiveOutput,
SessionGetInput,
SessionGetOutput,
SessionForkInput,
SessionForkOutput,
SessionSwitchAgentInput,
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionPromptInput,
SessionPromptOutput,
SessionCompactInput,
SessionCompactOutput,
SessionWaitInput,
SessionWaitOutput,
SessionStageInput,
SessionStageOutput,
SessionClearInput,
SessionClearOutput,
SessionCommitInput,
SessionCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionHistoryInput,
SessionHistoryOutput,
SessionEventsInput,
SessionEventsOutput,
SessionInterruptInput,
SessionInterruptOutput,
SessionMessageInput,
SessionMessageOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
ModelListOutput,
GenerateTextInput,
GenerateTextOutput,
ProviderListInput,
ProviderListOutput,
ProviderGetInput,
ProviderGetOutput,
IntegrationListInput,
IntegrationListOutput,
IntegrationGetInput,
IntegrationGetOutput,
IntegrationConnectKeyInput,
IntegrationConnectKeyOutput,
IntegrationConnectOauthInput,
IntegrationConnectOauthOutput,
IntegrationAttemptStatusInput,
IntegrationAttemptStatusOutput,
IntegrationAttemptCompleteInput,
IntegrationAttemptCompleteOutput,
IntegrationAttemptCancelInput,
IntegrationAttemptCancelOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
ProjectDirectoriesOutput,
PermissionListRequestsInput,
PermissionListRequestsOutput,
PermissionListSavedInput,
PermissionListSavedOutput,
PermissionRemoveSavedInput,
PermissionRemoveSavedOutput,
PermissionCreateInput,
PermissionCreateOutput,
PermissionListInput,
PermissionListOutput,
PermissionGetInput,
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
FileReadInput,
FileReadOutput,
FileListInput,
FileListOutput,
FileFindInput,
FileFindOutput,
CommandListInput,
CommandListOutput,
SkillListInput,
SkillListOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
PtyCreateInput,
PtyCreateOutput,
PtyGetInput,
PtyGetOutput,
PtyUpdateInput,
PtyUpdateOutput,
PtyRemoveInput,
PtyRemoveOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
ShellRemoveOutput,
QuestionListRequestsInput,
QuestionListRequestsOutput,
QuestionListInput,
QuestionListOutput,
QuestionReplyInput,
QuestionReplyOutput,
QuestionRejectInput,
QuestionRejectOutput,
ReferenceListInput,
ReferenceListOutput,
ProjectCopyCreateInput,
ProjectCopyCreateOutput,
ProjectCopyRemoveInput,
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
} from "./types"
import { ClientError } from "./client-error"
@@ -137,6 +157,7 @@ interface RequestDescriptor {
readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean
readonly binary?: true
}
export function make(options: ClientOptions) {
@@ -182,6 +203,7 @@ export function make(options: ClientOptions) {
const request = async <A>(descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise<A> => {
const response = await execute(descriptor, requestOptions)
if (response.status !== descriptor.successStatus) return responseError(response, descriptor)
if (descriptor.binary) return new Uint8Array(await response.arrayBuffer()) as A
if (descriptor.empty) {
try {
await response.body?.cancel()
@@ -270,9 +292,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
agents: {
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
request<AgentsListOutput>(
agent: {
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
request<AgentListOutput>(
{
method: "GET",
path: `/api/agent`,
@@ -284,9 +306,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>(
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
request<SessionListOutput>(
{
method: "GET",
path: `/api/session`,
@@ -306,8 +328,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
create: (input?: SessionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsCreateOutput }>(
create: (input?: SessionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCreateOutput }>(
{
method: "POST",
path: `/api/session`,
@@ -324,7 +346,7 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
active: (requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsActiveOutput }>(
request<{ readonly data: SessionActiveOutput }>(
{
method: "GET",
path: `/api/session/active`,
@@ -334,8 +356,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
get: (input: SessionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsGetOutput }>(
get: (input: SessionGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
@@ -345,8 +367,20 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
switchAgent: (input: SessionsSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchAgentOutput>(
fork: (input: SessionForkInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionForkOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
body: { messageID: input["messageID"] },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) =>
request<SessionSwitchAgentOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`,
@@ -357,8 +391,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
switchModel: (input: SessionsSwitchModelInput, requestOptions?: RequestOptions) =>
request<SessionsSwitchModelOutput>(
switchModel: (input: SessionSwitchModelInput, requestOptions?: RequestOptions) =>
request<SessionSwitchModelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/model`,
@@ -369,8 +403,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rename: (input: SessionsRenameInput, requestOptions?: RequestOptions) =>
request<SessionsRenameOutput>(
rename: (input: SessionRenameInput, requestOptions?: RequestOptions) =>
request<SessionRenameOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/rename`,
@@ -381,8 +415,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
prompt: (input: SessionsPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsPromptOutput }>(
prompt: (input: SessionPromptInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPromptOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
@@ -393,19 +427,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
compact: (input: SessionsCompactInput, requestOptions?: RequestOptions) =>
request<SessionsCompactOutput>(
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204,
declaredStatuses: [404, 503, 400, 401],
declaredStatuses: [404, 409, 503, 500, 400, 401],
empty: true,
},
requestOptions,
),
wait: (input: SessionsWaitInput, requestOptions?: RequestOptions) =>
request<SessionsWaitOutput>(
wait: (input: SessionWaitInput, requestOptions?: RequestOptions) =>
request<SessionWaitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/wait`,
@@ -415,8 +449,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
stage: (input: SessionsStageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsStageOutput }>(
stage: (input: SessionStageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionStageOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`,
@@ -427,8 +461,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
clear: (input: SessionsClearInput, requestOptions?: RequestOptions) =>
request<SessionsClearOutput>(
clear: (input: SessionClearInput, requestOptions?: RequestOptions) =>
request<SessionClearOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/clear`,
@@ -438,8 +472,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
commit: (input: SessionsCommitInput, requestOptions?: RequestOptions) =>
request<SessionsCommitOutput>(
commit: (input: SessionCommitInput, requestOptions?: RequestOptions) =>
request<SessionCommitOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`,
@@ -449,8 +483,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
context: (input: SessionsContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsContextOutput }>(
context: (input: SessionContextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionContextOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/context`,
@@ -460,8 +494,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
request<SessionsHistoryOutput>(
history: (input: SessionHistoryInput, requestOptions?: RequestOptions) =>
request<SessionHistoryOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
@@ -472,8 +506,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
sse<SessionsEventsOutput>(
events: (input: SessionEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionEventsOutput> =>
sse<SessionEventsOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/event`,
@@ -484,8 +518,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
interrupt: (input: SessionsInterruptInput, requestOptions?: RequestOptions) =>
request<SessionsInterruptOutput>(
interrupt: (input: SessionInterruptInput, requestOptions?: RequestOptions) =>
request<SessionInterruptOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`,
@@ -495,8 +529,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
message: (input: SessionsMessageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionsMessageOutput }>(
message: (input: SessionMessageInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionMessageOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message/${encodeURIComponent(input.messageID)}`,
@@ -507,9 +541,9 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
},
messages: {
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
request<MessagesListOutput>(
message: {
list: (input: MessageListInput, requestOptions?: RequestOptions) =>
request<MessageListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
@@ -521,9 +555,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
models: {
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
request<ModelsListOutput>(
model: {
list: (input?: ModelListInput, requestOptions?: RequestOptions) =>
request<ModelListOutput>(
{
method: "GET",
path: `/api/model`,
@@ -535,9 +569,24 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
providers: {
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
request<ProvidersListOutput>(
generate: {
text: (input: GenerateTextInput, requestOptions?: RequestOptions) =>
request<{ readonly data: GenerateTextOutput }>(
{
method: "POST",
path: `/api/generate`,
query: { location: input["location"] },
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
provider: {
list: (input?: ProviderListInput, requestOptions?: RequestOptions) =>
request<ProviderListOutput>(
{
method: "GET",
path: `/api/provider`,
@@ -548,8 +597,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
request<ProvidersGetOutput>(
get: (input: ProviderGetInput, requestOptions?: RequestOptions) =>
request<ProviderGetOutput>(
{
method: "GET",
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
@@ -561,9 +610,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
integrations: {
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
request<IntegrationsListOutput>(
integration: {
list: (input?: IntegrationListInput, requestOptions?: RequestOptions) =>
request<IntegrationListOutput>(
{
method: "GET",
path: `/api/integration`,
@@ -574,8 +623,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
request<IntegrationsGetOutput>(
get: (input: IntegrationGetInput, requestOptions?: RequestOptions) =>
request<IntegrationGetOutput>(
{
method: "GET",
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
@@ -586,8 +635,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectKeyOutput>(
connectKey: (input: IntegrationConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
@@ -599,8 +648,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectOauthOutput>(
connectOauth: (input: IntegrationConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
@@ -612,8 +661,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptStatusOutput>(
attemptStatus: (input: IntegrationAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
@@ -624,8 +673,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCompleteOutput>(
attemptComplete: (input: IntegrationAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
@@ -637,8 +686,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCancelOutput>(
attemptCancel: (input: IntegrationAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
@@ -650,9 +699,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
credentials: {
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialsUpdateOutput>(
credential: {
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialUpdateOutput>(
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
@@ -664,8 +713,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialsRemoveOutput>(
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialRemoveOutput>(
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
@@ -677,9 +726,35 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
permissions: {
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionsListRequestsOutput>(
project: {
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
method: "GET",
path: `/api/project/current`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
request<ProjectDirectoriesOutput>(
{
method: "GET",
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
permission: {
listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionListRequestsOutput>(
{
method: "GET",
path: `/api/permission/request`,
@@ -690,8 +765,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListSavedOutput }>(
listSaved: (input?: PermissionListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionListSavedOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
@@ -702,8 +777,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionsRemoveSavedOutput>(
removeSaved: (input: PermissionRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionRemoveSavedOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
@@ -713,8 +788,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsCreateOutput }>(
create: (input: PermissionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
@@ -733,8 +808,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListOutput }>(
list: (input: PermissionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
@@ -744,8 +819,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsGetOutput }>(
get: (input: PermissionGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
@@ -755,8 +830,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
request<PermissionsReplyOutput>(
reply: (input: PermissionReplyInput, requestOptions?: RequestOptions) =>
request<PermissionReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
@@ -768,9 +843,22 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
files: {
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
request<FilesListOutput>(
file: {
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
request<FileReadOutput>(
{
method: "GET",
path: `/api/fs/read/${encodePath(input.path)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: true,
},
requestOptions,
),
list: (input?: FileListInput, requestOptions?: RequestOptions) =>
request<FileListOutput>(
{
method: "GET",
path: `/api/fs/list`,
@@ -781,8 +869,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
request<FilesFindOutput>(
find: (input: FileFindInput, requestOptions?: RequestOptions) =>
request<FileFindOutput>(
{
method: "GET",
path: `/api/fs/find`,
@@ -794,9 +882,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
commands: {
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
request<CommandsListOutput>(
command: {
list: (input?: CommandListInput, requestOptions?: RequestOptions) =>
request<CommandListOutput>(
{
method: "GET",
path: `/api/command`,
@@ -808,9 +896,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
skills: {
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
request<SkillsListOutput>(
skill: {
list: (input?: SkillListInput, requestOptions?: RequestOptions) =>
request<SkillListOutput>(
{
method: "GET",
path: `/api/skill`,
@@ -822,16 +910,16 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
events: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> =>
sse<EventsSubscribeOutput>(
event: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventSubscribeOutput> =>
sse<EventSubscribeOutput>(
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
},
ptys: {
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
request<PtysListOutput>(
pty: {
list: (input?: PtyListInput, requestOptions?: RequestOptions) =>
request<PtyListOutput>(
{
method: "GET",
path: `/api/pty`,
@@ -842,8 +930,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
request<PtysCreateOutput>(
create: (input?: PtyCreateInput, requestOptions?: RequestOptions) =>
request<PtyCreateOutput>(
{
method: "POST",
path: `/api/pty`,
@@ -861,8 +949,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
request<PtysGetOutput>(
get: (input: PtyGetInput, requestOptions?: RequestOptions) =>
request<PtyGetOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
@@ -873,8 +961,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
request<PtysUpdateOutput>(
update: (input: PtyUpdateInput, requestOptions?: RequestOptions) =>
request<PtyUpdateOutput>(
{
method: "PUT",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
@@ -886,8 +974,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
request<PtysRemoveOutput>(
remove: (input: PtyRemoveInput, requestOptions?: RequestOptions) =>
request<PtyRemoveOutput>(
{
method: "DELETE",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
@@ -899,9 +987,77 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>(
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
{
method: "GET",
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input: ShellCreateInput, requestOptions?: RequestOptions) =>
request<ShellCreateOutput>(
{
method: "POST",
path: `/api/shell`,
query: { location: input["location"] },
body: {
command: input["command"],
cwd: input["cwd"],
timeout: input["timeout"],
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
get: (input: ShellGetInput, requestOptions?: RequestOptions) =>
request<ShellGetOutput>(
{
method: "GET",
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
output: (input: ShellOutputInput, requestOptions?: RequestOptions) =>
request<ShellOutputOutput>(
{
method: "GET",
path: `/api/shell/${encodeURIComponent(input.id)}/output`,
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
remove: (input: ShellRemoveInput, requestOptions?: RequestOptions) =>
request<ShellRemoveOutput>(
{
method: "DELETE",
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
},
question: {
listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionListRequestsOutput>(
{
method: "GET",
path: `/api/question/request`,
@@ -912,8 +1068,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionsListOutput }>(
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
@@ -923,8 +1079,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
request<QuestionsReplyOutput>(
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
request<QuestionReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
@@ -935,8 +1091,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
request<QuestionsRejectOutput>(
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
request<QuestionRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
@@ -947,9 +1103,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
references: {
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
request<ReferencesListOutput>(
reference: {
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
request<ReferenceListOutput>(
{
method: "GET",
path: `/api/reference`,
@@ -961,9 +1117,9 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
projectCopies: {
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesCreateOutput>(
projectCopy: {
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopyCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
@@ -975,8 +1131,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRemoveOutput>(
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRemoveOutput>(
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
@@ -988,8 +1144,8 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRefreshOutput>(
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRefreshOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
@@ -1004,6 +1160,10 @@ export function make(options: ClientOptions) {
}
}
function encodePath(value: string): string {
return value.split("/").map(encodeURIComponent).join("/")
}
function appendQuery(params: URLSearchParams, key: string, value: unknown): void {
if (value === undefined || value === null) return
if (Array.isArray(value)) {
+1719 -142
View File
@@ -33,22 +33,6 @@ export type SessionNotFoundError = {
export const isSessionNotFoundError = (value: unknown): value is SessionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionNotFoundError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type MessageNotFoundError = {
readonly _tag: "MessageNotFoundError"
readonly sessionID: string
@@ -58,6 +42,14 @@ export type MessageNotFoundError = {
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
export type ConflictError = {
readonly _tag: "ConflictError"
readonly message: string
readonly resource?: string | undefined
}
export const isConflictError = (value: unknown): value is ConflictError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -66,6 +58,14 @@ export type SessionBusyError = {
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type UnknownError = {
readonly _tag: "UnknownError"
readonly message: string
@@ -94,6 +94,10 @@ export type PtyNotFoundError = { readonly _tag: "PtyNotFoundError"; readonly pty
export const isPtyNotFoundError = (value: unknown): value is PtyNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "PtyNotFoundError"
export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly id: string; readonly message: string }
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
export type QuestionNotFoundError = {
readonly _tag: "QuestionNotFoundError"
readonly requestID: string
@@ -123,13 +127,13 @@ export type LocationGetOutput = {
readonly project: { readonly id: string; readonly directory: string }
}
export type AgentsListInput = {
export type AgentListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentsListOutput = {
export type AgentListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -156,7 +160,7 @@ export type AgentsListOutput = {
}>
}
export type SessionsListInput = {
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
readonly limit?: number | undefined
@@ -239,7 +243,7 @@ export type SessionsListInput = {
}["cursor"]
}
export type SessionsListOutput = {
export type SessionListOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly parentID?: string
@@ -274,7 +278,7 @@ export type SessionsListOutput = {
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
export type SessionsCreateInput = {
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
readonly agent?: string | null
@@ -301,7 +305,7 @@ export type SessionsCreateInput = {
}["location"]
}
export type SessionsCreateOutput = {
export type SessionCreateOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
@@ -335,11 +339,11 @@ export type SessionsCreateOutput = {
}
}["data"]
export type SessionsActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
export type SessionActiveOutput = { readonly data: { readonly [x: string]: { readonly type: "running" } } }["data"]
export type SessionsGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionGetInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsGetOutput = {
export type SessionGetOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
@@ -373,30 +377,69 @@ export type SessionsGetOutput = {
}
}["data"]
export type SessionsSwitchAgentInput = {
export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: { readonly messageID?: string | undefined }["messageID"]
}
export type SessionForkOutput = {
readonly data: {
readonly id: string
readonly parentID?: string
readonly projectID: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number }
readonly title: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly diff?: string
readonly files?: ReadonlyArray<{
readonly path: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
readonly patch: string
}>
}
}
}["data"]
export type SessionSwitchAgentInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly agent: { readonly agent: string }["agent"]
}
export type SessionsSwitchAgentOutput = void
export type SessionSwitchAgentOutput = void
export type SessionsSwitchModelInput = {
export type SessionSwitchModelInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly model: {
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}["model"]
}
export type SessionsSwitchModelOutput = void
export type SessionSwitchModelOutput = void
export type SessionsRenameInput = {
export type SessionRenameInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly title: { readonly title: string }["title"]
}
export type SessionsRenameOutput = void
export type SessionRenameOutput = void
export type SessionsPromptInput = {
export type SessionPromptInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | null
@@ -412,7 +455,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -431,7 +473,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -450,7 +491,6 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
@@ -469,14 +509,13 @@ export type SessionsPromptInput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionsPromptOutput = {
export type SessionPromptOutput = {
readonly data: {
readonly admittedSeq: number
readonly id: string
@@ -494,7 +533,6 @@ export type SessionsPromptOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
readonly timeCreated: number
@@ -502,21 +540,21 @@ export type SessionsPromptOutput = {
}
}["data"]
export type SessionsCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsCompactOutput = void
export type SessionCompactOutput = void
export type SessionsWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsWaitOutput = void
export type SessionWaitOutput = void
export type SessionsStageInput = {
export type SessionStageInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID: { readonly messageID: string; readonly files?: boolean | undefined }["messageID"]
readonly files?: { readonly messageID: string; readonly files?: boolean | undefined }["files"]
}
export type SessionsStageOutput = {
export type SessionStageOutput = {
readonly data: {
readonly messageID: string
readonly partID?: string
@@ -532,17 +570,17 @@ export type SessionsStageOutput = {
}
}["data"]
export type SessionsClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionClearInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsClearOutput = void
export type SessionClearOutput = void
export type SessionsCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionCommitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsCommitOutput = void
export type SessionCommitOutput = void
export type SessionsContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionContextInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsContextOutput = {
export type SessionContextOutput = {
readonly data: ReadonlyArray<
| {
readonly id: string
@@ -574,7 +612,6 @@ export type SessionsContextOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
readonly type: "user"
}
| {
@@ -697,13 +734,13 @@ export type SessionsContextOutput = {
>
}["data"]
export type SessionsHistoryInput = {
export type SessionHistoryInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"]
readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"]
}
export type SessionsHistoryOutput = {
export type SessionHistoryOutput = {
readonly data: ReadonlyArray<
| {
readonly id: string
@@ -752,6 +789,19 @@ export type SessionsHistoryOutput = {
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly type: "session.next.forked"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly parentID: string
readonly messageID?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
@@ -775,7 +825,6 @@ export type SessionsHistoryOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -803,7 +852,6 @@ export type SessionsHistoryOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -1167,12 +1215,12 @@ export type SessionsHistoryOutput = {
readonly hasMore: boolean
}
export type SessionsEventsInput = {
export type SessionEventsInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly after?: { readonly after?: number | undefined }["after"]
}
export type SessionsEventsOutput =
export type SessionEventsOutput =
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -1220,6 +1268,19 @@ export type SessionsEventsOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.forked"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly parentID: string
readonly messageID?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
@@ -1243,7 +1304,6 @@ export type SessionsEventsOutput =
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -1271,7 +1331,6 @@ export type SessionsEventsOutput =
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
}
readonly delivery: "steer" | "queue"
}
@@ -1632,16 +1691,16 @@ export type SessionsEventsOutput =
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
}
export type SessionsInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInterruptInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionsInterruptOutput = void
export type SessionInterruptOutput = void
export type SessionsMessageInput = {
export type SessionMessageInput = {
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]
}
export type SessionsMessageOutput = {
export type SessionMessageOutput = {
readonly data:
| {
readonly id: string
@@ -1673,7 +1732,6 @@ export type SessionsMessageOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
readonly type: "user"
}
| {
@@ -1795,7 +1853,7 @@ export type SessionsMessageOutput = {
}
}["data"]
export type MessagesListInput = {
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
readonly limit?: number | undefined
@@ -1814,7 +1872,7 @@ export type MessagesListInput = {
}["cursor"]
}
export type MessagesListOutput = {
export type MessageListOutput = {
readonly data: ReadonlyArray<
| {
readonly id: string
@@ -1846,7 +1904,6 @@ export type MessagesListOutput = {
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly system?: string
readonly type: "user"
}
| {
@@ -1970,13 +2027,13 @@ export type MessagesListOutput = {
readonly cursor: { readonly previous?: string | null; readonly next?: string | null }
}
export type ModelsListInput = {
export type ModelListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ModelsListOutput = {
export type ModelListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2029,13 +2086,29 @@ export type ModelsListOutput = {
}>
}
export type ProvidersListInput = {
export type GenerateTextInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly prompt: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
}["prompt"]
readonly model?: {
readonly prompt: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
}["model"]
}
export type GenerateTextOutput = { readonly data: { readonly text: string } }["data"]
export type ProviderListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProvidersListOutput = {
export type ProviderListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2061,14 +2134,14 @@ export type ProvidersListOutput = {
}>
}
export type ProvidersGetInput = {
export type ProviderGetInput = {
readonly providerID: { readonly providerID: string }["providerID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProvidersGetOutput = {
export type ProviderGetOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2094,13 +2167,13 @@ export type ProvidersGetOutput = {
}
}
export type IntegrationsListInput = {
export type IntegrationListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationsListOutput = {
export type IntegrationListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2145,14 +2218,14 @@ export type IntegrationsListOutput = {
}>
}
export type IntegrationsGetInput = {
export type IntegrationGetInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationsGetOutput = {
export type IntegrationGetOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2197,7 +2270,7 @@ export type IntegrationsGetOutput = {
} | null
}
export type IntegrationsConnectKeyInput = {
export type IntegrationConnectKeyInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2206,9 +2279,9 @@ export type IntegrationsConnectKeyInput = {
readonly label?: { readonly key: string; readonly label?: string | undefined }["label"]
}
export type IntegrationsConnectKeyOutput = void
export type IntegrationConnectKeyOutput = void
export type IntegrationsConnectOauthInput = {
export type IntegrationConnectOauthInput = {
readonly integrationID: { readonly integrationID: string }["integrationID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2230,7 +2303,7 @@ export type IntegrationsConnectOauthInput = {
}["label"]
}
export type IntegrationsConnectOauthOutput = {
export type IntegrationConnectOauthOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2248,14 +2321,14 @@ export type IntegrationsConnectOauthOutput = {
}
}
export type IntegrationsAttemptStatusInput = {
export type IntegrationAttemptStatusInput = {
readonly attemptID: { readonly attemptID: string }["attemptID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationsAttemptStatusOutput = {
export type IntegrationAttemptStatusOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2293,7 +2366,7 @@ export type IntegrationsAttemptStatusOutput = {
}
}
export type IntegrationsAttemptCompleteInput = {
export type IntegrationAttemptCompleteInput = {
readonly attemptID: { readonly attemptID: string }["attemptID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2301,18 +2374,18 @@ export type IntegrationsAttemptCompleteInput = {
readonly code?: { readonly code?: string | undefined }["code"]
}
export type IntegrationsAttemptCompleteOutput = void
export type IntegrationAttemptCompleteOutput = void
export type IntegrationsAttemptCancelInput = {
export type IntegrationAttemptCancelInput = {
readonly attemptID: { readonly attemptID: string }["attemptID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type IntegrationsAttemptCancelOutput = void
export type IntegrationAttemptCancelOutput = void
export type CredentialsUpdateInput = {
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2320,24 +2393,41 @@ export type CredentialsUpdateInput = {
readonly label: { readonly label: string }["label"]
}
export type CredentialsUpdateOutput = void
export type CredentialUpdateOutput = void
export type CredentialsRemoveInput = {
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialsRemoveOutput = void
export type CredentialRemoveOutput = void
export type PermissionsListRequestsInput = {
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PermissionsListRequestsOutput = {
export type ProjectCurrentOutput = { readonly id: string; readonly directory: string }
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ReadonlyArray<{ readonly directory: string; readonly strategy?: string }>
export type PermissionListRequestsInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PermissionListRequestsOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2354,11 +2444,9 @@ export type PermissionsListRequestsOutput = {
}>
}
export type PermissionsListSavedInput = {
readonly projectID?: { readonly projectID?: string | undefined }["projectID"]
}
export type PermissionListSavedInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
export type PermissionsListSavedOutput = {
export type PermissionListSavedOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly projectID: string
@@ -2367,11 +2455,11 @@ export type PermissionsListSavedOutput = {
}>
}["data"]
export type PermissionsRemoveSavedInput = { readonly id: { readonly id: string }["id"] }
export type PermissionRemoveSavedInput = { readonly id: { readonly id: string }["id"] }
export type PermissionsRemoveSavedOutput = void
export type PermissionRemoveSavedOutput = void
export type PermissionsCreateInput = {
export type PermissionCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
readonly id?: string | null
@@ -2438,13 +2526,13 @@ export type PermissionsCreateInput = {
}["agent"]
}
export type PermissionsCreateOutput = {
export type PermissionCreateOutput = {
readonly data: { readonly id: string; readonly effect: "allow" | "deny" | "ask" }
}["data"]
export type PermissionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type PermissionsListOutput = {
export type PermissionListOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly sessionID: string
@@ -2456,12 +2544,12 @@ export type PermissionsListOutput = {
}>
}["data"]
export type PermissionsGetInput = {
export type PermissionGetInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
}
export type PermissionsGetOutput = {
export type PermissionGetOutput = {
readonly data: {
readonly id: string
readonly sessionID: string
@@ -2473,16 +2561,25 @@ export type PermissionsGetOutput = {
}
}["data"]
export type PermissionsReplyInput = {
export type PermissionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
readonly reply: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["reply"]
readonly message?: { readonly reply: "once" | "always" | "reject"; readonly message?: string | undefined }["message"]
}
export type PermissionsReplyOutput = void
export type PermissionReplyOutput = void
export type FilesListInput = {
export type FileReadInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly path: string
}
export type FileReadOutput = globalThis.Uint8Array
export type FileListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly path?: string | undefined
@@ -2493,7 +2590,7 @@ export type FilesListInput = {
}["path"]
}
export type FilesListOutput = {
export type FileListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2502,7 +2599,7 @@ export type FilesListOutput = {
readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }>
}
export type FilesFindInput = {
export type FileFindInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly query: string
@@ -2529,7 +2626,7 @@ export type FilesFindInput = {
}["limit"]
}
export type FilesFindOutput = {
export type FileFindOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2538,13 +2635,13 @@ export type FilesFindOutput = {
readonly data: ReadonlyArray<{ readonly path: string; readonly type: "file" | "directory" }>
}
export type CommandsListInput = {
export type CommandListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CommandsListOutput = {
export type CommandListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2560,13 +2657,13 @@ export type CommandsListOutput = {
}>
}
export type SkillsListInput = {
export type SkillListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type SkillsListOutput = {
export type SkillListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2581,15 +2678,1341 @@ export type SkillsListOutput = {
}>
}
export type EventsSubscribeOutput = OpenCodeEventEncoded
export type EventSubscribeOutput =
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "models-dev.refreshed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "integration.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "integration.connection.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly integrationID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "catalog.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.created"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly info: {
readonly id: string
readonly slug: string
readonly projectID: string
readonly workspaceID?: string
readonly directory: string
readonly path?: string
readonly parentID?: string
readonly summary?: {
readonly additions: number
readonly deletions: number
readonly files: number
readonly diffs?: ReadonlyArray<{
readonly file?: string
readonly patch?: string
readonly additions: number
readonly deletions: number
readonly status?: "added" | "deleted" | "modified"
}>
}
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly share?: { readonly url: string }
readonly title: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly version: string
readonly metadata?: { readonly [x: string]: any }
readonly time: {
readonly created: number
readonly updated: number
readonly compacting?: number
readonly archived?: number
}
readonly permission?: ReadonlyArray<{
readonly permission: string
readonly pattern: string
readonly action: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly diff?: string
}
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly info: {
readonly id: string
readonly slug: string
readonly projectID: string
readonly workspaceID?: string
readonly directory: string
readonly path?: string
readonly parentID?: string
readonly summary?: {
readonly additions: number
readonly deletions: number
readonly files: number
readonly diffs?: ReadonlyArray<{
readonly file?: string
readonly patch?: string
readonly additions: number
readonly deletions: number
readonly status?: "added" | "deleted" | "modified"
}>
}
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly share?: { readonly url: string }
readonly title: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly version: string
readonly metadata?: { readonly [x: string]: any }
readonly time: {
readonly created: number
readonly updated: number
readonly compacting?: number
readonly archived?: number
}
readonly permission?: ReadonlyArray<{
readonly permission: string
readonly pattern: string
readonly action: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly diff?: string
}
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.deleted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly info: {
readonly id: string
readonly slug: string
readonly projectID: string
readonly workspaceID?: string
readonly directory: string
readonly path?: string
readonly parentID?: string
readonly summary?: {
readonly additions: number
readonly deletions: number
readonly files: number
readonly diffs?: ReadonlyArray<{
readonly file?: string
readonly patch?: string
readonly additions: number
readonly deletions: number
readonly status?: "added" | "deleted" | "modified"
}>
}
readonly cost?: number
readonly tokens?: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly share?: { readonly url: string }
readonly title: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly version: string
readonly metadata?: { readonly [x: string]: any }
readonly time: {
readonly created: number
readonly updated: number
readonly compacting?: number
readonly archived?: number
}
readonly permission?: ReadonlyArray<{
readonly permission: string
readonly pattern: string
readonly action: "allow" | "deny" | "ask"
}>
readonly revert?: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly diff?: string
}
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "message.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly info:
| {
readonly id: string
readonly sessionID: string
readonly role: "user"
readonly time: { readonly created: number }
readonly format?:
| (
| { readonly type: "text" }
| {
readonly type: "json_schema"
readonly schema: { readonly [x: string]: any }
readonly retryCount?: number | undefined | undefined
}
)
| undefined
readonly summary?:
| {
readonly title?: string | undefined
readonly body?: string | undefined
readonly diffs: ReadonlyArray<{
readonly file?: string
readonly patch?: string
readonly additions: number
readonly deletions: number
readonly status?: "added" | "deleted" | "modified"
}>
}
| undefined
readonly agent: string
readonly model: {
readonly providerID: string
readonly modelID: string
readonly variant?: string | undefined
}
readonly system?: string | undefined
readonly tools?: { readonly [x: string]: boolean } | undefined
}
| {
readonly id: string
readonly sessionID: string
readonly role: "assistant"
readonly time: { readonly created: number; readonly completed?: number | undefined }
readonly error?:
| {
readonly name: "ProviderAuthError"
readonly data: { readonly providerID: string; readonly message: string }
}
| {
readonly name: "UnknownError"
readonly data: { readonly message: string; readonly ref?: string | undefined }
}
| { readonly name: "MessageOutputLengthError"; readonly data: {} }
| { readonly name: "MessageAbortedError"; readonly data: { readonly message: string } }
| {
readonly name: "StructuredOutputError"
readonly data: { readonly message: string; readonly retries: number }
}
| {
readonly name: "ContextOverflowError"
readonly data: { readonly message: string; readonly responseBody?: string | undefined }
}
| { readonly name: "ContentFilterError"; readonly data: { readonly message: string } }
| {
readonly name: "APIError"
readonly data: {
readonly message: string
readonly statusCode?: number | undefined
readonly isRetryable: boolean
readonly responseHeaders?: { readonly [x: string]: string } | undefined
readonly responseBody?: string | undefined
readonly metadata?: { readonly [x: string]: string } | undefined
}
}
| undefined
readonly parentID: string
readonly modelID: string
readonly providerID: string
readonly mode: string
readonly agent: string
readonly path: { readonly cwd: string; readonly root: string }
readonly summary?: boolean | undefined
readonly cost: number
readonly tokens: {
readonly total?: number | undefined
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly structured?: any | undefined
readonly variant?: string | undefined
readonly finish?: string | undefined
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "message.removed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "message.part.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly part:
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "text"
readonly text: string
readonly synthetic?: boolean | undefined
readonly ignored?: boolean | undefined
readonly time?: { readonly start: number; readonly end?: number | undefined } | undefined
readonly metadata?: { readonly [x: string]: any } | undefined
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "subtask"
readonly prompt: string
readonly description: string
readonly agent: string
readonly model?: { readonly providerID: string; readonly modelID: string } | undefined
readonly command?: string | undefined
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "reasoning"
readonly text: string
readonly metadata?: { readonly [x: string]: any } | undefined
readonly time: { readonly start: number; readonly end?: number | undefined }
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "file"
readonly mime: string
readonly filename?: string | undefined
readonly url: string
readonly source?:
| (
| {
readonly text: { readonly value: string; readonly start: number; readonly end: number }
readonly type: "file"
readonly path: string
}
| {
readonly text: { readonly value: string; readonly start: number; readonly end: number }
readonly type: "symbol"
readonly path: string
readonly range: {
readonly start: { readonly line: number; readonly character: number }
readonly end: { readonly line: number; readonly character: number }
}
readonly name: string
readonly kind: number
}
| {
readonly text: { readonly value: string; readonly start: number; readonly end: number }
readonly type: "resource"
readonly clientName: string
readonly uri: string
}
)
| undefined
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "tool"
readonly callID: string
readonly tool: string
readonly state:
| { readonly status: "pending"; readonly input: { readonly [x: string]: any }; readonly raw: string }
| {
readonly status: "running"
readonly input: { readonly [x: string]: any }
readonly title?: string | undefined
readonly metadata?: { readonly [x: string]: any } | undefined
readonly time: { readonly start: number }
}
| {
readonly status: "completed"
readonly input: { readonly [x: string]: any }
readonly output: string
readonly title: string
readonly metadata: { readonly [x: string]: any }
readonly time: {
readonly start: number
readonly end: number
readonly compacted?: number | undefined
}
readonly attachments?:
| ReadonlyArray<{
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "file"
readonly mime: string
readonly filename?: string | undefined
readonly url: string
readonly source?:
| (
| {
readonly text: {
readonly value: string
readonly start: number
readonly end: number
}
readonly type: "file"
readonly path: string
}
| {
readonly text: {
readonly value: string
readonly start: number
readonly end: number
}
readonly type: "symbol"
readonly path: string
readonly range: {
readonly start: { readonly line: number; readonly character: number }
readonly end: { readonly line: number; readonly character: number }
}
readonly name: string
readonly kind: number
}
| {
readonly text: {
readonly value: string
readonly start: number
readonly end: number
}
readonly type: "resource"
readonly clientName: string
readonly uri: string
}
)
| undefined
}>
| undefined
}
| {
readonly status: "error"
readonly input: { readonly [x: string]: any }
readonly error: string
readonly metadata?: { readonly [x: string]: any } | undefined
readonly time: { readonly start: number; readonly end: number }
}
readonly metadata?: { readonly [x: string]: any } | undefined
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "step-start"
readonly snapshot?: string | undefined
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "step-finish"
readonly reason: string
readonly snapshot?: string | undefined
readonly cost: number
readonly tokens: {
readonly total?: number | undefined
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "snapshot"
readonly snapshot: string
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "patch"
readonly hash: string
readonly files: ReadonlyArray<string>
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "agent"
readonly name: string
readonly source?: { readonly value: string; readonly start: number; readonly end: number } | undefined
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "retry"
readonly attempt: number
readonly error: {
readonly name: "APIError"
readonly data: {
readonly message: string
readonly statusCode?: number | undefined
readonly isRetryable: boolean
readonly responseHeaders?: { readonly [x: string]: string } | undefined
readonly responseBody?: string | undefined
readonly metadata?: { readonly [x: string]: string } | undefined
}
}
readonly time: { readonly created: number }
}
| {
readonly id: string
readonly sessionID: string
readonly messageID: string
readonly type: "compaction"
readonly auto: boolean
readonly overflow?: boolean | undefined
readonly tail_start_id?: string | undefined
}
readonly time: number
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "message.part.removed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string; readonly partID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.agent.switched"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly agent: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.model.switched"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.moved"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subdirectory?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.renamed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly title: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.forked"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly parentID: string
readonly messageID?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.prompted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
}
readonly delivery: "steer" | "queue"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.prompt.admitted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly prompt: {
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly mime: string
readonly name?: string
readonly description?: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly source?: { readonly start: number; readonly end: number; readonly text: string }
}>
}
readonly delivery: "steer" | "queue"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.context.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.synthetic"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.shell.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly callID: string
readonly command: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.shell.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly callID: string
readonly output: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.step.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly snapshot?: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.step.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly finish: string
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly snapshot?: string
readonly files?: ReadonlyArray<string>
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.step.failed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly error: { readonly type: "unknown"; readonly message: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.text.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.text.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly delta: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.text.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly textID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly delta: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.reasoning.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly reasoningID: string
readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.input.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly name: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.input.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly delta: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.input.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.called"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown }
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.progress"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly structured: { readonly [x: string]: unknown }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.success"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly structured: { readonly [x: string]: unknown }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.tool.failed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly assistantMessageID: string
readonly callID: string
readonly error: { readonly type: "unknown"; readonly message: string }
readonly result?: unknown
readonly provider: {
readonly executed: boolean
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.retried"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly attempt: number
readonly error: {
readonly message: string
readonly statusCode?: number
readonly isRetryable: boolean
readonly responseHeaders?: { readonly [x: string]: string }
readonly responseBody?: string
readonly metadata?: { readonly [x: string]: string }
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.compaction.started"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly reason: "auto" | "manual"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.compaction.delta"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly text: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.compaction.ended"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly messageID: string
readonly reason: "auto" | "manual"
readonly text: string
readonly recent: string
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.revert.staged"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly timestamp: number
readonly sessionID: string
readonly revert: {
readonly messageID: string
readonly partID?: string
readonly snapshot?: string
readonly diff?: string
readonly files?: ReadonlyArray<{
readonly path: string
readonly status: "added" | "modified" | "deleted"
readonly additions: number
readonly deletions: number
readonly patch: string
}>
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.revert.cleared"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.next.revert.committed"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.edited"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reference.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "permission.v2.asked"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly id: string
readonly sessionID: string
readonly action: string
readonly resources: ReadonlyArray<string>
readonly save?: ReadonlyArray<string>
readonly metadata?: { readonly [x: string]: unknown }
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "permission.v2.replied"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly requestID: string
readonly reply: "once" | "always" | "reject"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "plugin.added"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "project.directories.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly projectID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.watcher.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "pty.created"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly info: {
readonly id: string
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "pty.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly info: {
readonly id: string
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "pty.exited"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string; readonly exitCode: number }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "pty.deleted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.created"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly info: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.exited"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly id: string
readonly exit?: number
readonly status: "running" | "exited" | "timeout" | "killed"
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.deleted"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly id: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "question.v2.asked"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly id: string
readonly sessionID: string
readonly questions: ReadonlyArray<{
readonly question: string
readonly header: string
readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }>
readonly multiple?: boolean
readonly custom?: boolean
}>
readonly tool?: { readonly messageID: string; readonly callID: string }
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "question.v2.replied"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly requestID: string
readonly answers: ReadonlyArray<ReadonlyArray<string>>
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "question.v2.rejected"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly requestID: string }
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "todo.updated"
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly todos: ReadonlyArray<{ readonly content: string; readonly status: string; readonly priority: string }>
}
}
| {
readonly id: string
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } | undefined
readonly location?: { readonly directory: string; readonly workspaceID?: string } | undefined
readonly type: "server.connected"
readonly data: {}
}
export type PtysListInput = {
export type PtyListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtysListOutput = {
export type PtyListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2607,7 +4030,7 @@ export type PtysListOutput = {
}>
}
export type PtysCreateInput = {
export type PtyCreateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
@@ -2648,7 +4071,7 @@ export type PtysCreateInput = {
}["env"]
}
export type PtysCreateOutput = {
export type PtyCreateOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2666,14 +4089,14 @@ export type PtysCreateOutput = {
}
}
export type PtysGetInput = {
export type PtyGetInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtysGetOutput = {
export type PtyGetOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2691,7 +4114,7 @@ export type PtysGetOutput = {
}
}
export type PtysUpdateInput = {
export type PtyUpdateInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2703,7 +4126,7 @@ export type PtysUpdateInput = {
readonly size?: { readonly title?: string; readonly size?: { readonly rows: number; readonly cols: number } }["size"]
}
export type PtysUpdateOutput = {
export type PtyUpdateOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2721,22 +4144,176 @@ export type PtysUpdateOutput = {
}
}
export type PtysRemoveInput = {
export type PtyRemoveInput = {
readonly ptyID: { readonly ptyID: string }["ptyID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PtysRemoveOutput = void
export type PtyRemoveOutput = void
export type QuestionsListRequestsInput = {
export type ShellListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type QuestionsListRequestsOutput = {
export type ShellListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: ReadonlyArray<{
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}>
}
export type ShellCreateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly command: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["command"]
readonly cwd?: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["cwd"]
readonly timeout?: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["timeout"]
readonly metadata?: {
readonly command: string
readonly cwd?: string
readonly timeout?: number
readonly metadata?: { readonly [x: string]: JsonValue }
}["metadata"]
}
export type ShellCreateOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
}
export type ShellGetInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellGetOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
}
export type ShellOutputInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["location"]
readonly cursor?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["cursor"]
readonly limit?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly cursor?: number | undefined
readonly limit?: number | undefined
}["limit"]
}
export type ShellOutputOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
export type ShellRemoveInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ShellRemoveOutput = void
export type QuestionListRequestsInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type QuestionListRequestsOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2756,9 +4333,9 @@ export type QuestionsListRequestsOutput = {
}>
}
export type QuestionsListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type QuestionsListOutput = {
export type QuestionListOutput = {
readonly data: ReadonlyArray<{
readonly id: string
readonly sessionID: string
@@ -2773,28 +4350,28 @@ export type QuestionsListOutput = {
}>
}["data"]
export type QuestionsReplyInput = {
export type QuestionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
}
export type QuestionsReplyOutput = void
export type QuestionReplyOutput = void
export type QuestionsRejectInput = {
export type QuestionRejectInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
}
export type QuestionsRejectOutput = void
export type QuestionRejectOutput = void
export type ReferencesListInput = {
export type ReferenceListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ReferencesListOutput = {
export type ReferenceListOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
@@ -2817,7 +4394,7 @@ export type ReferencesListOutput = {
}>
}
export type ProjectCopiesCreateInput = {
export type ProjectCopyCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2827,9 +4404,9 @@ export type ProjectCopiesCreateInput = {
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
}
export type ProjectCopiesCreateOutput = { readonly directory: string }
export type ProjectCopyCreateOutput = { readonly directory: string }
export type ProjectCopiesRemoveInput = {
export type ProjectCopyRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -2838,13 +4415,13 @@ export type ProjectCopiesRemoveInput = {
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type ProjectCopiesRemoveOutput = void
export type ProjectCopyRemoveOutput = void
export type ProjectCopiesRefreshInput = {
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectCopiesRefreshOutput = void
export type ProjectCopyRefreshOutput = void
+2 -1
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>
@@ -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")
+22 -22
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"),
})
+130 -43
View File
@@ -7,25 +7,28 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client)).toEqual([
"health",
"location",
"agents",
"sessions",
"messages",
"models",
"providers",
"integrations",
"credentials",
"permissions",
"files",
"commands",
"skills",
"events",
"ptys",
"questions",
"references",
"projectCopies",
"agent",
"session",
"message",
"model",
"generate",
"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",
@@ -34,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) => {
@@ -49,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 () =>
@@ -65,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",
})
@@ -112,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" } })
@@ -179,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 () =>
@@ -197,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)
+6 -1
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] })
+2 -2
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 }))
})
}),
})
+18 -2
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 } : {}),
},
})
+109
View File
@@ -0,0 +1,109 @@
export * as Generate from "./generate"
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "./effect/app-node"
import { llmClient } from "./effect/app-node-platform"
import { Integration } from "./integration"
import { ModelV2 } from "./model"
import { SessionRunnerModel } from "./session/runner/model"
export interface TextInput {
readonly prompt: string
readonly model?: ModelV2.Ref
}
export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionError>()(
"Generate.ModelSelectionError",
{ message: Schema.String },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
"Generate.UnavailableError",
{ message: Schema.String, service: Schema.optional(Schema.String) },
) {}
export type Error = ModelSelectionError | UnavailableError
export interface Interface {
readonly text: (input: TextInput) => Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Generate") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const llm = yield* LLMClient.Service
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model.default().pipe(
Effect.flatMap((model) =>
model && SessionRunnerModel.supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
),
)
if (!selected)
return yield* new ModelSelectionError({
message: requested
? `Model unavailable: ${requested.providerID}/${requested.id}`
: "No model specified and no supported model is available",
})
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
Effect.mapError(
() =>
new ModelSelectionError({
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
}),
),
)
})
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
const selected = yield* selectModel(input.model)
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
Effect.mapError((error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: selected.providerID }),
),
)
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: LLMError) =>
new UnavailableError({
message: error.message,
service: selected.providerID,
}),
),
)
return response.text
})
const text: Interface["text"] = (input) =>
runText(input).pipe(
Effect.catchTag(
"Integration.Authorization",
() =>
new UnavailableError({
message: "Generation credentials are unavailable",
}),
),
)
return Service.of({ text })
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
@@ -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)
+8
View File
@@ -9,6 +9,7 @@ import { Node } from "./effect/app-node"
import { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
import { Watcher } from "./filesystem/watcher"
import { Image } from "./image"
import { Integration } from "./integration"
@@ -19,13 +20,16 @@ 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"
import { Shell } from "./shell"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionCompaction } from "./session/compaction"
import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
@@ -40,6 +44,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,
@@ -57,6 +62,7 @@ export const locationServices = LayerNode.group([
FileSystem.node,
Watcher.node,
Pty.node,
Shell.node,
SkillV2.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
@@ -71,9 +77,11 @@ export const locationServices = LayerNode.group([
ReferenceGuidance.node,
SessionTodo.node,
QuestionV2.node,
Generate.node,
ReadToolFileSystem.node,
BuiltInTools.node,
SessionRunnerModel.node,
SessionCompaction.node,
Snapshot.node,
SessionRunnerLLM.node,
])
+4 -3
View File
@@ -8,7 +8,9 @@ import { Global } from "../global"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
const TRUNCATION_GLOB = path.join(Global.Path.data, "tool-output", "*")
// Combined output files written by the Shell service, e.g. `<data>/shell/<projectID>/<shellID>.out`.
// Whitelisted so agents can read a command's full captured output without an external-directory prompt.
const SHELL_OUTPUT_GLOB = path.join(Global.Path.data, "shell", "*", "*")
const BUILD_SYSTEM =
"You are an AI coding agent. Help the user accomplish software engineering tasks by inspecting the workspace, making targeted changes, and using tools according to the configured permissions."
@@ -102,7 +104,7 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const location = yield* Location.Service
const worktree = location.directory
const whitelistedDirs = [TRUNCATION_GLOB, path.join(Global.Path.tmp, "*")]
const whitelistedDirs = [SHELL_OUTPUT_GLOB, path.join(Global.Path.tmp, "*")]
const readonlyExternalDirectory: PermissionV2.Ruleset = [
{ action: "external_directory", resource: "*", effect: "ask" },
...whitelistedDirs.map(
@@ -124,7 +126,6 @@ export const Plugin = define({
yield* ctx.agent.transform((draft) => {
draft.update(AgentV2.defaultID, (item) => {
item.description = "The default agent. Executes tools based on configured permissions."
item.system ??= BUILD_SYSTEM
item.mode = "primary"
item.permissions.push(
...PermissionV2.merge(defaults, [
+6 -1
View File
@@ -31,6 +31,7 @@ import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SdkPlugins } from "./sdk"
import { SkillPlugin } from "./skill"
import { VariantPlugin } from "./variant"
@@ -65,6 +66,7 @@ const layer = Layer.effectDiscard(
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const sdkPlugins = yield* SdkPlugins.Service
const integration = yield* Integration.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
@@ -112,13 +114,15 @@ 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(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.
for (const plugin of sdkPlugins.all()) yield* add(plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}),
@@ -151,5 +155,6 @@ export const node = makeLocationNode({
httpClient,
SkillV2.node,
Reference.node,
SdkPlugins.node,
],
})
+37
View File
@@ -0,0 +1,37 @@
export * as SdkPlugins from "./sdk"
import type { Plugin } from "@opencode-ai/plugin/v2/effect"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/app-node"
/**
* Holds the plugins an embedder (the `@opencode-ai/sdk-next` host) contributes,
* so `PluginInternal` can add them on every Location boot through the ordinary
* `ctx.plugin.add` seam — the same path `ConfigExternalPlugin` uses for plugins
* discovered from config. A plugin registered after a Location has booted only
* applies to Locations booted afterward, matching config-plugin timing;
* embedders register at startup before creating Sessions.
*
* State lives in this global-node service (like `ApplicationTools`) rather than
* module scope, so the list belongs to one embedded instance and is disposed
* with it instead of leaking across `OpenCode.create` calls.
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly Plugin[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
export const layer = Layer.effect(
Service,
Effect.sync(() => {
const plugins: Plugin[] = []
return Service.of({
register: (plugin) => Effect.sync(() => void plugins.push(plugin)),
all: () => plugins,
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+8 -2
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 {
+5 -14
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 {
+12
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"),
+3 -3
View File
@@ -8,7 +8,7 @@ import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { PtyID } from "./pty/schema"
import { Shell } from "./shell"
import { ShellSelect } from "./shell/select"
import { lazy } from "./util/lazy"
const BUFFER_LIMIT = 1024 * 1024 * 2
@@ -164,8 +164,8 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const command = input.command || Shell.preferred(Config.latest(yield* config.entries(), "shell"))
const args = Shell.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const command = input.command || ShellSelect.preferred(Config.latest(yield* config.entries(), "shell"))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
const env = {
...process.env,
+71 -8
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"
@@ -33,6 +33,7 @@ import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot"
import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
@@ -87,7 +88,11 @@ type CreateInput = CreateBaseInput &
type CompactInput = {
sessionID: SessionSchema.ID
prompt?: Prompt
}
type ForkInput = {
sessionID: SessionSchema.ID
messageID?: SessionMessage.ID
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
@@ -113,11 +118,18 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
export type Error =
| NotFoundError
| MessageDecodeError
| OperationUnavailableError
| PromptConflictError
| BusyError
| 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
@@ -169,11 +181,14 @@ export interface Interface {
skill: string
resume?: boolean
}) => Effect.Effect<void, OperationUnavailableError>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
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
@@ -197,6 +212,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) =>
@@ -270,6 +286,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 })
@@ -370,7 +409,11 @@ export const layer = Layer.effect(
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const session = yield* result.get(input.sessionID)
// A staged revert must be committed before admitting new input so the prompt
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
@@ -433,8 +476,19 @@ export const layer = Layer.effect(
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
const session = yield* result.get(input.sessionID)
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions.
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID })
const context = yield* store.context(input.sessionID)
const compacted = yield* Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
return yield* compaction.compactManual({ session, messages: context })
}).pipe(
Effect.provide(locations.get(session.location)),
Effect.catch(() => Effect.succeed(false)),
)
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" })
return undefined
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
@@ -445,6 +499,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)),
),
@@ -492,7 +556,6 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
system: input.system,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
+124 -31
View File
@@ -1,11 +1,16 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { DateTime, Effect, Stream } from "effect"
import { LLM, LLMClient, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { Context, DateTime, Effect, Layer, Stream } from "effect"
import type { Config } from "../config"
import { Config as ConfigV2 } from "../config"
import type { EventV2 } from "../event"
import { EventV2 as EventV2Service } from "../event"
import { makeLocationNode } from "../effect/app-node"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionRunnerModel } from "./runner/model"
import { SessionSchema } from "./schema"
import { Token } from "../util/token"
@@ -50,11 +55,6 @@ Rules:
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
type Entry = {
readonly seq: number
readonly message: SessionMessage.Message
}
type Settings = {
readonly auto: boolean
readonly buffer: number
@@ -69,13 +69,31 @@ type Dependencies = {
readonly config: readonly Config.Entry[]
}
type Input = {
export type AutoInput = {
readonly sessionID: SessionSchema.ID
readonly entries: readonly Entry[]
readonly model: Model
readonly messages: readonly SessionMessage.Message[]
readonly request: LLMRequest
}
type CompactInput = {
readonly sessionID: SessionSchema.ID
readonly messages: readonly SessionMessage.Message[]
readonly model: Model
}
export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Message[]
}
export interface Interface {
readonly compactIfNeeded: (input: AutoInput) => Effect.Effect<boolean>
readonly compactAfterOverflow: (input: AutoInput) => Effect.Effect<boolean>
readonly compactManual: (input: ManualInput) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionCompaction") {}
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
const truncate = (value: string) =>
@@ -131,14 +149,14 @@ const settings = (documents: readonly Config.Entry[]) => {
}
const select = (
entries: readonly Entry[],
messages: readonly SessionMessage.Message[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = entries
.filter((entry) => entry.message.type !== "compaction")
.map((entry) => serialize(entry.message))
const conversation = messages
.filter((message) => message.type !== "compaction")
.map(serialize)
.filter(Boolean)
if (conversation.length === 0) return
if (conversation.length === 0) return undefined
let total = 0
let split = conversation.length
let splitPrefix = ""
@@ -172,19 +190,21 @@ export const buildPrompt = (input: { readonly previousSummary?: string; readonly
...input.context,
].join("\n\n")
export const make = (dependencies: Dependencies) => {
const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
const compact = Effect.fn("SessionCompaction.compact")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly model: Model
readonly reason: SessionMessage.Compaction["reason"]
readonly previousSummary?: string
readonly context: readonly string[]
readonly recent: string
readonly output?: number
}) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
const selected = select(input.entries, config.tokens)
const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
const summaryPrompt = buildPrompt({
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
})
const output = input.output ?? input.model.route.defaults.limits?.output ?? 0
const summaryPrompt = buildPrompt({ previousSummary: input.previousSummary, context: input.context })
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
const messageID = SessionMessage.ID.create()
@@ -192,7 +212,7 @@ export const make = (dependencies: Dependencies) => {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
reason: input.reason,
})
const chunks: string[] = []
@@ -221,17 +241,58 @@ export const make = (dependencies: Dependencies) => {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
reason: input.reason,
text: summary,
recent: selected.recent,
recent: input.recent,
})
return true
})
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) {
if (!config.auto) return false
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: AutoInput) {
return yield* compactSelected({
sessionID: input.sessionID,
messages: input.messages,
model: input.request.model,
reason: "auto",
force: false,
output: input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0,
})
})
const compactSelected = Effect.fn("SessionCompaction.compactSelected")(function* (
input: CompactInput & {
readonly reason: SessionMessage.Compaction["reason"]
readonly force: boolean
readonly output?: number
},
) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
const selected = select(input.messages, config.tokens)
if (!selected) return false
const previousSummary = input.messages.find((message) => message.type === "compaction")
const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead
const previousRecent = previousSummary?.type === "compaction" ? previousSummary.recent : ""
return yield* compact({
sessionID: input.sessionID,
model: input.model,
reason: input.reason,
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: (forcedShortContext ? [previousRecent, selected.recent] : [previousRecent, selected.head]).filter(
Boolean,
),
recent: forcedShortContext ? "" : selected.recent,
output: input.output,
})
})
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: CompactInput) {
return yield* compactSelected({ ...input, reason: "manual", force: true })
})
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: AutoInput) {
if (!config.auto) return false
const context = input.request.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.request.model.route.defaults.limits?.output ?? 0
if (
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
context - Math.max(output, config.buffer)
@@ -242,5 +303,37 @@ export const make = (dependencies: Dependencies) => {
return {
compactIfNeeded,
compactAfterOverflow,
compactManual,
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Service.Service
const llm = yield* LLMClient.Service
const config = yield* ConfigV2.Service
const models = yield* SessionRunnerModel.Service
const compaction = make({ events, llm, config: yield* config.entries() })
return Service.of({
compactIfNeeded: compaction.compactIfNeeded,
compactAfterOverflow: compaction.compactAfterOverflow,
compactManual: Effect.fn("SessionCompaction.compactManual")(function* (input) {
const model = yield* models.resolve(input.session).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!model) return false
return yield* compaction.compactManual({
sessionID: input.session.id,
messages: input.messages,
model,
})
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2Service.node, llmClient, ConfigV2.node, SessionRunnerModel.node],
})
+1 -1
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({
@@ -133,7 +134,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
system: event.data.prompt.system,
time: { created: event.data.timestamp },
}),
)
+202 -2
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")
+25 -25
View File
@@ -10,7 +10,6 @@ import {
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
@@ -35,6 +34,7 @@ import { SessionRunnerModel } from "./model"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { SessionRunnerSystemPrompt } from "./system-prompt"
import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
@@ -102,10 +102,9 @@ export const layer = Layer.effect(
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const config = yield* Config.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const compaction = yield* SessionCompaction.Service
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
@@ -194,23 +193,22 @@ export const layer = Layer.effect(
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
// Mirror V1 (session/llm/request.ts): append the current turn's per-request system string after the
// agent prompt and durable baseline. The current turn's user prompt is the latest user message in context.
const turnSystem = context.findLast((message) => message.type === "user")?.system
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,
providerOptions: { openai: { promptCacheKey } },
system: [agent.info?.system, system.baseline, turnSystem]
system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
return yield* Effect.die(continueAfterCompaction(currentStep))
const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, {
@@ -223,9 +221,9 @@ export const layer = Layer.effect(
},
snapshot: startSnapshot,
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publication = Semaphore.makeUnsafe(1)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
publication.withPermit(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
@@ -240,7 +238,9 @@ export const layer = Layer.effect(
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
yield* publication.withPermit(
publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"),
)
return
}
needsContinuation = true
@@ -269,7 +269,7 @@ export const layer = Layer.effect(
).pipe(FiberSet.run(toolFibers))
}),
),
Effect.ensuring(withPublication(publisher.flush())),
Effect.ensuring(publication.withPermit(publisher.flush())),
)
return yield* Effect.uninterruptibleMask((restore) =>
@@ -281,14 +281,14 @@ export const layer = Layer.effect(
recoverOverflow &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? failure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
return yield* Effect.die(continueAfterOverflowCompaction(currentStep))
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = failure instanceof LLMError ? failure : undefined
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(publisher.failAssistant(llmFailure.reason.message))
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* publication.withPermit(publisher.failAssistant(llmFailure.reason.message))
}
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
@@ -296,19 +296,19 @@ export const layer = Layer.effect(
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
return yield* Effect.interrupt
}
if (streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* withPublication(publisher.failAssistant("Provider turn interrupted"))
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
yield* publication.withPermit(publisher.failAssistant("Provider turn interrupted"))
}
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
const failure = Cause.squash(settled.cause)
const message = failure instanceof Error ? failure.message : String(failure)
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
yield* publication.withPermit(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = publisher.stepSettlement()
if (stepSettlement && !streamInterrupted && !toolsInterrupted && !publisher.hasProviderError()) {
@@ -319,7 +319,7 @@ export const layer = Layer.effect(
.files({ from: startSnapshot, to: endSnapshot })
.pipe(Effect.catch(() => Effect.succeed(undefined)))
: undefined
yield* withPublication(
yield* publication.withPermit(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
timestamp: yield* DateTime.now,
@@ -333,9 +333,9 @@ export const layer = Layer.effect(
)
}
if (publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
yield* publication.withPermit(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !publisher.hasProviderError())
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* publication.withPermit(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause))
return yield* Effect.failCause(settled.cause)
@@ -424,7 +424,7 @@ export const node = makeLocationNode({
SystemContextRegistry.node,
SkillGuidance.node,
ReferenceGuidance.node,
Config.node,
SessionCompaction.node,
Snapshot.node,
Database.node,
],
+1 -1
View File
@@ -101,7 +101,7 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
})
}
const withVariant = (
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
@@ -0,0 +1,105 @@
You are OpenCode, the best coding agent on the planet.
You are 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.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- ctrl+p to list available actions
- 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
# 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 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 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.
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:
- Run the build
- Fix any type errors
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.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<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.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# 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
- 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 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.
- 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 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 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 subagent tool]
</example>
IMPORTANT: Always use the todowrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
@@ -0,0 +1,147 @@
You are opencode, an agent - please keep going until the users query is completely resolved, before ending your turn and yielding back to the user.
Your thinking should be thorough and so it's fine if it's very long. However, avoid unnecessary repetition and verbosity. You should be concise, but thorough.
You MUST iterate and keep going until the problem is solved.
You have everything you need to resolve this problem. I want you to fully solve this autonomously before coming back to me.
Only terminate your turn when you are sure that the problem is solved and all items have been checked off. Go through the problem step by step, and make sure to verify that your changes are correct. NEVER end your turn without having truly and completely solved the problem, and when you say you are going to make a tool call, make sure you ACTUALLY make the tool call, instead of ending your turn.
THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH.
You must use the webfetch tool to recursively gather all information from URL's provided to you by the user, as well as any links you find in the content of those pages.
Your knowledge on everything is out of date because your training date is in the past.
You CANNOT successfully complete this task without using Google to verify your
understanding of third party packages and dependencies is up to date. You must use the webfetch tool to search google for how to properly use libraries, packages, frameworks, dependencies, etc. every single time you install or implement one. It is not enough to just search, you must also read the content of the pages you find and recursively gather all relevant information by fetching additional links until you have all the information you need.
Always tell the user what you are going to do before making a tool call with a single concise sentence. This will help them understand what you are doing and why.
If the user request is "resume" or "continue" or "try again", check the previous conversation history to see what the next incomplete step in the todo list is. Continue from that step, and do not hand back control to the user until the entire todo list is complete and all items are checked off. Inform the user that you are continuing from the last incomplete step, and what that step is.
Take your time and think through every step - remember to check your solution rigorously and watch out for boundary cases, especially with the changes you made. Use the sequential thinking tool if available. Your solution must be perfect. If not, continue working on it. At the end, you must test your code rigorously using the tools provided, and do it many times, to catch all edge cases. If it is not robust, iterate more and make it perfect. Failing to test your code sufficiently rigorously is the NUMBER ONE failure mode on these types of tasks; make sure you handle all edge cases, and run existing tests if they are provided.
You MUST plan extensively before each function call, and reflect extensively on the outcomes of the previous function calls. DO NOT do this entire process by making function calls only, as this can impair your ability to solve the problem and think insightfully.
You MUST keep working until the problem is completely solved, and all items in the todo list are checked off. Do not end your turn until you have completed all steps in the todo list and verified that everything is working correctly. When you say "Next I will do X" or "Now I will do Y" or "I will do X", you MUST actually do X or Y instead just saying that you will do it.
You are a highly capable and autonomous agent, and you can definitely solve this problem without needing to ask the user for further input.
# Workflow
1. Fetch any URL's provided by the user using the `webfetch` tool.
2. Understand the problem deeply. Carefully read the issue and think critically about what is required. Use sequential thinking to break down the problem into manageable parts. Consider the following:
- What is the expected behavior?
- What are the edge cases?
- What are the potential pitfalls?
- How does this fit into the larger context of the codebase?
- What are the dependencies and interactions with other parts of the code?
3. Investigate the codebase. Explore relevant files, search for key functions, and gather context.
4. Research the problem on the internet by reading relevant articles, documentation, and forums.
5. Develop a clear, step-by-step plan. Break down the fix into manageable, incremental steps. Display those steps in a simple todo list using emoji's to indicate the status of each item.
6. Implement the fix incrementally. Make small, testable code changes.
7. Debug as needed. Use debugging techniques to isolate and resolve issues.
8. Test frequently. Run tests after each change to verify correctness.
9. Iterate until the root cause is fixed and all tests pass.
10. Reflect and validate comprehensively. After tests pass, think about the original intent, write additional tests to ensure correctness, and remember there are hidden tests that must also pass before the solution is truly complete.
Refer to the detailed sections below for more information on each step.
## 1. Fetch Provided URLs
- If the user provides a URL, use the `webfetch` tool to retrieve the content of the provided URL.
- After fetching, review the content returned by the webfetch tool.
- If you find any additional URLs or links that are relevant, use the `webfetch` tool again to retrieve those links.
- Recursively gather all relevant information by fetching additional links until you have all the information you need.
## 2. Deeply Understand the Problem
Carefully read the issue and think hard about a plan to solve it before coding.
## 3. Codebase Investigation
- Explore relevant files and directories.
- Search for key functions, classes, or variables related to the issue.
- Read and understand relevant code snippets.
- Identify the root cause of the problem.
- Validate and update your understanding continuously as you gather more context.
## 4. Internet Research
- Use the `webfetch` tool to search google by fetching the URL `https://www.google.com/search?q=your+search+query`.
- After fetching, review the content returned by the fetch tool.
- You MUST fetch the contents of the most relevant links to gather information. Do not rely on the summary that you find in the search results.
- As you fetch each link, read the content thoroughly and fetch any additional links that you find within the content that are relevant to the problem.
- Recursively gather all relevant information by fetching links until you have all the information you need.
## 5. Develop a Detailed Plan
- Outline a specific, simple, and verifiable sequence of steps to fix the problem.
- Create a todo list in markdown format to track your progress.
- Each time you complete a step, check it off using `[x]` syntax.
- Each time you check off a step, display the updated todo list to the user.
- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next.
## 6. Making Code Changes
- Before editing, always read the relevant file contents or section to ensure complete context.
- Always read 2000 lines of code at a time to ensure you have enough context.
- If a patch is not applied correctly, attempt to reapply it.
- Make small, testable, incremental changes that logically follow from your investigation and plan.
- Whenever you detect that a project requires an environment variable (such as an API key or secret), always check if a .env file exists in the project root. If it does not exist, automatically create a .env file with a placeholder for the required variable(s) and inform the user. Do this proactively, without waiting for the user to request it.
## 7. Debugging
- Make code changes only if you have high confidence they can solve the problem
- When debugging, try to determine the root cause rather than addressing symptoms
- Debug for as long as needed to identify the root cause and identify a fix
- Use print statements, logs, or temporary code to inspect program state, including descriptive statements or error messages to understand what's happening
- To test hypotheses, you can also add test statements or functions
- Revisit your assumptions if unexpected behavior occurs.
# Communication Guidelines
Always communicate clearly and concisely in a casual, friendly yet professional tone.
<examples>
"Let me fetch the URL you provided to gather more information."
"Ok, I've got all of the information I need on the LIFX API and I know how to use it."
"Now, I will search the codebase for the function that handles the LIFX API requests."
"I need to update several files here - stand by"
"OK! Now let's run the tests to make sure everything is working correctly."
"Whelp - I see we have some problems. Let's fix those up."
</examples>
- Respond with clear, direct answers. Use bullet points and code blocks for structure. - Avoid unnecessary explanations, repetition, and filler.
- Always write code directly to the correct files.
- Do not display code to the user unless they specifically ask for it.
- Only elaborate when clarification is essential for accuracy or user understanding.
# Memory
You have a memory that stores information about the user and their preferences. This memory is used to provide a more personalized experience. You can access and update this memory as needed. The memory is stored in a file called `.github/instructions/memory.instruction.md`. If the file is empty, you'll need to create it.
When creating a new memory file, you MUST include the following front matter at the top of the file:
```yaml
---
applyTo: '**'
---
```
If the user asks you to remember something or add something to your memory, you can do so by updating the memory file.
# Reading Files and Folders
**Always check if you have already read a file, folder, or workspace structure before reading it again.**
- If you have already read the content and it has not changed, do NOT re-read it.
- Only re-read files or folders if:
- You suspect the content has changed since your last read.
- You have made edits to the file or folder.
- You encounter an error that suggests the context may be stale or incomplete.
- Use your internal memory and previous context to avoid redundant reads.
- This will save time, reduce unnecessary operations, and make your workflow more efficient.
# Writing Prompts
If you are asked to write a prompt, you should always generate the prompt in markdown format.
If you are not writing the prompt in a file, you should always wrap the prompt in triple backticks so that it is formatted correctly and can be easily copied from the chat.
Remember that todo lists must always be written in markdown format and must always be wrapped in triple backticks.
# Git
If the user tells you to stage and commit, you may do so.
You are NEVER allowed to stage and commit files automatically.
@@ -0,0 +1,79 @@
You are OpenCode, the best coding agent on the planet.
You are 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.
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## 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 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
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend commits unless explicitly requested.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into bland, generic layouts.
Aim for interfaces that feel intentional and deliberate.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile.
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Default: do the work without asking questions. Treat short tasks as sufficient direction; infer missing details by reading the codebase and following existing conventions.
- Questions: only ask when you are truly blocked after checking relevant context AND you cannot safely pick a reasonable default. This usually means one of:
* The request is ambiguous in a way that materially changes the result and you cannot disambiguate by reading the repo.
* The action is destructive/irreversible, touches production, or changes billing/security posture.
* You need a secret/credential/value that cannot be inferred (API key, account id, etc.).
- If you must ask: do all non-blocked work first, then ask exactly one targeted question, include your recommended default, and state what would change based on the answer.
- Never ask permission questions like "Should I proceed?" or "Do you want me to run tests?"; proceed with the most reasonable option and mention what you did.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
## Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scannability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
@@ -0,0 +1,95 @@
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.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
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
# Tone and style
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 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.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# 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 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 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 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.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
@@ -0,0 +1,155 @@
You are opencode, an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
# Core Mandates
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments.
- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Path Construction:** Before using any file system tool (e.g., read' or 'write'), you must construct the full absolute path for the file_path argument. Always combine the absolute path of the project's root directory with the file's path relative to the root. For example, if the project root is /path/to/project/ and the file is foo/bar/baz.txt, the final path you must use is /path/to/project/foo/bar/baz.txt. If the user provides a relative path, you must resolve it against the root directory to create an absolute path.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
# Primary Workflows
## Software Engineering Tasks
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' '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 '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 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.
# Operational Guidelines
## Tone and Style (CLI Interaction)
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
- **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 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 '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.
## Interaction Details
- **Help Command:** The user can use '/help' to display help information.
- **Feedback:** To report a bug or provide feedback, please use the /bug command.
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: list files here.
model: [tool_call: ls for path '/path/to/project']
</example>
<example>
user: start the server implemented in server.js
model: [tool_call: shell for 'node server.js &' because it must run in the background]
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
[tool_call: glob for path 'tests/test_auth.py']
[tool_call: read for absolute_path '/path/to/tests/test_auth.py']
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
[tool_call: read for absolute_path '/path/to/requirements.txt']
(After analysis)
Looks good, 'requests' is available.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
Should I proceed?
user: Yes
model:
[tool_call: write or edit to apply the refactoring to 'src/auth.py']
Refactoring complete. Running verification...
[tool_call: shell for 'ruff check src/auth.py && pytest']
(After verification passes)
All checks passed. This is a stable checkpoint.
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read \`someFile.ts\` to understand its functionality.
[tool_call: read for absolute_path '/path/to/someFile.ts' or use glob to find \`someFile.ts\` if its location is unknown]
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
[tool_call: glob for paths ['**/*.test.ts', 'src/**/*.spec.ts'] assuming someFile.ts is in the src directory]
(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: shell for 'npm run test']
</example>
<example>
user: How do I update the user's profile information in this system?
model:
I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
Okay, \`/path/to/UserProfileService.java\` seems like the most relevant file. I'll read its content to understand how updates are handled.
[tool_call: read for absolute_path '/path/to/UserProfileService.java']
(After reading the file)
It appears the \`updateUserProfile\` method in \`UserProfileService.java\` is responsible for this. It expects a user ID and a \`UserProfileDTO\` object...
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
[tool_call: glob for pattern '**/app.config']
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
# Final Reminder
Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
@@ -0,0 +1,107 @@
You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.
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. 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
- The best changes are often the smallest correct changes.
- When you are weighing two correct approaches, prefer the more minimal one (less new names, helpers, tests, etc).
- Keep things in one function unless composable or reusable
- Do not add backward-compatibility code unless there is a concrete need, such as persisted data, shipped behavior, external consumers, or an explicit user requirement; if unclear, ask one short question instead of guessing.
## Autonomy and persistence
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.
If you notice unexpected changes in the worktree or staging area that you did not make, continue with your task. NEVER revert, undo, or modify changes you did not make unless the user explicitly asks you to. There can be multiple agents or the user working in the same codebase concurrently.
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch.
- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them, or were autogenerated. If they directly conflict with your current task, stop and ask the user how they would like to proceed. Otherwise, focus on the task at hand.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.
## Special user requests
If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
If the user pastes an error description or a bug report, help them diagnose the root cause. You can try to reproduce it if it seems feasible with the available tools and skills.
If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
- Ensure the page loads properly on both desktop and mobile
- For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
# Working with the user
## General
Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question, ") or framing phrases.
Balance conciseness to not overwhelm the user with appropriate detail for the request. Do not narrate abstractly; explain what you are doing and why.
Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
## Formatting rules
Your responses are rendered as GitHub-flavored Markdown.
Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
Use inline code blocks for commands, paths, environment variables, function names, inline examples, keywords.
Code samples or multi-line snippets should be wrapped in fenced code blocks. Include a language tag when possible.
Dont use emojis or em dashes unless explicitly instructed.
## Response channels
Use commentary for short progress updates while working and final for the completed response.
### `commentary` channel
Only use `commentary` for intermediary updates. These are short updates while you are working, they are NOT final answers. Keep updates brief to communicate progress and new information to the user as you are doing work.
Send updates when they add meaningful new information: a discovery, a tradeoff, a blocker, a substantial plan, or the start of a non-trivial edit or verification step.
Do not narrate routine reads, searches, obvious next steps, or minor confirmations. Combine related progress into a single update.
Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question") or framing phrases.
Before substantial work, send a short update describing your first step. Before editing files, send an update describing the edit.
After you have sufficient context, and the work is substantial you can provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
### `final` channel
Use final for the completed response.
Structure your final response if necessary. The complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
If the user asks for a code explanation, include code references. For simple tasks, just state the outcome without heavy formatting.
For large or complex changes, lead with the solution, then explain what you did and why. For casual chat, just chat. If something couldnt be done (tests, builds, etc.), say so. Suggest next steps only when they are natural and useful; if you list options, use numbered items.
@@ -0,0 +1,95 @@
You are OpenCode, an interactive general AI agent running on a user's computer.
Your primary goal is to help users with software engineering tasks by taking action — use the tools available to you to make real changes on the user's system. You should also answer questions when asked. Always adhere strictly to the following system instructions and the user's requirements.
# Prompt and Tool Use
The user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task.
When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.
If the `task` tool is available, you can use it to delegate a focused subtask to a subagent instance. When delegating, provide a complete prompt with all necessary context because a newly created subagent does not automatically see your current context.
You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance.
The results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information.
Tool results and user messages may include `<system-reminder>` tags. These are authoritative system directives that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode).
When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.
# General Guidelines for Coding
When building something from scratch, you should:
- Understand the user's requirements.
- Ask the user for clarification if there is anything unclear.
- Design the architecture and make a plan for the implementation.
- Write the code in a modular and maintainable way.
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 `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:
- Understand the codebase by reading it with tools (`read`, `glob`, `grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve the goal.
- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes.
- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests.
- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes.
- Make MINIMAL changes to achieve the goal. This is very important to your performance.
- Follow the coding style of existing code in the project.
DO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations.
# General Guidelines for Research and Data Processing
The user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must:
- Understand the user's requirements thoroughly, ask for clarification before you start if needed.
- Make plans before doing deep or wide research, to ensure you are always on track.
- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy.
- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.
- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected.
- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation.
# Working Environment
## Operating System
The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.
## Working Directory
The working directory should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.
# Project Information
Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root.
> Why `AGENTS.md`?
>
> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or arent relevant to human contributors.
>
> We intentionally kept it separate to:
>
> - Give agents a clear, predictable place for instructions.
> - Keep `README`s concise and focused on human contributors.
> - Provide precise, agent-focused guidance that complements existing `README` and docs.
If the `AGENTS.md` is empty or insufficient, you may check `README`/`README.md` files or `AGENTS.md` files in subdirectories for more information about specific parts of the project.
If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.
# Ultimate Reminders
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.
- Never diverge from the requirements and the goals of the task you work on. Stay on track.
- Never give the user more than what they want.
- Try your best to avoid any hallucination. Do fact checking before providing any factual information.
- Think about the best approach, then take action decisively.
- Do not give up too early.
- ALWAYS, keep it stupidly simple. Do not overcomplicate things.
- When the task requires creating or modifying files, always use tools to do so. Never treat displaying code in your response as a substitute for actually writing it to the file system.
@@ -0,0 +1,97 @@
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 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 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.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep or glob to find where similar tests are defined, then read relevant files one at a time (one tool per message, wait for each result), then edit or write to add tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# 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 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 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 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.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
@@ -0,0 +1,30 @@
export * as SessionRunnerSystemPrompt from "./system-prompt"
import type { Model } from "@opencode-ai/llm"
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_DEFAULT from "./prompt/default.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_GPT from "./prompt/gpt.txt"
import PROMPT_KIMI from "./prompt/kimi.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
export function provider(model: Model) {
const id = model.id.toLowerCase()
if (id.includes("gpt-4") || id.includes("o1") || id.includes("o3")) return normalize(PROMPT_BEAST)
if (id.includes("gpt")) {
if (id.includes("codex")) return normalize(PROMPT_CODEX)
return normalize(PROMPT_GPT)
}
if (id.includes("gemini-")) return normalize(PROMPT_GEMINI)
if (id.includes("claude")) return normalize(PROMPT_ANTHROPIC)
if (id.includes("trinity")) return normalize(PROMPT_TRINITY)
if (id.includes("kimi")) return normalize(PROMPT_KIMI)
return normalize(PROMPT_DEFAULT)
}
function normalize(prompt: string) {
return prompt.replaceAll("\r\n", "\n")
}
+281 -198
View File
@@ -1,226 +1,309 @@
export * as Shell from "./shell"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "./flag/flag"
import { FSUtil } from "./fs-util"
import { which } from "./util/which"
import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { makeLocationNode } from "./effect/app-node"
import { AppProcess } from "./process"
import { Config } from "./config"
import { EventV2 } from "./event"
import { Location } from "./location"
import { Global } from "./global"
import { ShellSelect } from "./shell/select"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
fish: { deny: true, login: true },
ksh: { login: true, posix: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
id: Shell.ID,
}) {}
// Exited processes stay observable (status, exit code, retained output) until removed explicitly.
// Cap retention so abandoned commands do not accumulate unbounded state and output files.
const EXITED_LIMIT = 25
type Info = Shell.Info
type Active = {
// Immutable snapshot; lifecycle updates replace it via immer `produce`.
info: Info
file: string
size: number
// 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>
}
export type Item = {
path: string
name: string
acceptable: boolean
/**
* Location-owned non-interactive shell command process service.
*
* Each `create` spawns one shell command, captures combined stdout/stderr to a
* file, and returns an ID. Clients poll `get` for status and `output` for
* file-backed output by cursor. No session, message, or permission state lives
* here; callers (e.g. `ShellTool`) own that association and store the shell ID.
*/
export interface Interface {
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
}
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Shell") {}
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const location = yield* Location.Service
const config = yield* Config.Service
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const outputDir = path.join(global.data, "shell", location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
const { createWriteStream, createReadStream } = yield* Effect.promise(() => import("fs"))
yield* Effect.promise(() => mkdir(outputDir, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock waiters still pending at teardown; succeed is a no-op once already resolved.
yield* Deferred.fail(session.done, new NotFoundError({ id: Shell.ID.make(session.info.id) }))
}
sessions.clear()
exitOrder.length = 0
}),
)
const require = Effect.fn("Shell.require")(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
})
return
}
try {
process.kill(-pid, "SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
}
}
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* events.publish(Shell.Event.Deleted, { id })
})
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
})
function full(file: string) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
return which(shell) || shell
}
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
})
function meta(file: string) {
return META[name(file)]
}
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
return (yield* require(id)).info
})
function ok(file: string) {
return meta(file)?.deny !== true
}
const wait = Effect.fn("Shell.wait")(function* (id: Shell.ID) {
return yield* Deferred.await((yield* require(id)).done)
})
function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
bytes.copy(buffer, offset)
offset += bytes.length
})
stream.on("end", () => resolve(offset))
stream.on("error", () => resolve(0))
}),
)
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
truncated: false,
}
})
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
}
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
const id = Shell.ID.ascending()
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)
const file = path.join(outputDir, `${id}.out`)
const env = {
...process.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
function win() {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
),
)
}
const info: Info = {
id,
status: "running",
command: input.command,
cwd,
shell,
file,
metadata: input.metadata ?? {},
time: { started: Date.now() },
}
async function unix() {
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
// 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>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* appProcess.spawn(
ChildProcess.make(shell, args, {
cwd,
env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
file,
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]
return fallback()
}
const stream = createWriteStream(file)
const outputDone = Deferred.makeUnsafe<void>()
const pump = handle.all.pipe(
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
}),
),
)
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())
}),
)
export function gitbash() {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
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) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
})
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
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* events.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
status,
})
exitOrder.push(id)
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
})
function fallback() {
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}
if (input.timeout) {
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(input.timeout)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
),
)
}
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
runFork(
handle.exitCode.pipe(
Effect.flatMap((code) => finish("exited", code)),
Effect.catch(() => Effect.void),
),
)
export function login(file: string) {
return meta(file)?.login === true
}
yield* events.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catch(() => Effect.void)),
)
export function posix(file: string) {
return meta(file)?.posix === true
}
const session = yield* Deferred.await(ready)
return session.info
})
export function ps(file: string) {
return meta(file)?.ps === true
}
return Service.of({ create, list, get, wait, output, remove })
}),
)
function info(file: string): Item {
const item = full(file)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
acceptable: ok(item),
}
}
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
export function args(file: string, command: string, cwd: 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 === "cmd") return ["/c", command]
if (ps(file)) return ["-NoProfile", "-Command", command]
return ["-c", command]
}
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, Config.node, Global.node, AppProcess.node],
})
+199
View File
@@ -0,0 +1,199 @@
export * as ShellSelect from "./select"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { readFile } from "fs/promises"
import { statSync } from "fs"
import { setTimeout as sleep } from "node:timers/promises"
import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { which } from "../util/which"
const SIGKILL_TIMEOUT_MS = 200
const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
bash: { login: true, posix: true },
dash: { login: true, posix: true },
fish: { deny: true, login: true },
ksh: { login: true, posix: true },
nu: { deny: true },
powershell: { ps: true },
pwsh: { ps: true },
sh: { login: true, posix: true },
zsh: { login: true, posix: true },
}
export type Item = {
path: string
name: string
acceptable: boolean
}
export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
const pid = proc.pid
if (!pid || opts?.exited?.()) return
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
})
return
}
try {
process.kill(-pid, "SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch {
proc.kill("SIGTERM")
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
}
}
function stat(file: string) {
return statSync(file, { throwIfNoEntry: false }) ?? undefined
}
function full(file: string) {
if (process.platform !== "win32") return file
const shell = FSUtil.windowsPath(file)
if (path.win32.dirname(shell) !== ".") {
if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell
return shell
}
if (name(shell) === "bash") return gitbash() || which(shell) || shell
return which(shell) || shell
}
function meta(file: string) {
return META[name(file)]
}
function ok(file: string) {
return meta(file)?.deny !== true
}
function rooted(file: string) {
return path.isAbsolute(FSUtil.windowsPath(file))
}
function resolve(file: string) {
const shell = full(file)
if (rooted(shell)) {
if (stat(shell)?.isFile()) return shell
return
}
return which(shell) ?? undefined
}
function win() {
return Array.from(
new Set(
[which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
),
)
}
async function unix() {
const text = await readFile("/etc/shells", "utf8").catch(() => "")
if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
function select(file: string | undefined, opts?: { acceptable?: boolean }) {
if (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") return win()[0]
return fallback()
}
export function gitbash() {
if (process.platform !== "win32") return
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = which("git")
if (!git) return
const file = path.join(git, "..", "..", "bin", "bash.exe")
if (stat(file)?.size) return file
}
function fallback() {
if (process.platform === "darwin") return "/bin/zsh"
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}
export function name(file: string) {
if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase()
return path.basename(file).toLowerCase()
}
export function login(file: string) {
return meta(file)?.login === true
}
export function posix(file: string) {
return meta(file)?.posix === true
}
export function ps(file: string) {
return meta(file)?.ps === true
}
function info(file: string): Item {
const item = full(file)
const n = name(item)
return {
path: item,
name: resolve(n) ? n : item,
acceptable: ok(item),
}
}
export function args(file: string, command: string) {
const n = name(file)
if (n === "nu" || n === "fish") return ["-c", command]
if (n === "zsh" || n === "bash") return ["-c", command]
if (n === "cmd") return ["/c", command]
if (ps(file)) return ["-NoProfile", "-Command", command]
return ["-c", command]
}
let defaultPreferred: string | undefined
let defaultAcceptable: string | undefined
export function preferred(configShell?: string) {
if (configShell) return select(configShell)
defaultPreferred ??= select(process.env.SHELL)
return defaultPreferred
}
preferred.reset = () => {
defaultPreferred = undefined
}
export function acceptable(configShell?: string) {
if (configShell) return select(configShell, { acceptable: true })
defaultAcceptable ??= select(process.env.SHELL, { acceptable: true })
return defaultAcceptable
}
acceptable.reset = () => {
defaultAcceptable = undefined
}
export async function list(): Promise<Item[]> {
const shells = process.platform === "win32" ? win() : await unix()
return shells.filter((s) => resolve(s)).map(info)
}
+1 -2
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)
-195
View File
@@ -1,195 +0,0 @@
export * as BashTool from "./bash"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process"
import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "bash"
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
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
}),
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
.pipe(Schema.optional)
.annotate({
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`,
}),
})
const StructuredOutput = Schema.Struct({
exit: Schema.Number.pipe(Schema.optional),
truncated: Schema.Boolean,
timeout: Schema.Boolean.pipe(Schema.optional),
})
const Output = Schema.Struct({
...StructuredOutput.fields,
output: Schema.String,
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
type Output = typeof Output.Type
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
const modelOutput = (output: Output) => {
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
const isTimeout = (error: AppProcess.AppProcessError) =>
error.cause instanceof Error && error.cause.message === "Timed out"
/**
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
*/
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// 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: 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.
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
const externalCommandDirectories = (command: string, cwd: string) => {
const directories = new Set<string>()
for (const token of shellTokens(command)) {
const value = unquote(token).replace(/[;,|&]+$/, "")
if (!path.isAbsolute(value)) continue
const resolved = FSUtil.resolve(value)
if (FSUtil.contains(cwd, resolved)) continue
directories.add(FSUtil.resolve(path.dirname(resolved)))
}
return [...directories]
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const mutation = yield* LocationMutation.Service
const fs = yield* FSUtil.Service
const appProcess = yield* AppProcess.Service
const config = yield* Config.Service
const permission = yield* PermissionV2.Service
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.`,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
toModelOutput: ({ output }) => [
{ type: "text", text: output.output },
{ type: "text", text: modelOutput(output) },
],
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)
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("\\", "/")}. Bash 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}`))
const entries = yield* config.entries()
const shell =
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
.shell ?? defaultShell()
const command = ChildProcess.make(input.command, [], {
cwd: target.canonical,
shell,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
})
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
const result = yield* appProcess
.run(command, {
combineOutput: true,
timeout: Duration.millis(timeout),
maxOutputBytes: MAX_CAPTURE_BYTES,
})
.pipe(
Effect.catchTag("AppProcessError", (error) =>
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
),
)
if (!result) {
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,
...(warnings.length ? { warnings } : {}),
}
}
const output = result.output?.toString("utf8") || "(no output)"
const notice = result.outputTruncated
? "[output capture truncated at the in-memory safety limit]"
: undefined
return {
exit: result.exitCode,
output: notice ? `${output}\n\n${notice}` : output,
truncated: result.outputTruncated === true,
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
}),
})
.pipe(Effect.orDie)
}),
)
+7 -9
View File
@@ -1,8 +1,7 @@
export * as BuiltInTools from "./builtins"
import { makeLocationNode } from "../effect/app-node"
import { Layer } from "effect"
import { BashTool } from "./bash"
import { Context, Layer } from "effect"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -16,8 +15,6 @@ import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write"
import { FSUtil } from "../fs-util"
import { AppProcess } from "../process"
import { Config } from "../config"
import { Location } from "../location"
import { LocationMutation } from "../location-mutation"
import { FileMutation } from "../file-mutation"
@@ -30,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
@@ -43,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,
BashTool.layer,
EditTool.layer,
GlobTool.layer,
GrepTool.layer,
@@ -58,14 +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,
AppProcess.node,
Config.node,
Location.node,
LocationMutation.node,
FileMutation.node,
+16 -7
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) => {
+277
View File
@@ -0,0 +1,277 @@
export * as ShellTool from "./shell"
import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
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, 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({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
}),
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
.pipe(Schema.optional)
.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({
exit: Schema.Number.pipe(Schema.optional),
truncated: Schema.Boolean,
timeout: Schema.Boolean.pipe(Schema.optional),
})
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): string | undefined => {
if (output.status === "running") return undefined
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
/**
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
* legacy shell runtime into core.
*/
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
// TODO: Port BashArity reusable command-prefix approvals.
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
// 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 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.
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
const externalCommandDirectories = (command: string, cwd: string) => {
const directories = new Set<string>()
for (const token of shellTokens(command)) {
const value = unquote(token).replace(/[;,|&]+$/, "")
if (!path.isAbsolute(value)) continue
const resolved = FSUtil.resolve(value)
if (FSUtil.contains(cwd, resolved)) continue
directories.add(FSUtil.resolve(path.dirname(resolved)))
}
return [...directories]
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
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. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input,
output: Output,
structured: StructuredOutput,
toStructuredOutput: ({ output }) => ({
truncated: output.truncated,
...(output.exit === undefined ? {} : { exit: output.exit }),
...(output.timeout === undefined ? {} : { timeout: output.timeout }),
}),
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 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({
action: name,
resources: [input.command],
save: [input.command],
sessionID: context.sessionID,
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
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 {
exit: final.exit,
output: `${body}${notice}`,
truncated,
status: "completed" as const,
...(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],
})
+23 -29
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],
})
+3
View File
@@ -136,6 +136,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,
+7 -2
View File
@@ -79,8 +79,9 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
resource: "*",
effect: enabled ? ("allow" as const) : ("deny" as const),
}))
for (const [action, rule] of Object.entries(info ?? {})) {
for (const [key, rule] of Object.entries(info ?? {})) {
if (!rule) continue
const action = normalizeAction(key)
if (typeof rule === "string") {
rules.push({ action, resource: "*", effect: rule })
continue
@@ -90,8 +91,12 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
return rules.length ? rules : undefined
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
function normalizeAction(action: string) {
return action === "write" || action === "patch" ? "edit" : action
if (action === "write" || action === "patch") return "edit"
if (action === "task") return "subagent"
if (action === "bash") return "shell"
return action
}
function agents(info: typeof ConfigV1.Info.Type) {
+27 -2
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
@@ -122,6 +146,7 @@ describe("AgentV2", () => {
"summary",
"title",
])
expect((yield* agent.get(AgentV2.defaultID))?.system).toBeUndefined()
for (const item of agents) {
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
}
+3 -6
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([])
-103
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")
}),
)
})
+20 -3
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" })
+22 -1
View File
@@ -143,6 +143,27 @@ describe("Config", () => {
}),
)
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
Effect.sync(() => {
expect(
ConfigMigrateV1.migrate({
permission: {
task: "ask",
bash: { "git status": "allow", "*": "deny" },
write: "deny",
read: "allow",
},
}).permissions,
).toEqual([
{ action: "subagent", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
{ action: "shell", resource: "*", effect: "deny" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
])
}),
)
it.live("returns an empty configuration when directory files do not exist", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -566,7 +587,7 @@ describe("Config", () => {
expect(documents[0]?.info.snapshots).toBe(false)
expect(documents[0]?.info.share).toBe("auto")
expect(documents[0]?.info.permissions).toEqual([
{ action: "bash", resource: "*", effect: "ask" },
{ action: "shell", resource: "*", effect: "ask" },
{ action: "edit", resource: "*.md", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "question", resource: "*", effect: "deny" },
File diff suppressed because one or more lines are too long
+164
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")
}),
)
})
+11 -6
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))
@@ -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",
@@ -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()
+107
View File
@@ -0,0 +1,107 @@
import { describe, expect } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
import { OpenAIChat } from "@opencode-ai/llm/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
import { DateTime, Effect, Layer, LayerMap, Stream } from "effect"
import { testEffect } from "./lib/effect"
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const model = Model.make({
id: "summary-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
let requests: LLMRequest[] = []
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))
},
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
() =>
// The test only needs the compaction location service used by SessionV2.compact.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
SessionCompaction.layer.pipe(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),
) as unknown as Layer.Layer<LocationServices>,
),
)
const sessions = SessionV2.layer.pipe(
Layer.provide(locations),
Layer.provide(EventV2.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
Layer.provide(projects),
Layer.provide(SessionExecution.noopLayer),
)
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
projects,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
),
)
describe("SessionV2.compact", () => {
it.effect("manually compacts the active session context", () =>
Effect.gen(function* () {
requests = []
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const created = yield* session.create({ location })
yield* events.publish(SessionEvent.Prompted, {
sessionID: created.id,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(0),
prompt: Prompt.make({ text: "Please compact this session history." }),
delivery: "steer",
})
yield* session.compact({ sessionID: created.id })
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.")
expect(yield* session.context(created.id)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual session summary", recent: "" },
])
}),
)
})
@@ -1,5 +1,55 @@
import { expect, test } from "bun:test"
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
import { OpenAIChat } from "@opencode-ai/llm/protocols"
import { Config } from "@opencode-ai/core/config"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
let requests: LLMRequest[] = []
const model = Model.make({
id: "summary-model",
provider: "test",
route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
})
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: (request: LLMRequest) => {
requests.push(request)
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
},
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = Layer.mock(SessionRunnerModel.Service)({ resolve: () => Effect.succeed(model) })
const it = testEffect(
Layer.mergeAll(
Database.defaultLayer,
EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
SessionCompaction.layer.pipe(
Layer.provide(client),
Layer.provide(config),
Layer.provide(models),
Layer.provide(EventV2.defaultLayer),
),
),
)
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -16,3 +66,65 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = SessionV2.ID.make("ses_manual_compaction")
const userMessage = {
id: SessionMessage.ID.create(),
type: "user" as const,
text: "Manual compaction should include this short conversation.",
time: { created: DateTime.makeUnsafe(0) },
}
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "manual-compaction",
directory: "/project",
title: "Manual compaction",
version: "test",
})
.run()
.pipe(Effect.orDie)
const session = yield* store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
),
)
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
expect(yield* store.context(sessionID)).toMatchObject([
{ type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
])
expect(
yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie),
).toEqual([
{ type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
{ type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
])
}),
)
+90 -1
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
+38 -15
View File
@@ -1,10 +1,12 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -110,6 +112,21 @@ const eventCount = (type: string) =>
),
)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
const assistantRow = (id: SessionMessage.ID, seq: number) => {
const { id: _, type, ...data } = encodeMessage(
SessionMessage.Assistant.make({
id,
type: "assistant",
agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [],
time: { created: DateTime.makeUnsafe(0) },
}),
)
return { id, session_id: sessionID, type, seq, time_created: 0, data }
}
describe("SessionV2.prompt", () => {
it.effect("exposes the execution registry", () =>
Effect.gen(function* () {
@@ -174,30 +191,36 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("preserves an optional per-request system string through admission and projection", () =>
it.effect("commits a staged revert before admitting a new prompt", () =>
Effect.gen(function* () {
yield* setup
const { db } = yield* Database.Service
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const message = yield* session.prompt({
const boundary = yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "Fix the failing tests", system: "Per-request override" }),
prompt: Prompt.make({ text: "boundary" }),
resume: false,
})
expect(message.prompt.system).toBe("Per-request override")
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
prompt: { text: "Fix the failing tests", system: "Per-request override" },
})
yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* events.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
timestamp: yield* DateTime.now,
revert: { messageID: boundary.id, files: [] },
})
expect((yield* session.get(sessionID)).revert?.messageID).toBe(boundary.id)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: message.id, type: "user", text: "Fix the failing tests", system: "Per-request override" },
])
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "after revert" }), resume: false })
expect((yield* session.get(sessionID)).revert).toBeUndefined()
expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all().pipe(Effect.orDie)).map(
(row) => row.id,
),
).not.toContain(stale)
}),
)
@@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -58,7 +59,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" },
@@ -73,6 +74,7 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.suc
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(SessionCompaction.layer),
Layer.provide(Snapshot.noopLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { Model } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
const prompt = (id: string) =>
SessionRunnerSystemPrompt.provider(Model.make({ id, provider: "test", route: OpenAIChat.route }))
describe("SessionRunnerSystemPrompt", () => {
test("selects the legacy provider-family prompts from the model id", () => {
expect(prompt("gpt-5")).toContain("You are OpenCode, You and the user share the same workspace")
expect(prompt("gpt-4.1")).toContain("THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH")
expect(prompt("o3")).toContain("THE PROBLEM CAN NOT BE SOLVED WITHOUT EXTENSIVE INTERNET RESEARCH")
expect(prompt("gpt-5-codex")).toContain("## Editing constraints")
expect(prompt("gemini-2.5-pro")).toContain("# Core Mandates")
expect(prompt("claude-sonnet-4")).toContain("# Professional objectivity")
expect(prompt("kimi-k2")).toContain("# Prompt and Tool Use")
expect(prompt("trinity")).toContain("what command should I run to list files")
expect(prompt("llama-3.3")).toContain("You are opencode, an interactive CLI tool")
})
})
@@ -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)
+68 -48
View File
@@ -23,6 +23,7 @@ import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
@@ -32,6 +33,7 @@ import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator
import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
@@ -96,6 +98,7 @@ const client = Layer.succeed(
}),
)
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const defaultSystem = SessionRunnerSystemPrompt.provider(model)
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = Model.make({
id: "compact",
@@ -126,7 +129,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({
@@ -232,6 +235,7 @@ const config = Layer.succeed(
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(SessionCompaction.layer),
Layer.provide(Snapshot.noopLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
@@ -779,8 +783,8 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }])
@@ -799,6 +803,49 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("uses the selected model family prompt when the agent does not override it", () =>
Effect.gen(function* () {
yield* setup
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-provider-prompt", ["Done"]).completeEvents
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
expect.stringContaining("You are OpenCode, You and the user share the same workspace"),
"Initial context",
])
}),
)
it.effect("uses the selected model family prompt when the agent system override is empty", () =>
Effect.gen(function* () {
yield* setup
currentModel = Model.make({ id: "gpt-5", provider: "openai", route: OpenAIChat.route })
const agent = yield* AgentV2.Service
yield* agent.transform((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.system = ""
agent.mode = "primary"
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false })
requests.length = 0
response = fragmentFixture("text", "text-empty-agent-system", ["Done"]).completeEvents
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
expect.stringContaining("You are OpenCode, You and the user share the same workspace"),
"Initial context",
])
}),
)
it.effect("includes the effective default agent system before durable context", () =>
Effect.gen(function* () {
yield* setup
@@ -847,36 +894,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("appends the per-request prompt system after the agent prompt and durable baseline", () =>
Effect.gen(function* () {
yield* setup
const agent = yield* AgentV2.Service
yield* agent.transform((editor) =>
editor.update(AgentV2.ID.make("build"), (agent) => {
agent.system = "Build agent instructions"
agent.mode = "primary"
}),
)
const session = yield* SessionV2.Service
yield* session.prompt({
sessionID,
prompt: Prompt.make({ text: "First", system: "Per-request override" }),
resume: false,
})
requests.length = 0
response = fragmentFixture("text", "text-system", ["Done"]).completeEvents
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([
"Build agent instructions",
"Initial context",
"Per-request override",
])
}),
)
it.effect("omits the per-request system part when the prompt has no system string", () =>
it.effect("uses only the agent prompt and durable baseline as system parts", () =>
Effect.gen(function* () {
yield* setup
const agent = yield* AgentV2.Service
@@ -951,8 +969,8 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nBuild skills"],
["Initial context\n\nBuild skills"],
[defaultSystem, "Initial context\n\nBuild skills"],
[defaultSystem, "Initial context\n\nBuild skills"],
])
expect(systemTexts(requests[1]!)).toContainEqual(expect.stringContaining("Reviewer skills"))
}),
@@ -985,7 +1003,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context\n\nBuild skills"],
[defaultSystem, "Initial context\n\nBuild skills"],
])
}),
)
@@ -1014,7 +1032,9 @@ describe("SessionRunnerLLM", () => {
response = []
yield* session.resume(sessionID)
expect(requests.map((request) => request.model)).toEqual([model])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([["Initial context"]])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
[defaultSystem, "Initial context"],
])
}),
)
@@ -1063,9 +1083,9 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"])
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
@@ -1109,9 +1129,9 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
["Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
}),
)
@@ -1146,8 +1166,8 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Replacement context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Replacement context"],
])
yield* replaySessionProjection(sessionID)
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
@@ -1375,7 +1395,7 @@ describe("SessionRunnerLLM", () => {
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false })
yield* session.resume(sessionID)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual(["Initial context"])
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
}),
)
@@ -1574,8 +1594,8 @@ describe("SessionRunnerLLM", () => {
expect(requests.map((request) => request.model)).toEqual([model, replacementModel])
expect(requests.map((request) => request.system.map((part) => part.text))).toEqual([
["Initial context"],
["Initial context"],
[defaultSystem, "Initial context"],
[defaultSystem, "Initial context"],
])
expect(systemTexts(requests[1]!)).toContain("Replacement context")
}),
+4
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],
+34 -36
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { which } from "@opencode-ai/core/util/which"
@@ -8,92 +8,90 @@ const withShell = async (shell: string | undefined, fn: () => void | Promise<voi
const prev = process.env.SHELL
if (shell === undefined) delete process.env.SHELL
else process.env.SHELL = shell
Shell.acceptable.reset()
Shell.preferred.reset()
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
try {
await fn()
} finally {
if (prev === undefined) delete process.env.SHELL
else process.env.SHELL = prev
Shell.acceptable.reset()
Shell.preferred.reset()
ShellSelect.acceptable.reset()
ShellSelect.preferred.reset()
}
}
describe("shell", () => {
test("normalizes shell names", () => {
expect(Shell.name("/bin/bash")).toBe("bash")
expect(ShellSelect.name("/bin/bash")).toBe("bash")
if (process.platform === "win32") {
expect(Shell.name("C:/tools/NU.EXE")).toBe("nu")
expect(Shell.name("C:/tools/PWSH.EXE")).toBe("pwsh")
expect(ShellSelect.name("C:/tools/NU.EXE")).toBe("nu")
expect(ShellSelect.name("C:/tools/PWSH.EXE")).toBe("pwsh")
}
})
test("detects login shells", () => {
expect(Shell.login("/bin/bash")).toBe(true)
expect(Shell.login("C:/tools/pwsh.exe")).toBe(false)
expect(ShellSelect.login("/bin/bash")).toBe(true)
expect(ShellSelect.login("C:/tools/pwsh.exe")).toBe(false)
})
test("detects posix shells", () => {
expect(Shell.posix("/bin/bash")).toBe(true)
expect(Shell.posix("/bin/fish")).toBe(false)
expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
expect(ShellSelect.posix("/bin/bash")).toBe(true)
expect(ShellSelect.posix("/bin/fish")).toBe(false)
expect(ShellSelect.posix("C:/tools/pwsh.exe")).toBe(false)
})
test("falls back when configured shell cannot be resolved", async () => {
await withShell(undefined, async () => {
const preferred = Shell.preferred()
const acceptable = Shell.acceptable()
expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
const preferred = ShellSelect.preferred()
const acceptable = ShellSelect.acceptable()
expect(ShellSelect.preferred("opencode-missing-shell")).toBe(preferred)
expect(ShellSelect.acceptable("opencode-missing-shell")).toBe(acceptable)
})
})
test("falls back for terminal-only acceptable shells", () => {
expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
expect(ShellSelect.name(ShellSelect.acceptable("fish"))).not.toBe("fish")
expect(ShellSelect.name(ShellSelect.acceptable("nu"))).not.toBe("nu")
})
test("builds command args per shell family", () => {
expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"])
const zsh = Shell.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") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {
expect(Shell.name(Shell.acceptable())).not.toBe("nu")
expect(ShellSelect.name(ShellSelect.acceptable())).not.toBe("nu")
})
})
test("normalizes Git Bash shell paths from env", async () => {
const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe"
await withShell(shell, async () => {
expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell))
expect(ShellSelect.preferred()).toBe(FSUtil.windowsPath(shell))
})
})
test("resolves /usr/bin/bash from env to Git Bash", async () => {
const bash = Shell.gitbash()
const bash = ShellSelect.gitbash()
if (!bash) return
await withShell("/usr/bin/bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
expect(ShellSelect.acceptable()).toBe(bash)
expect(ShellSelect.preferred()).toBe(bash)
})
})
test("resolves bare bash to Git Bash before PATH", async () => {
const bash = Shell.gitbash()
const bash = ShellSelect.gitbash()
if (!bash) return
expect(Shell.acceptable("bash")).toBe(bash)
expect(Shell.preferred("bash")).toBe(bash)
expect(ShellSelect.acceptable("bash")).toBe(bash)
expect(ShellSelect.preferred("bash")).toBe(bash)
await withShell("bash", async () => {
expect(Shell.acceptable()).toBe(bash)
expect(Shell.preferred()).toBe(bash)
expect(ShellSelect.acceptable()).toBe(bash)
expect(ShellSelect.preferred()).toBe(bash)
})
})
@@ -101,7 +99,7 @@ describe("shell", () => {
const shell = which("pwsh") || which("powershell")
if (!shell) return
await withShell(path.win32.basename(shell), async () => {
expect(Shell.preferred()).toBe(shell)
expect(ShellSelect.preferred()).toBe(shell)
})
})
}
+15 -1
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)
-432
View File
@@ -1,432 +0,0 @@
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 { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@opencode-ai/core/config"
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 { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
const sessionID = SessionV2.ID.make("ses_bash_tool_test")
const assertions: PermissionV2.AssertInput[] = []
const runs: Array<{
readonly command: string
readonly cwd?: string
readonly shell?: string | boolean
readonly options?: AppProcess.RunOptions
}> = []
let denyAction: string | undefined
let result: AppProcess.RunResult = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
let runFailure: AppProcess.AppProcessError | undefined
let afterPermission = (_input: PermissionV2.AssertInput): Effect.Effect<void> => Effect.void
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const appProcess = Layer.succeed(
AppProcess.Service,
AppProcess.Service.of({
run: (command: ChildProcess.Command, options?: AppProcess.RunOptions) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") throw new Error("expected standard command")
runs.push({ command: command.command, cwd: command.options.cwd, shell: command.options.shell, options })
return runFailure ? Effect.fail(runFailure) : Effect.succeed(result)
}),
} as unknown as AppProcess.Interface),
)
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed([]),
}),
)
const reset = () => {
assertions.length = 0
runs.length = 0
denyAction = undefined
runFailure = undefined
afterPermission = () => Effect.void
result = {
command: "mock",
exitCode: 0,
output: Buffer.from("hello\n"),
stdout: Buffer.from("hello\n"),
stderr: Buffer.alloc(0),
outputTruncated: false,
stdoutTruncated: false,
stderrTruncated: false,
}
}
const withTool = <A, E, R>(
directory: string,
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
processLayer: Layer.Layer<AppProcess.Service> = appProcess,
) => {
const filesystem = FSUtil.defaultLayer
const activeLocation = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
)
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const bash = BashTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Layer.provide(filesystem),
Layer.provide(processLayer),
Layer.provide(config),
)
return Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service)
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
})
const it = testEffect(Layer.empty)
describe("BashTool", () => {
it.live("registers and returns structured successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command")
expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({
result: {
type: "content",
value: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
},
output: {
structured: {
exit: 0,
truncated: false,
},
content: [
{ type: "text", text: "hello\n" },
{ type: "text", text: "Command exited with code 0." },
],
},
})
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
expect(runs[0]?.options).toMatchObject({
combineOutput: true,
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => expect(runs).toMatchObject([{ cwd: realpathSync(path.join(tmp.path, "src")) }])),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("rejects a workdir that stops being a directory during approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const workdir = path.join(tmp.path, "src")
afterPermission = (input) =>
input.action === "bash"
? Effect.promise(async () => {
await fs.rm(workdir, { recursive: true })
await fs.writeFile(workdir, "not a directory")
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(
withTool(tmp.path, (registry) => executeTool(registry, call({ command: "pwd", workdir: "src" }))),
),
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
expect(assertions.map((input) => input.action)).toEqual(["bash"])
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
if (process.platform !== "win32") {
it.live("executes a real shell command through AppProcess", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withTool(
tmp.path,
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
AppProcess.defaultLayer,
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.result).toEqual({
type: "content",
value: [
{ type: "text", text: "core-bash" },
{ type: "text", text: "Command exited with code 0." },
],
})
expect(settled.output?.structured).toMatchObject({
exit: 0,
})
expect(settled.output?.structured).not.toHaveProperty("output")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
}
it.live("approves an explicit external workdir before bash execution", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) => {
reset()
return withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "bash"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
expect(runs).toHaveLength(1)
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("does not execute after external-directory or bash denial", () =>
Effect.acquireUseRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
yield* withTool(active.path, (registry) =>
executeTool(registry, call({ command: "pwd", workdir: outside.path })),
)
expect(assertions.map((item) => item.action)).toEqual(["external_directory"])
expect(runs).toEqual([])
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toEqual([])
}),
([active, outside]) =>
Effect.promise(() =>
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()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({
truncated: false,
})
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
expect(settled.output?.structured).toMatchObject({
exit: 7,
truncated: false,
})
expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" })
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("surfaces bounded process-capture truncation", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
result = { ...result, outputTruncated: true }
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output capture truncated"),
})
expect(settled.output?.structured).not.toHaveProperty("resource")
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
runFailure = new AppProcess.AppProcessError({ command: "sleep", cause: new Error("Timed out") })
return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
expect(settled.output?.structured).toMatchObject({
timeout: true,
truncated: false,
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/bash.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"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.",
"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.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+417
View File
@@ -0,0 +1,417 @@
import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
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 { 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 { 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
const permission = Layer.succeed(
PermissionV2.Service,
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const reset = () => {
assertions.length = 0
denyAction = undefined
afterPermission = () => Effect.void
}
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,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "shell", input },
})
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(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
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: 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: [helloCommand] }])
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("resolves a relative workdir from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return Effect.promise(() => fs.mkdir(path.join(tmp.path, "src"))).pipe(
Effect.andThen(
withSession(tmp.path, (registry) => settleTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen((settled) =>
Effect.sync(() =>
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining(realpathSync(path.join(tmp.path, "src"))),
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("rejects a workdir that stops being a directory during approval", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
const workdir = path.join(tmp.path, "src")
afterPermission = (input) =>
input.action === "shell"
? Effect.promise(async () => {
await fs.rm(workdir, { recursive: true })
await fs.writeFile(workdir, "not a directory")
}).pipe(Effect.orDie)
: Effect.void
return Effect.promise(() => fs.mkdir(workdir)).pipe(
Effect.andThen(
withSession(tmp.path, (registry) => executeTool(registry, call({ command: cwdCommand, workdir: "src" }))),
),
Effect.andThen(Effect.sync(() => expect(assertions.map((input) => input.action)).toEqual(["shell"]))),
)
},
(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()])),
([active, outside]) => {
reset()
return withSession(active.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: outside.path })),
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "shell"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
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()])),
([active, outside]) =>
Effect.gen(function* () {
reset()
denyAction = "external_directory"
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* withSession(active.path, (registry) => executeTool(registry, call({ command: cwdCommand })))
expect(assertions.map((item) => item.action)).toEqual(["shell"])
}),
([active, outside]) =>
Effect.promise(() =>
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()])),
([active, outside]) => {
reset()
denyAction = "external_directory"
const target = path.join(outside.path, "secret.txt")
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"])
expect(settled.output?.structured).not.toHaveProperty("warnings")
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Warnings:"),
})
}),
),
)
},
([active, outside]) =>
Effect.promise(() =>
Promise.all([active[Symbol.asyncDispose](), outside[Symbol.asyncDispose]()]).then(() => undefined),
),
),
)
it.live("keeps non-zero exits useful", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: bodyExitCommand }, "call-nonzero")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 7, truncated: false })
expect(settled.output?.content[0]).toEqual({ type: "text", text: "body" })
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command exited with code 7"),
})
}),
),
)
},
(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(() => tmpdir()),
(tmp) => {
reset()
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: overflowCommand(bytes) }, "call-overflow")),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ exit: 0, truncated: true })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("output truncated; full output saved to:"),
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("returns a useful timeout settlement", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
settleTool(registry, call({ command: idleCommand, timeout: 50 })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(settled.output?.structured).toMatchObject({ timeout: true, truncated: false })
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("Command timed out"),
})
}),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/shell.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
"Replace token-based command-argument external-directory advisories with parser-based detection.",
"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 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.",
]) {
expect(source).toContain(`TODO: ${todo}`)
}
})
+7 -7
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)
+98 -21
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>) {
+37 -14
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(
+1
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:*",
+2
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: {
+2
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)
+2 -2
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,
@@ -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"
@@ -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* () {
@@ -9,7 +9,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
@@ -58,7 +58,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
})
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
return yield* Effect.promise(() => ShellSelect.list())
})
const list = Effect.fn("PtyHttpApi.list")(function* () {
@@ -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,
+4 -4
View File
@@ -35,7 +35,7 @@ import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { ShellID } from "@/tool/shell/id"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Truncate } from "@/tool/truncate"
@@ -520,8 +520,8 @@ export const layer = Layer.effect(
}).pipe(Effect.ensuring(markReady))
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const args = Shell.args(sh, input.command, cwd)
const sh = ShellSelect.preferred(cfg.shell)
const args = ShellSelect.args(sh, input.command)
let output = ""
let aborted = false
@@ -1396,7 +1396,7 @@ export const layer = Layer.effect(
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const cfg = yield* config.get()
const sh = Shell.preferred(cfg.shell)
const sh = ShellSelect.preferred(cfg.shell)
const results = yield* Effect.promise(() =>
Promise.all(
shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
+11 -17
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"
+10 -13
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"
+3 -3
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,
+7 -7
View File
@@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { fileURLToPath } from "url"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Shell } from "@opencode-ai/core/shell"
import { ShellSelect } from "@opencode-ai/core/shell/select"
import { ShellID } from "./shell/id"
import * as Truncate from "./truncate"
@@ -291,7 +291,7 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan,
})
function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
if (process.platform === "win32" && ShellSelect.ps(shell)) {
return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
@@ -357,7 +357,7 @@ export const ShellTool = Tool.define(
const resolvePath = Effect.fn("ShellTool.resolvePath")(function* (text: string, root: string, shell: string) {
if (process.platform === "win32") {
if (Shell.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
if (ShellSelect.posix(shell) && text.startsWith("/") && FSUtil.windowsPath(text) === text) {
const file = yield* cygpath(shell, text)
if (file) return file
}
@@ -387,7 +387,7 @@ export const ShellTool = Tool.define(
patterns: new Set<string>(),
always: new Set<string>(),
}
const shellKind = ShellID.toKind(Shell.name(shell))
const shellKind = ShellID.toKind(ShellSelect.name(shell))
for (const node of commands(root)) {
const command = parts(node)
@@ -597,8 +597,8 @@ export const ShellTool = Tool.define(
return () =>
Effect.gen(function* () {
const cfg = yield* config.get()
const shell = Shell.acceptable(cfg.shell)
const name = Shell.name(shell)
const shell = ShellSelect.acceptable(cfg.shell)
const name = ShellSelect.name(shell)
const limits = yield* trunc.limits()
const prompt = ShellPrompt.render(name, process.platform, limits, defaultTimeoutMs)
yield* Effect.logInfo("shell tool using shell", { shell })
@@ -616,7 +616,7 @@ export const ShellTool = Tool.define(
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
}
const timeout = params.timeout ?? defaultTimeoutMs
const ps = Shell.ps(shell)
const ps = ShellSelect.ps(shell)
yield* Effect.scoped(
Effect.gen(function* () {
const tree = yield* Effect.acquireRelease(parse(params.command, ps), (tree) =>
+26 -27
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(() => {
+1 -1
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.
@@ -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")
}),
)
})

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