Compare commits

..
92 changed files with 815 additions and 509 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.4",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+2 -10
View File
@@ -336,18 +336,12 @@ export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorObject = Schema.Struct({
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
@@ -440,9 +434,7 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
@@ -1,9 +1,8 @@
import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -45,59 +44,11 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
@@ -405,7 +405,12 @@ test("executes a selected slash command after creating its worktree", async ({ p
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
},
})
await route.fulfill({ status: 204, headers })
await route.fulfill({
status: 200,
headers,
contentType: "application/json",
body: JSON.stringify({ data: { type: "prompt", inboxID: "msg_workspace_review" } }),
})
})
const editor = page.locator('[data-component="composer-editor"]')
await editor.fill("/review")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.4",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"bin": {
+23 -6
View File
@@ -1,4 +1,5 @@
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { Command } from "@opencode/schema/command"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -37,6 +38,9 @@ export type TurnStart =
| { readonly type: "skill"; readonly id: string }
| { readonly type: "compaction"; readonly id: string }
/** `prompt` submissions admitted work under the start ID; `immediate` submissions have none to follow. */
export type Admission = Command.Outcome["type"]
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
export const ChildSessionUpdateMethod = "opencode/session/child_update"
@@ -76,8 +80,7 @@ export async function streamTurn(input: {
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly submit: (signal: AbortSignal) => Promise<Admission>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
@@ -114,6 +117,14 @@ export async function streamTurn(input: {
.catch(() => {})
}
// Command-spawned subagents run while the invoking Session is idle, so cancelling the prompt
// must interrupt them directly. Sessions that start after cancellation are interrupted on start.
const interruptChildren = async () => {
await Promise.all(
[...openChildren].map((sessionID) => input.client.session.interrupt({ sessionID }).catch(() => {})),
)
}
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
const projected = child ? projectChildUpdate(value, child) : value
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
@@ -178,9 +189,10 @@ export async function streamTurn(input: {
if (!started) continue
if (event.type === "session.execution.started") {
if (child) {
await notifyChild(child, { type: "status", status: "running" })
if (mode === "turn" && control.cancelled) {
await input.client.session.interrupt({ sessionID: event.data.sessionID }).catch(() => {})
}
if (child) await notifyChild(child, { type: "status", status: "running" })
continue
}
@@ -342,11 +354,15 @@ export async function streamTurn(input: {
input.sessionSignal?.removeEventListener("abort", connectionAbort)
await stream.return?.(undefined).catch(() => {})
}
const onCancel = () => void interruptChildren()
control.admission.signal.addEventListener("abort", onCancel, { once: true })
try {
await input.submit(control.admission.signal).catch((error) => {
const admission = await input.submit(control.admission.signal).catch((error): Admission => {
if (!control.cancelled) throw error
// The request may have been admitted before the abort reached the server; keep observing.
return "prompt"
})
if (input.action) {
if (admission === "immediate") {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
@@ -384,6 +400,7 @@ export async function streamTurn(input: {
await completed.catch(() => {})
throw error
} finally {
control.admission.signal.removeEventListener("abort", onCancel)
if (!handedOff) await closeStream()
}
}
+17 -5
View File
@@ -52,6 +52,7 @@ import {
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type Admission,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
@@ -327,7 +328,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
cwd: state.cwd,
start: prepared.start,
writeTextFile: capabilities.writeTextFile,
action: prepared.command !== undefined,
control,
connectionSignal: input.connection.signal,
sessionSignal: state.abort.signal,
@@ -362,7 +362,12 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
return { start, text, files, synthetic, slash, command }
}
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
async function submitPrompt(
client: OpenCodeClient,
session: Attached,
prompt: PreparedPrompt,
signal: AbortSignal,
): Promise<Admission> {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
@@ -372,23 +377,30 @@ async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: P
resume: false,
})
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.start.type === "compaction") {
await client.session.compact({ sessionID: session.id, id: prompt.start.id })
return "prompt"
}
if (prompt.command) {
return client.session.command(
// The command admits any resulting input under the turn's start ID so the stream can follow it.
const outcome = await client.session.command(
{
sessionID: session.id,
name: prompt.command.name,
id: prompt.start.id,
text: prompt.slash?.args ?? "",
files: prompt.files,
delivery: "steer",
},
{ signal },
)
return outcome.type
}
return client.session.prompt(
await client.session.prompt(
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
{ signal },
)
return "prompt"
}
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
+2 -5
View File
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { ShellEnvironment } from "./shell-environment"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -29,9 +28,6 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
// A managed service may have been started by a GUI client with launchd's environment. Adopt the
// login shell's before anything reads process.env, including the OPENCODE_* settings below.
if (options.mode === "service") yield* ShellEnvironment.adopt()
return yield* processEffect(options).pipe(
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
@@ -42,8 +38,9 @@ export const run = Effect.fnUntraced(function* (options: Options) {
],
}),
),
Effect.provide(NodeServices.layer),
)
}, Effect.provide(NodeServices.layer))
})
const processEffect = Effect.fnUntraced(function* (options: Options) {
const inherited = process.env.OPENCODE_PTY_HANDOFF
-80
View File
@@ -1,80 +0,0 @@
export * as ShellEnvironment from "./shell-environment"
import { randomUUID } from "node:crypto"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
// Set on the probe shell so an rc file that itself starts opencode cannot recurse into another probe.
export const RESOLVING = "OPENCODE_RESOLVING_SHELL_ENVIRONMENT"
// Shell bookkeeping that describes the probe process, not the user's environment.
const TRANSIENT = new Set([RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
export type Variables = Readonly<Record<string, string | undefined>>
/**
* Resolve the environment of the user's interactive login shell when the current environment did
* not come from one.
*
* A background service elected by a GUI client (the desktop app, an editor, a login item) inherits
* launchd's environment: PATH is `/usr/bin:/bin:/usr/sbin:/sbin` and nothing exported from
* `.zshrc` or `.bashrc` exists. Every process the server spawns extends `process.env`, so stdio MCP
* servers using `#!/usr/bin/env node`, `npx`, `uvx`, formatters, and hooks fail with no useful error
* while the bash tool, which prefers the client's terminal environment, keeps working. Editors such
* as VS Code and Zed resolve the login shell at startup for the same reason.
*
* Returns `undefined` when there is nothing to adopt: Windows, an environment that already passed
* through a shell (`SHLVL`), a probe already in progress, or a shell that fails to report.
*/
export const resolve = Effect.fnUntraced(function* (env: Variables = process.env) {
if (process.platform === "win32") return undefined
if (env[RESOLVING] !== undefined || env.SHLVL !== undefined) return undefined
const spawner = yield* ChildProcessSpawner
const marker = randomUUID()
// Interactive and login: most PATH edits live in `.zshrc`/`.bashrc`, which only interactive shells read.
// `env -0` keeps values containing newlines intact.
const output = yield* spawner
.string(
ChildProcess.make(env.SHELL || "/bin/sh", ["-ilc", `printf '%s' '${marker}'; env -0; printf '%s' '${marker}'`], {
env: { ...env, [RESOLVING]: "1" },
stdin: "ignore",
// Interactive shells without a terminal warn on stderr; nothing reads it, so never let it fill.
stderr: "ignore",
}),
)
.pipe(
Effect.timeout("10 seconds"),
Effect.tapError((error) => Effect.logWarning("shell environment unavailable", { error })),
Effect.orElseSucceed(() => undefined),
)
if (output === undefined) return undefined
const start = output.indexOf(marker)
const end = output.lastIndexOf(marker)
if (start === -1 || end === start) {
yield* Effect.logWarning("shell environment unavailable", { shell: env.SHELL, reason: "missing markers" })
return undefined
}
return Object.fromEntries(
output
.slice(start + marker.length, end)
.split("\0")
.flatMap((entry) => {
const separator = entry.indexOf("=")
if (separator <= 0) return []
const key = entry.slice(0, separator)
return TRANSIENT.has(key) ? [] : [[key, entry.slice(separator + 1)] as const]
}),
)
})
/** Apply the resolved login-shell environment to this process before anything reads `process.env`. */
export const adopt = Effect.fnUntraced(function* () {
const variables = yield* resolve()
if (variables === undefined) return
Object.assign(process.env, variables)
yield* Effect.logInfo("shell environment adopted", {
shell: process.env.SHELL,
variables: Object.keys(variables).length,
})
})
@@ -0,0 +1,138 @@
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, lastUserText, newSession, type ChatRequest } from "./subprocess"
describe("acp slash command subprocess", () => {
test("template command stays pending until its model work completes and streams the output", async () => {
const script = scriptedModel()
await using fixture = await createAcpFixture({
respond: script.respond,
config: { commands: { audit: { description: "Audit the change", template: "Audit $ARGUMENTS" } } },
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const gate = script.hold()
const pending = acp
.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/audit now" }],
})
.then(expectOk)
await gate.started
expect(lastUserText(fixture.llm.requests.at(-1)!)).toBe("Audit now")
// The response is held, so the prompt must still be pending.
expect(await Promise.race([pending.then(() => "settled"), Bun.sleep(200).then(() => "pending")])).toBe("pending")
gate.release("COMMAND_RESPONSE")
expect((await pending).stopReason).toBe("end_turn")
const chunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
isAgentText(params, "COMMAND_RESPONSE"),
)
expect(chunk.params.sessionId).toBe(session.sessionId)
}, 60_000)
test("subagent command follows the child and the parent's follow-up before completing", async () => {
const script = scriptedModel()
await using fixture = await createAcpFixture({
respond: script.respond,
config: {
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
},
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const child = script.hold()
const parent = script.hold()
const pending = acp
.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/audit now" }],
})
.then(expectOk)
await child.started
expect(lastUserText(fixture.llm.requests.at(-1)!)).toBe("You are a subagent spawned by another session.\nAudit now")
child.release("CHILD_DONE")
await parent.started
expect(lastUserText(fixture.llm.requests.at(-1)!)).toContain("CHILD_DONE")
expect(await Promise.race([pending.then(() => "settled"), Bun.sleep(200).then(() => "pending")])).toBe("pending")
parent.release("PARENT_DONE")
expect((await pending).stopReason).toBe("end_turn")
const childChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
isAgentText(params, "CHILD_DONE"),
)
expect(childChunk.params.update._meta?.["opencode/child-session"]).toMatchObject({ parentID: session.sessionId })
const parentChunk = await acp.waitForNotification<SessionNotification>("session/update", (params) =>
isAgentText(params, "PARENT_DONE"),
)
expect(parentChunk.params.update._meta?.["opencode/child-session"]).toBeUndefined()
}, 60_000)
test("cancelling during subagent command work interrupts the child and the parent's follow-up", async () => {
const script = scriptedModel()
await using fixture = await createAcpFixture({
respond: script.respond,
config: {
commands: { audit: { description: "Audit in a subagent", template: "Audit $ARGUMENTS", subagent: true } },
},
})
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const child = script.hold()
const parent = script.hold()
const pending = acp
.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "/audit now" }],
})
.then(expectOk)
await child.started
await acp.notify("session/cancel", { sessionId: session.sessionId })
// Both model responses stay held, so the prompt can only settle by interrupting the child and
// the parent's follow-up run that the cancelled-subagent notice wakes.
expect((await pending).stopReason).toBe("cancelled")
child.release("LATE")
parent.release("LATE")
const followUp = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hello again" }],
}),
)
expect(followUp.stopReason).toBe("end_turn")
}, 60_000)
})
/** Holds agent-loop model responses in FIFO order; auxiliary requests (no tools) answer immediately. */
function scriptedModel() {
const holds: Array<{ started: () => void; released: Promise<string> }> = []
return {
respond: (request: ChatRequest) => {
const next = request.tools ? holds.shift() : undefined
if (!next) return "accepted"
next.started()
return next.released
},
hold() {
const started = Promise.withResolvers<void>()
const released = Promise.withResolvers<string>()
holds.push({ started: started.resolve, released: released.promise })
return { started: started.promise, release: released.resolve }
},
}
}
function isAgentText(params: SessionNotification, text: string) {
return (
params.update.sessionUpdate === "agent_message_chunk" &&
params.update.content.type === "text" &&
params.update.content.text === text
)
}
+7 -5
View File
@@ -656,6 +656,7 @@ describe("acp event behavior", () => {
{ signal },
)
submitted.resolve()
return "prompt"
},
})
@@ -694,10 +695,9 @@ describe("acp event behavior", () => {
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt(
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
{ signal },
),
fixture.client.session
.prompt({ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" }, { signal })
.then(() => "prompt" as const),
})
try {
@@ -784,7 +784,9 @@ function turn(input: {
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
input.fixture.client.session
.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal })
.then(() => "prompt" as const),
})
}
+3 -4
View File
@@ -98,7 +98,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
start: { type: "input", id },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }).then(() => "prompt" as const),
})
expect(updates).toEqual([
@@ -122,7 +122,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
}
})
test("acp action resolves without prompt lifecycle events", async () => {
test("acp immediate admission resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
@@ -150,9 +150,8 @@ test("acp action resolves without prompt lifecycle events", async () => {
cwd: "/workspace",
start: { type: "input", id: "msg_action" },
writeTextFile: false,
action: true,
control: { cancelled: false, admission: new AbortController() },
submit: async () => {},
submit: async () => "immediate",
})
expect(response).toMatchObject({ stopReason: "end_turn" })
@@ -547,7 +547,10 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
start: { type: "input", id: inboxID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
submit: (signal) =>
fixture.client.session
.prompt({ sessionID, id: inboxID, text: "hello" }, { signal })
.then(() => "prompt" as const),
})
}
+2 -1
View File
@@ -12,7 +12,7 @@ describe("acp service prompt routing and usage", () => {
return Response.json({ data: makeSession("ses_routes") })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/command") {
return new Response(null, { status: 204 })
return Response.json({ data: { type: "immediate" } })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
@@ -42,6 +42,7 @@ describe("acp service prompt routing and usage", () => {
const compact = fixture.requests.find((request) => request.path === "/api/session/ses_routes/compact")
expect(command?.body).toMatchObject({
name: "review",
id: expect.stringMatching(/^msg_/),
text: "now",
files: [],
delivery: "steer",
+48 -5
View File
@@ -4,6 +4,7 @@ import type {
SessionConfigOption,
SessionConfigSelectOption,
} from "@agentclientprotocol/sdk"
import { Schema } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
@@ -46,6 +47,7 @@ type Waiter = {
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly notify: (method: string, params?: unknown) => Promise<void>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
@@ -64,7 +66,38 @@ description: Verifier compatibility skill.
# Verifier Skill
`
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
/** The subset of an OpenAI-compatible chat request that tests inspect. */
const ChatRequest = Schema.Struct({
messages: Schema.Array(
Schema.Struct({
role: Schema.String,
content: Schema.Union([
Schema.String,
Schema.Array(Schema.Struct({ text: Schema.String.pipe(Schema.optional) })),
]).pipe(Schema.optional),
}),
),
tools: Schema.Array(Schema.Unknown).pipe(Schema.optional),
})
export type ChatRequest = typeof ChatRequest.Type
const decodeChatRequest = Schema.decodeUnknownSync(ChatRequest)
/** Text of the most recent user message. */
export function lastUserText(request: ChatRequest) {
const content = request.messages.findLast((message) => message.role === "user")?.content
if (!Array.isArray(content)) return content
return content.flatMap((part) => (part.text === undefined ? [] : [part.text])).join("")
}
export type FixtureOptions = {
readonly skill?: string
/** Extra opencode.json entries merged over the verifier config. */
readonly config?: Record<string, Schema.Json>
/** Produce the scripted completion text; awaiting here holds the model response. */
readonly respond?: (request: ChatRequest) => string | Promise<string>
}
export async function createAcpFixture(options: FixtureOptions = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
@@ -76,7 +109,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: unknown[] = []
const requests: ChatRequest[] = []
const llm = Bun.serve({
hostname: "127.0.0.1",
port: 0,
@@ -84,15 +117,19 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
requests.push(await request.json().catch(() => undefined))
return new Response(completion("accepted"), {
const body = decodeChatRequest(await request.json())
requests.push(body)
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
headers: { "content-type": "text/event-stream" },
})
},
})
await Bun.write(
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
JSON.stringify({
...verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined),
...options.config,
}),
)
await Bun.write(models, "{}")
@@ -306,6 +343,12 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
async notify(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const notification: JsonRpcNotification<unknown> = { jsonrpc: "2.0", method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(notification)}\n`))
await child.stdin.flush()
},
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
@@ -1,68 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ShellEnvironment } from "../src/shell-environment"
// A stand-in login shell: it records every invocation, applies "rc file" exports, then runs the
// probe script exactly as `$SHELL -ilc <script>` would.
let root: string
let shell: string
let invocations: string
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-shell-env-"))
shell = path.join(root, "login-shell")
invocations = path.join(root, "invocations")
await fs.writeFile(
shell,
[
"#!/bin/sh",
`printf '%s\\n' "$1" >> '${invocations}'`,
`[ "$1" = -ilc ] || exit 64`,
'export PATH="/rc/bin:$PATH"',
"export FROM_RC=yes",
"export MULTILINE='first",
"second'",
'export SAW_GUARD="${OPENCODE_RESOLVING_SHELL_ENVIRONMENT:-unset}"',
"export SHLVL=1 PWD=/rc OLDPWD=/rc",
'eval "$2"',
].join("\n"),
{ mode: 0o755 },
)
})
afterAll(() => fs.rm(root, { recursive: true, force: true }))
const skip = process.platform === "win32"
const launchd = { SHELL: "", HOME: "", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }
const resolve = (env: Record<string, string | undefined>) =>
Effect.runPromise(
ShellEnvironment.resolve({ ...launchd, ...env, SHELL: env.SHELL ?? shell, HOME: root }).pipe(
Effect.provide(NodeServices.layer),
),
)
test.skipIf(skip)("adopts the login shell's exports over a launchd environment", async () => {
const variables = await resolve({})
expect(variables?.PATH).toBe("/rc/bin:/usr/bin:/bin:/usr/sbin:/sbin")
expect(variables?.FROM_RC).toBe("yes")
expect(variables?.MULTILINE).toBe("first\nsecond")
expect(variables?.SAW_GUARD).toBe("1")
for (const key of [ShellEnvironment.RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
expect(variables).not.toHaveProperty(key)
})
test.skipIf(skip)("does not probe when the environment already came from a shell or a probe", async () => {
const before = await fs.readFile(invocations, "utf8").catch(() => "")
expect(await resolve({ SHLVL: "1" })).toBeUndefined()
expect(await resolve({ [ShellEnvironment.RESOLVING]: "1" })).toBeUndefined()
expect(await fs.readFile(invocations, "utf8").catch(() => "")).toBe(before)
})
test.skipIf(skip)("falls back to the inherited environment when the shell cannot report", async () => {
expect(await resolve({ SHELL: "/bin/false" })).toBeUndefined()
expect(await resolve({ SHELL: path.join(root, "missing-shell") })).toBeUndefined()
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+3 -2
View File
@@ -15,6 +15,7 @@ import type { SessionMessage } from "@opencode/schema/session-message"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Command } from "@opencode/schema/command"
import type { Skill } from "@opencode/schema/skill"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
@@ -29,7 +30,6 @@ import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
import type { FileSystem } from "@opencode/schema/filesystem"
import type { Command } from "@opencode/schema/command"
import type { OpenCodeEvent } from "@opencode/protocol/groups/event"
import type { Pty } from "@opencode/schema/pty"
import type { PtyTicket } from "@opencode/schema/pty-ticket"
@@ -266,13 +266,14 @@ export type SessionPromptOperation<E = never> = (input: SessionPromptInput) => E
export type SessionCommandInput = {
readonly sessionID: Session.ID
readonly name: string
readonly id?: SessionMessage.ID | undefined
readonly text: string
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type SessionCommandOutput = void
export type SessionCommandOutput = Command.Outcome
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
export type SessionSkillInput = {
@@ -484,13 +484,17 @@ const EndpointSessionCommand = (raw: RawClient["server.session"]) => (input: Ses
params: { sessionID: input["sessionID"] },
payload: {
name: input["name"],
id: input["id"],
text: input["text"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
},
}).pipe(Effect.mapError(mapClientError)),
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
@@ -682,24 +682,25 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<SessionCommandOutput>(
request<{ readonly data: SessionCommandOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/command`,
body: {
name: input["name"],
id: input["id"],
text: input["text"],
files: input["files"],
agents: input["agents"],
skills: input["skills"],
delivery: input["delivery"],
},
successStatus: 204,
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: true,
empty: false,
},
requestOptions,
),
).then((value) => value.data),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>(
{
+29 -1
View File
@@ -161,6 +161,8 @@ export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
export type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxCompactionPayload = {}
@@ -4107,6 +4109,7 @@ export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly name: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4124,8 +4127,29 @@ export type SessionCommandInput = {
}>
readonly delivery?: ("steer" | "queue") | null
}["name"]
readonly id?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
readonly name?: string
readonly description?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly agents?: ReadonlyArray<{
readonly name: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly skills?: ReadonlyArray<{
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
}["id"]
readonly text: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4145,6 +4169,7 @@ export type SessionCommandInput = {
}["text"]
readonly files?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4164,6 +4189,7 @@ export type SessionCommandInput = {
}["files"]
readonly agents?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4183,6 +4209,7 @@ export type SessionCommandInput = {
}["agents"]
readonly skills?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4202,6 +4229,7 @@ export type SessionCommandInput = {
}["skills"]
readonly delivery?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4221,7 +4249,7 @@ export type SessionCommandInput = {
}["delivery"]
}
export type SessionCommandOutput = void
export type SessionCommandOutput = { data: CommandOutcome }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.4",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+32 -3
View File
@@ -4,6 +4,7 @@ import { Command } from "@opencode/schema/command"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionMessage } from "@opencode/schema/session-message"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Bus } from "./bus.js"
@@ -11,10 +12,23 @@ import { State } from "./state.js"
export const Info = Command.Info
export type Info = Command.Info
export const Outcome = Command.Outcome
export type Outcome = Command.Outcome
export { Event } from "@opencode/schema/command"
export const immediate: Outcome = { type: "immediate" }
export function prompted(admitted: { readonly id: SessionMessage.ID }): Outcome {
return { type: "prompt", inboxID: admitted.id }
}
export interface Invocation {
readonly sessionID: Session.ID
/**
* Identity for the input this command admits. Commands that prompt a Session must admit
* with this ID and report it in their outcome so clients can follow the resulting work.
*/
readonly messageID: SessionMessage.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
@@ -22,7 +36,8 @@ export interface Invocation {
export interface Definition {
readonly name: string
readonly description?: string
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
/** Resolve with an Outcome to report admitted work; `void` means the command finished immediately. */
readonly execute: (input: Invocation) => Effect.Effect<Outcome | void, unknown>
}
export type Editor = {
@@ -45,7 +60,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly execute: (input: {
readonly name: string
readonly invocation: Invocation
}) => Effect.Effect<void, NotFoundError | ExecutionError>
}) => Effect.Effect<Outcome, NotFoundError | ExecutionError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
@@ -82,10 +97,22 @@ export const layer = Layer.effect(
const definition = state.get().get(input.name)
if (!definition)
return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` })
return yield* definition.execute(input.invocation).pipe(
const outcome = yield* definition.execute(input.invocation).pipe(
Effect.tapError((error) => Effect.logError("command execution failed", { command: input.name, error })),
Effect.mapError((error) => new ExecutionError({ command: input.name, message: errorMessage(error) })),
)
if (outcome === undefined) return immediate
// Plugin callbacks cross a JavaScript boundary; only a well-formed outcome is reportable.
if (!isOutcome(outcome))
return yield* new ExecutionError({ command: input.name, message: "Command returned an invalid outcome" })
// Clients follow the invocation ID, so a command that admitted under another ID (typically by
// omitting `id: messageID` from its prompt) would leave them waiting forever. Fail loudly instead.
if (outcome.type === "prompt" && outcome.inboxID !== input.invocation.messageID)
return yield* new ExecutionError({
command: input.name,
message: `Command admitted ${outcome.inboxID} instead of the invocation message ID ${input.invocation.messageID}`,
})
return outcome
}),
})
}),
@@ -97,6 +124,8 @@ export const node = makeLocationNode({
deps: [Bus.node],
})
const isOutcome = Schema.is(Outcome)
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "string") return error
+8 -4
View File
@@ -11,6 +11,7 @@ import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { Command } from "../../command.js"
import { Location } from "../../location.js"
import { Session } from "../../session.js"
import { SubagentJob } from "../../session/subagent-job.js"
@@ -104,9 +105,10 @@ export const Plugin = define({
agent: selected.id,
model: model ?? selected.info?.model ?? parent.model,
})
yield* sessions.prompt({
const admitted = yield* sessions.prompt({
...input.prompt,
sessionID: child.id,
id: input.messageID,
text: ["You are a subagent spawned by another session.", text].join("\n"),
resume: false,
})
@@ -119,20 +121,22 @@ export const Plugin = define({
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
return
return Command.prompted(admitted)
}
if (agent !== undefined) {
const session = yield* ctx.session.get({ sessionID: input.sessionID })
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
}
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
yield* ctx.session.prompt({
const admitted = yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text,
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
return Command.prompted(admitted)
}),
})
}
}
+9 -4
View File
@@ -3,6 +3,7 @@ export * as CommandPlugin from "./command.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Bus } from "../bus.js"
import { Command } from "../command.js"
import { Location } from "../location.js"
import { Mcp } from "../mcp/index.js"
import PROMPT_INITIALIZE from "./command/initialize.txt"
@@ -34,10 +35,11 @@ export const Plugin = define({
.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: append(PROMPT_INITIALIZE.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
.pipe(Effect.map(Command.prompted)),
})
editor.add({
name: "review",
@@ -47,10 +49,11 @@ export const Plugin = define({
.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: append(PROMPT_REVIEW.replace("${path}", location.project.directory), input.prompt.text),
delivery: input.delivery,
})
.pipe(Effect.asVoid),
.pipe(Effect.map(Command.prompted)),
})
for (const prompt of loaded.prompts) {
editor.add({
@@ -67,16 +70,18 @@ export const Plugin = define({
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
yield* ctx.session.prompt({
const admitted = yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: result.messages
.map((message) => promptMessageText(message.content))
.join("\n")
.trim(),
delivery: input.delivery,
})
}).pipe(Effect.asVoid),
return Command.prompted(admitted)
}),
})
}
})
+1 -2
View File
@@ -167,8 +167,7 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
})
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
draft.transport = "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
+1 -1
View File
@@ -98,7 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
draft.transport = "websocket"
})
}
})
+2 -1
View File
@@ -186,12 +186,13 @@ export interface Interface {
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
id?: SessionMessage.ID
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
}) => Effect.Effect<Command.Outcome, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
+4 -1
View File
@@ -3,6 +3,7 @@ export * as SessionCommand from "./command.js"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect } from "effect"
import { Command } from "../command.js"
import { Instance } from "../instance/service.js"
@@ -11,6 +12,7 @@ import { Plugin } from "../plugin/service.js"
export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
session: Session.Info
command: string
id?: SessionMessage.ID
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
@@ -19,10 +21,11 @@ export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
}) {
const instances = yield* Instance.Service
const commands = yield* Plugin.awaitActivation.pipe(Effect.andThen(Command.Service), instances.provide(input.session))
yield* commands.execute({
return yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.session.id,
messageID: input.id ?? SessionMessage.ID.create(),
prompt: {
text: input.text,
files: input.files,
@@ -100,6 +100,7 @@ export const layer = (options?: Options) =>
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
@@ -123,9 +124,7 @@ export const layer = (options?: Options) =>
state,
text,
}),
// Restart notices must not revive idle owners of long-lived shells.
// Interrupted executions resume separately after their notices are admitted.
resume: false,
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -209,7 +208,7 @@ export const layer = (options?: Options) =>
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
? recoverShell(background, recovery, suspended)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
+34 -2
View File
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
import { Command } from "@opencode/core/command"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Effect } from "effect"
import { testEffect } from "./lib/effect"
@@ -16,7 +17,10 @@ describe("Command", () => {
editor.add({
name: "goal",
description: "Manage the session goal",
execute: (input) => Effect.sync(() => calls.push(input)),
execute: (input) =>
Effect.sync(() => {
calls.push(input)
}),
})
})
@@ -25,14 +29,41 @@ describe("Command", () => {
)
const invocation = {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_goal"),
prompt: { text: "ship it", files: [{ uri: "file:///tmp/plan.md" }] },
delivery: "steer" as const,
}
yield* command.execute({ name: "goal", invocation })
expect(yield* command.execute({ name: "goal", invocation })).toEqual(Command.immediate)
expect(calls).toEqual([invocation])
}),
)
it.effect("returns prompt outcomes and rejects ones admitted under another message ID", () =>
Effect.gen(function* () {
const command = yield* Command.Service
yield* command.transform((editor) => {
editor.add({ name: "ask", execute: (input) => Effect.succeed(Command.prompted({ id: input.messageID })) })
editor.add({
name: "stray",
execute: () => Effect.succeed(Command.prompted({ id: SessionMessage.ID.make("msg_other") })),
})
})
const invocation = {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_ask"),
prompt: { text: "" },
delivery: "steer" as const,
}
expect(yield* command.execute({ name: "ask", invocation })).toEqual(
Command.prompted({ id: invocation.messageID }),
)
const error = yield* command.execute({ name: "stray", invocation }).pipe(Effect.flip)
expect(error).toBeInstanceOf(Command.ExecutionError)
expect(error.message).toBe("Command admitted msg_other instead of the invocation message ID msg_ask")
}),
)
it.effect("replaces commands with later definitions", () =>
Effect.gen(function* () {
const command = yield* Command.Service
@@ -60,6 +91,7 @@ describe("Command", () => {
name: "fail",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_fail"),
prompt: { text: "" },
delivery: "steer",
},
@@ -13,6 +13,7 @@ import { Model } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
@@ -83,12 +84,18 @@ describe("command subagents", () => {
const gate = yield* llm.gate()
// This must return while the child's model is still blocked.
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
const outcome = yield* sessions.command({
sessionID: parent.id,
command: "review",
id: SessionMessage.ID.make("msg_review"),
text: "changes",
})
yield* gate.started
const children = (yield* sessions.list({ parentID: parent.id })).data
expect(children).toHaveLength(1)
const child = children[0]
if (!child) return yield* Effect.die("Expected a child session")
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_review") })
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
expect(yield* sessions.context(parent.id)).toEqual([])
+5 -2
View File
@@ -105,7 +105,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
Effect.sync(() => {
prompts.push({ text: input.text, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
id: input.id ?? SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -129,6 +129,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
name: "explain",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_explain"),
prompt: { text: item.input },
delivery: "queue",
},
@@ -176,7 +177,7 @@ Review files`,
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
id: input.id ?? SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -210,6 +211,7 @@ Review files`,
name: "nested/docs",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_docs"),
prompt: { text: "details", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
@@ -236,6 +238,7 @@ Review files`,
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_latest"),
prompt: { text: "latest" },
delivery: "steer",
},
@@ -10,9 +10,6 @@ import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
import { XAIPlugin } from "@opencode/core/plugin/provider/xai"
import { Provider } from "@opencode/core/provider"
import { withEnv } from "../fixture/env"
import { testEffect } from "../lib/effect"
@@ -109,50 +106,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
for (const builtin of [
{ id: "openai", model: "gpt-5.6-sol", package: "@opencode/ai/providers/openai/responses", plugin: OpenAIPlugin },
{ id: "xai", model: "grok-4.6", package: "@opencode/ai/providers/xai", plugin: XAIPlugin },
{ id: "azure", model: "gpt-5.6-sol", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
{ id: "custom-azure", model: "deployment", package: "@opencode/ai/providers/azure/responses", plugin: AzurePlugin },
]) {
it.live(`provider transport overrides ${builtin.id} defaults while model overrides still win`, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const providerID = Provider.ID.make(builtin.id)
const modelID = Model.ID.make(builtin.model)
yield* providers.transform((editor) => {
editor.update(providerID, (provider) => {
provider.activation = "enabled"
provider.package = builtin.package
})
editor.models.update(providerID, modelID, () => {})
})
yield* builtin.plugin.effect(host)
expect((yield* models.get(providerID, modelID))?.transport).toBe("websocket")
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
[builtin.id]: {
transport: "http",
models: { override: { modelID: builtin.model, transport: "websocket" } },
},
},
}),
}),
])
expect((yield* models.get(providerID, modelID))?.transport).toBe("http")
expect((yield* models.get(providerID, Model.ID.make("override")))?.transport).toBe("websocket")
}),
)
}
it.effect("adds key auth for custom providers without env credentials", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
+7 -1
View File
@@ -11,6 +11,7 @@ import { PluginModule } from "@opencode/core/plugin/module"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { fromPromise } from "@opencode/plugin/promise/adapter"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@@ -497,7 +498,12 @@ it.effect("reloading a plugin replaces its command implementation", () =>
])
const request = {
name: "greet",
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
invocation: {
sessionID: Session.ID.make("ses_plugin"),
messageID: SessionMessage.ID.make("msg_greet"),
prompt: { text: "" },
delivery: "steer" as const,
},
}
yield* load("1", "before")
+13 -3
View File
@@ -37,6 +37,7 @@ describe("CommandPlugin.Plugin", () => {
Effect.gen(function* () {
const command = yield* Command.Service
const prompts: {
id?: string
text: string
files?: readonly { readonly uri: string }[]
delivery?: "steer" | "queue"
@@ -51,9 +52,9 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
prompts.push({ id: input.id, text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: SessionMessage.ID.make("msg_test"),
id: input.id ?? SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -78,10 +79,11 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
})
yield* command.execute({
const outcome = yield* command.execute({
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_init"),
prompt: { text: "extra context", files: [{ uri: "file:///tmp/context.md" }] },
delivery: "queue",
},
@@ -90,6 +92,7 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_review"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
@@ -98,6 +101,7 @@ describe("CommandPlugin.Plugin", () => {
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_init_empty"),
prompt: { text: "" },
delivery: "steer",
},
@@ -106,27 +110,33 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_review_empty"),
prompt: { text: " " },
delivery: "steer",
},
})
expect(outcome).toEqual({ type: "prompt", inboxID: SessionMessage.ID.make("msg_init") })
expect(prompts).toEqual([
{
id: "msg_init",
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", "extra context"),
files: [{ uri: "file:///tmp/context.md" }],
delivery: "queue",
},
{
id: "msg_review",
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", () => "branch $& $$ $` $'"),
files: undefined,
delivery: "steer",
},
{
id: "msg_init_empty",
text: PROMPT_INITIALIZE.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
},
{
id: "msg_review_empty",
text: PROMPT_REVIEW.replace("${path}", project).replaceAll("$ARGUMENTS", ""),
files: undefined,
delivery: "steer",
@@ -586,26 +586,26 @@ describe("OpencodePlugin", () => {
expect(yield* websearch.default()).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("50 seconds")
yield* TestClock.adjust("9 minutes")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("10 seconds")
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(2)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(3)
expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 })
expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" })
state.advertised = false
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("10 minutes")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
+3 -16
View File
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
})
describe("SessionRestart background recovery", () => {
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
@@ -416,20 +416,6 @@ describe("SessionRestart background recovery", () => {
yield* restart.resumeSuspendedSessions
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
expect(yield* restarted.pendingBackground).toEqual([])
yield* restart.resumeSuspendedSessions
expect(drained).toEqual([])
expect(yield* SessionInbox.list(database.db, parent)).toHaveLength(1)
expect(yield* SessionInbox.list(database.db, child)).toHaveLength(1)
yield* seedInbox(database, parent, ["steer"])
yield* seedInbox(database, child, ["steer"])
yield* execution.wake(parent)
yield* execution.wake(child)
yield* Effect.forEach([parent, child], execution.awaitIdle, { discard: true })
expect(drained.toSorted()).toEqual([parent, child].toSorted())
expect((yield* store.context(parent)).filter((message) => message.type === "synthetic")).toMatchObject([
{
@@ -523,7 +509,7 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([])
expect(drained).toEqual([sessionID])
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
@@ -575,6 +561,7 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.4",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+11 -1
View File
@@ -1,20 +1,30 @@
import type { CommandApi } from "@opencode/client/effect/api"
import type { Command } from "@opencode/schema/command"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface CommandInvocation {
readonly sessionID: Session.ID
/**
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
* `prompt` outcome so clients can follow the resulting work.
*/
readonly messageID: SessionMessage.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
export type CommandOutcome = Command.Outcome
export interface CommandDefinition {
readonly name: string
readonly description?: string
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
readonly execute: (input: CommandInvocation) => Effect.Effect<CommandOutcome | void, unknown>
}
export interface CommandEditor {
+11 -1
View File
@@ -1,19 +1,29 @@
import type { CommandApi } from "@opencode/client/promise/api"
import type { Command } from "@opencode/schema/command"
import type { PromptInput } from "@opencode/schema/prompt-input"
import type { Session } from "@opencode/schema/session"
import type { SessionInbox } from "@opencode/schema/session-inbox"
import type { SessionMessage } from "@opencode/schema/session-message"
import type { Transform } from "./registration.js"
export interface CommandInvocation {
readonly sessionID: Session.ID
/**
* Identity for the input this command admits. Pass it as the prompt `id` and report it in a
* `prompt` outcome so clients can follow the resulting work.
*/
readonly messageID: SessionMessage.ID
readonly prompt: PromptInput.Prompt
readonly delivery: SessionInbox.Delivery
}
/** Resolve with `prompt` when the command admitted session input; `void` or `immediate` finish the command now. */
export type CommandOutcome = Command.Outcome
export interface CommandDefinition {
readonly name: string
readonly description?: string
readonly execute: (input: CommandInvocation) => Promise<void>
readonly execute: (input: CommandInvocation) => Promise<CommandOutcome | void>
}
export interface CommandEditor {
+60 -3
View File
@@ -2066,8 +2066,22 @@
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
@@ -2120,7 +2134,7 @@
}
}
},
"description": "Execute a slash command callback immediately.",
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2131,6 +2145,18 @@
"name": {
"type": "string"
},
"id": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Message ID for input the command admits, so its resulting work can be correlated."
},
"text": {
"type": "string"
},
@@ -12467,6 +12493,37 @@
"required": ["name"],
"additionalProperties": false
},
"Command.Outcome": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["immediate"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["prompt"]
},
"inboxID": {
"type": "string",
"pattern": "^msg_",
"description": "Admitted inbox item ID. Must equal the invocation message ID."
}
},
"required": ["type", "inboxID"],
"additionalProperties": false
}
]
},
"CommandExecutionErrorEncoded": {
"type": "object",
"properties": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+8 -5
View File
@@ -33,6 +33,7 @@ import {
UnknownError,
} from "../errors.js"
import { Agent } from "@opencode/schema/agent"
import { Command } from "@opencode/schema/command"
import { Skill } from "@opencode/schema/skill"
import { Model } from "@opencode/schema/model"
import { Permission } from "@opencode/schema/permission"
@@ -414,10 +415,13 @@ export const makeSessionGroup = <
params: { sessionID: Session.ID },
payload: Schema.Struct({
name: Schema.String,
id: SessionMessage.ID.pipe(Schema.optional).annotate({
description: "Message ID for input the command admits, so its resulting work can be correlated.",
}),
...PromptInput.Prompt.fields,
delivery: SessionInbox.Delivery.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
success: Schema.Struct({ data: Command.Outcome }),
error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError],
})
.middleware(sessionLocationMiddleware)
@@ -425,7 +429,8 @@ export const makeSessionGroup = <
OpenApi.annotations({
identifier: "session.command",
summary: "Run command",
description: "Execute a slash command callback immediately.",
description:
"Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
}),
),
)
@@ -558,9 +563,7 @@ export const makeSessionGroup = <
error: [SessionNotFoundError, SessionBusyError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
),
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" })),
)
.add(
HttpApiEndpoint.get("session.context", "/api/session/:sessionID/context", {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+17
View File
@@ -3,6 +3,7 @@ export * as Command from "./command.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { optional } from "./schema.js"
import { SessionMessage } from "./session-message.js"
const Updated = ephemeral({ type: "command.updated", schema: {} })
@@ -12,6 +13,22 @@ export const Info = Schema.Struct({
description: Schema.String.pipe(optional),
}).annotate({ identifier: "Command.Info" })
/**
* What a command did when invoked. `immediate` commands finish inside the request;
* `prompt` commands durably admitted session input whose resulting work clients
* follow through the invocation message ID.
*/
export const Outcome = Schema.Union([
Schema.Struct({ type: Schema.tag("immediate") }),
Schema.Struct({
type: Schema.tag("prompt"),
inboxID: SessionMessage.ID.annotate({
description: "Admitted inbox item ID. Must equal the invocation message ID.",
}),
}),
]).pipe(Schema.toTaggedUnion("type"), Schema.annotate({ identifier: "Command.Outcome" }))
export type Outcome = typeof Outcome.Type
export const Event = {
Updated,
Definitions: inventory(Updated),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.4",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+3 -2
View File
@@ -336,10 +336,11 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.command",
Effect.fn(function* (ctx) {
yield* session
const outcome = yield* session
.command({
sessionID: ctx.params.sessionID,
command: ctx.payload.name,
id: ctx.payload.id,
text: ctx.payload.text,
files: ctx.payload.files,
agents: ctx.payload.agents,
@@ -365,7 +366,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
),
),
)
return HttpApiSchema.NoContent.make()
return { data: outcome }
}),
)
.handle(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
describe("inference stat normalization", () => {
@@ -38,22 +38,6 @@ describe("inference stat normalization", () => {
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
})
test("merges union alpha routes without exposing the provider", () => {
;["opencode-go/union-alpha", "opencode/union-alpha"].forEach((model) => {
expect(statModel(model, "")).toBe("union-alpha")
expect(statProvider(model, "gpt-test-model", "test-provider")).toBe("unknown")
expect(
toModelAggregate({ ...aggregate(model, "test-provider"), provider_model: "gpt-test-model" }),
).toMatchObject([{ model: "union-alpha", provider: "unknown", requests: 1 }])
expect(toProviderAggregate(aggregate(model, "test-provider"))).toMatchObject([
{ provider: "unknown", requests: 1 },
])
expect(toGeoAggregate({ ...aggregate(model, "test-provider"), country: "US" })).toMatchObject([
{ model: "union-alpha", provider: "unknown", country: "US", requests: 1 },
])
})
})
test("merges renamed models under their current name", () => {
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
@@ -110,20 +94,6 @@ describe("inference stat normalization", () => {
}),
).toMatchObject([{ period_key: "2026-W20" }])
})
test("normalizes union alpha routes in stat queries", () => {
process.env.SST_RESOURCE_InferenceEvent = JSON.stringify({
catalog: "inference",
database: "events",
table: "generation",
})
process.env.SST_RESOURCE_StatsSyncConfig = JSON.stringify({ dataset: "zen" })
const query = buildStatsQuery(new Date("2026-05-20T00:00:00.000Z"), new Date("2026-05-21T00:00:00.000Z"))
expect(query).toContain("WHEN lower(model) = 'opencode-go/union-alpha' THEN 'union-alpha'")
expect(query).toContain("WHEN lower(model) = 'opencode/union-alpha' THEN 'union-alpha'")
expect(query).toContain("WHEN lower(model) IN ('omen-alpha', 'union-alpha') THEN 'unknown'")
})
})
function aggregate(model: string, provider: string) {
@@ -7,7 +7,6 @@ import {
MODEL_AUTHOR_RULES,
MODEL_NAME_ALIASES,
RETIRED_STAT_PROVIDERS,
STEALTH_MODELS,
statModel,
statProvider,
} from "./model-normalization"
@@ -264,7 +263,6 @@ ${Object.entries(MODEL_NAME_ALIASES)
function statProviderSql(model: string, providerModel: string, provider: string) {
return `CASE
WHEN lower(${model}) IN (${[...STEALTH_MODELS].map(sqlString).join(", ")}) THEN 'unknown'
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${providerModel}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
WHEN ${provider} <> '' AND lower(${provider}) NOT IN (${RETIRED_STAT_PROVIDERS.map(sqlString).join(", ")}) THEN ${provider}
@@ -13,11 +13,8 @@ export const MODEL_AUTHOR_RULES = [
{ match: "qwen", author: "qwen" },
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export const STEALTH_MODELS = new Set(["omen-alpha", "union-alpha"])
export const MODEL_NAME_ALIASES: Record<string, string> = {
"deepseek-flash": "deepseek-v4.1-flash",
"opencode-go/union-alpha": "union-alpha",
"opencode/union-alpha": "union-alpha",
"x-preview-f": "ox-alpha",
"xiaomi/mimo-v2.5": "mimo-v2.5",
}
@@ -48,10 +45,7 @@ export function statProvider(
providerModel: string | undefined,
provider: string | undefined,
) {
const normalized = statModel(model, providerModel)
if (STEALTH_MODELS.has(normalized.toLowerCase())) return "unknown"
const modelAuthorValue = modelAuthor(normalized)
const modelAuthorValue = modelAuthor(statModel(model, providerModel))
if (!modelAuthorValue) return undefined
const providerModelAuthor = modelAuthor(providerModel)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -128,7 +128,7 @@ export function createTuiAttention(input: {
return {
async notify(request) {
try {
if (!input.config.attention.notifications && !input.config.attention.sound) return skipped("attention_disabled")
if (!input.config.attention.enabled) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
+12 -3
View File
@@ -253,11 +253,20 @@ export const settings: Setting[] = [
format: (value) => `${value} ms`,
keywords: ["leader key", "shortcut timeout"],
},
{
title: "Attention",
category: "Alerts",
path: ["attention", "enabled"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["alerts"],
},
{
title: "Notifications",
category: "Alerts",
path: ["attention", "notifications"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["system notifications", "desktop notifications", "alerts"],
@@ -266,10 +275,10 @@ export const settings: Setting[] = [
title: "Sounds",
category: "Alerts",
path: ["attention", "sound"],
default: false,
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["audio", "sound effects", "alerts"],
keywords: ["audio", "sound effects"],
},
{
title: "Volume",
+5 -2
View File
@@ -95,6 +95,7 @@ export const Info = Schema.Struct({
).annotate({ description: "Scrolling behavior" }),
attention: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({ description: "Enable attention alerts" }),
notifications: Schema.optional(Schema.Boolean).annotate({ description: "Show system notifications" }),
sound: Schema.optional(Schema.Boolean).annotate({ description: "Play attention sounds" }),
volume: Schema.optional(
@@ -241,6 +242,7 @@ export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
sound: boolean
volume: number
@@ -283,8 +285,9 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
return {
...input,
attention: {
notifications: input.attention?.notifications ?? false,
sound: input.attention?.sound ?? false,
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
sound: input.attention?.sound ?? true,
volume: input.attention?.volume ?? 0.4,
sound_pack: input.attention?.sound_pack ?? "opencode.default",
sounds: input.attention?.sounds ?? {},
+2 -1
View File
@@ -1751,7 +1751,8 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
return client.session.command(
// Command outcomes are not inbox items; the event stream observes any admitted work.
await client.session.command(
{
sessionID: input.sessionID,
name: command.name,
@@ -712,9 +712,9 @@ describe("V2 mini transport", () => {
})
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
expect(ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text)).toEqual([
"Done.",
])
expect(
ui.commits.filter((commit) => commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
).toEqual(["Done."])
await transport.close()
})
@@ -742,7 +742,11 @@ describe("V2 mini transport", () => {
model: { providerID: "test", id: "model" },
content: [
{ type: "text", text: "I'll check." },
canonicalToolPart("read", { status: "completed", input: {}, content: [{ type: "text", text: "file" }] }),
canonicalToolPart("read", {
status: "completed",
input: {},
content: [{ type: "text", text: "file" }],
}),
],
time: { created: 2, completed: 3 },
},
@@ -759,7 +763,9 @@ describe("V2 mini transport", () => {
while (!ui.commits.some((commit) => commit.text === "Done.")) await Bun.sleep(0)
expect(
ui.commits.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool").map((commit) => commit.text),
ui.commits
.filter((commit) => commit.kind === "user" || commit.kind === "assistant" || commit.kind === "tool")
.map((commit) => commit.text),
).toEqual(["what happened", "Done."])
await transport.close()
})
@@ -2929,10 +2935,7 @@ describe("V2 mini transport", () => {
{ sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } },
{ signal: undefined },
)
expect(defaultModel).toHaveBeenCalledWith(
{ location: { directory: "/project" } },
{ signal: undefined },
)
expect(defaultModel).toHaveBeenCalledWith({ location: { directory: "/project" } }, { signal: undefined })
await transport.close()
})
@@ -3406,7 +3409,7 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1" },
})
})
return ok(undefined)
return ok({ type: "prompt" as const, inboxID: "msg_cmd" })
})
await transport.runPromptTurn({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.5",
"version": "2.0.4",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "2.0.5",
"version": "2.0.4",
"publisher": "sst-dev",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.4",
"private": true,
"type": "module",
"scripts": {
+60 -3
View File
@@ -2066,8 +2066,22 @@
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
@@ -2120,7 +2134,7 @@
}
}
},
"description": "Execute a slash command callback immediately.",
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2131,6 +2145,18 @@
"name": {
"type": "string"
},
"id": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Message ID for input the command admits, so its resulting work can be correlated."
},
"text": {
"type": "string"
},
@@ -12467,6 +12493,37 @@
"required": ["name"],
"additionalProperties": false
},
"Command.Outcome": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["immediate"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["prompt"]
},
"inboxID": {
"type": "string",
"pattern": "^msg_",
"description": "Admitted inbox item ID. Must equal the invocation message ID."
}
},
"required": ["type", "inboxID"],
"additionalProperties": false
}
]
},
"CommandExecutionErrorEncoded": {
"type": "object",
"properties": {
+60 -3
View File
@@ -2066,8 +2066,22 @@
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
@@ -2120,7 +2134,7 @@
}
}
},
"description": "Execute a slash command callback immediately.",
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2131,6 +2145,18 @@
"name": {
"type": "string"
},
"id": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
],
"description": "Message ID for input the command admits, so its resulting work can be correlated."
},
"text": {
"type": "string"
},
@@ -12467,6 +12493,37 @@
"required": ["name"],
"additionalProperties": false
},
"Command.Outcome": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["immediate"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["prompt"]
},
"inboxID": {
"type": "string",
"pattern": "^msg_",
"description": "Admitted inbox item ID. Must equal the invocation message ID."
}
},
"required": ["type", "inboxID"],
"additionalProperties": false
}
]
},
"CommandExecutionErrorEncoded": {
"type": "object",
"properties": {
@@ -471,7 +471,7 @@ effect: (ctx) =>
}),
```
The current command transform is add-only. An executor receives the session, prompt attachments, and delivery mode.
The current command transform is add-only. An executor receives the session, a message ID for admitted input, prompt attachments, and delivery mode. Return a `prompt` outcome when the command admits session input so clients follow that work; succeed with `void` for immediate actions.
```ts
effect: (ctx) =>
@@ -483,12 +483,15 @@ effect: (ctx) =>
name: "security-review",
description: "Review changes for security issues",
execute: (input) =>
session.prompt({
...input.prompt,
sessionID: input.sessionID,
text: `Review these changes for security issues.\n\n${input.prompt.text}`,
delivery: input.delivery,
}).pipe(Effect.asVoid),
session
.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text: `Review these changes for security issues.\n\n${input.prompt.text}`,
delivery: input.delivery,
})
.pipe(Effect.map((admitted) => ({ type: "prompt" as const, inboxID: admitted.id }))),
})
})
yield* command.reload()
@@ -419,25 +419,48 @@ Read the commands available at a location.
const commands = await ctx.command.list()
```
Register commands with a transform. The executor receives the session, prompt attachments, and requested delivery mode.
Register commands with a transform. The executor receives the session, a message ID for any input it admits, prompt attachments, and the requested delivery mode.
```ts
await ctx.command.transform((editor) => {
editor.add({
name: "security-review",
description: "Review changes for security issues",
execute: async ({ sessionID, prompt, delivery }) => {
await ctx.session.prompt({
execute: async ({ sessionID, messageID, prompt, delivery }) => {
const admitted = await ctx.session.prompt({
...prompt,
sessionID,
id: messageID,
text: `Review these changes for security issues.\n\n${prompt.text}`,
delivery,
})
return { type: "prompt", inboxID: admitted.id }
},
})
})
```
#### Outcomes
Return an outcome so clients know whether the command finished or started model work they should follow.
| Outcome | Meaning |
| -------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `undefined` or `{ type: "immediate" }` | The command finished inside the request. Clients complete the slash command right away. |
| `{ type: "prompt", inboxID }` | The command admitted session input. Clients keep the slash command open until that work settles. |
Admit with `id: messageID` and echo it as `inboxID`; a different `inboxID` fails the command. The input may go to the invoking session or to a child session it creates.
```ts
editor.add({
name: "clear-scratch",
description: "Delete the scratch directory",
execute: async () => {
await ctx.storage.remove("scratch")
},
})
```
Reload commands after external state used by a transform changes.
```ts
@@ -447,6 +470,7 @@ await ctx.command.reload()
#### Reference
Schemas: [`Command.Info`](/api#schema-Command.Info),
[`Command.Outcome`](/api#schema-Command.Outcome),
[`Session.Inbox.Delivery`](/api#schema-Session.Inbox.Delivery)
```ts
@@ -463,14 +487,17 @@ interface CommandEditor {
interface CommandDefinition {
name: string
description?: string
execute(input: CommandInvocation): Promise<void>
execute(input: CommandInvocation): Promise<CommandOutcome | void>
}
interface CommandInvocation {
sessionID: string
messageID: string
prompt: PromptInput
delivery: "steer" | "queue"
}
type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
```
### Integrations
+4 -2
View File
@@ -108,11 +108,12 @@ Set scroll speed and acceleration:
## Attention
Configure notifications and sounds. Both are off by default and can be enabled independently:
Configure notifications and sounds:
```json title="cli.json"
{
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4,
@@ -126,7 +127,8 @@ Configure notifications and sounds. Both are off by default and can be enabled i
| Field | Values | Description |
| --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `notifications` | boolean | Shows system notifications when the terminal is not focused. |
| `enabled` | boolean | Enables attention alerts. |
| `notifications` | boolean | Shows system notifications. |
| `sound` | boolean | Plays attention sounds. |
| `volume` | number from `0` to `1` | Sets sound volume. |
| `sound_pack` | string | Selects the active sound pack. |