Compare commits

..
Author SHA1 Message Date
Kit Langton b171a295df fix(cli): adopt the login shell environment in the background service
A managed 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 "Connection closed" while the bash
tool, which prefers the client's terminal environment, keeps working.

Resolve the user's interactive login shell once at service startup when the
inherited environment did not come from a shell, and adopt it before anything
reads process.env.
2026-09-16 11:57:09 -07:00
Kit Langton 79d657b8fe fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:43 -04:00
Kit Langton 606ec4fa38 fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:00 -04:00
Shoubhit Dash 2cdd938152 fix: honor provider transport overrides and preserve errors (#49350) 2026-09-16 22:42:23 +05:30
opencode 4d73b40e59 sync release versions for v2.0.5 2026-09-16 16:35:05 +00:00
opencode-agent[bot]andadamdotdevin 276f2eef3e fix(stats): hide Union Alpha provider (#49355)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 10:58:41 -05:00
Dax Raad 8806d34ed4 feat(tui): make notification and sound alerts independent toggles
Remove the attention.enabled master switch. Notifications and sounds are
now enabled independently and both default to off. Existing enabled keys
are ignored; there is no migration.
2026-09-16 11:39:09 -04:00
Shoubhit Dash cda2bc5100 test(core): match the one minute Console config refresh interval (#49327) 2026-09-16 17:37:14 +05:30
92 changed files with 509 additions and 815 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.4",
"version": "2.0.5",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.4",
"version": "2.0.5",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+10 -2
View File
@@ -336,12 +336,18 @@ 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 OpenResponsesErrorPayload = Schema.Struct({
const OpenResponsesErrorObject = 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])
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
? { ...value, type: "error" }
: value,
),
@@ -1,8 +1,9 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
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" })
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,
})
}
}),
)
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,12 +405,7 @@ test("executes a selected slash command after creating its worktree", async ({ p
item: { type: "user", payload: { text: expanded }, delivery: "steer" },
},
})
await route.fulfill({
status: 200,
headers,
contentType: "application/json",
body: JSON.stringify({ data: { type: "prompt", inboxID: "msg_workspace_review" } }),
})
await route.fulfill({ status: 204, headers })
})
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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"bin": {
+6 -23
View File
@@ -1,5 +1,4 @@
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { Command } from "@opencode/schema/command"
import type {
EventSubscribeOutput,
OpenCodeClient,
@@ -38,9 +37,6 @@ 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"
@@ -80,7 +76,8 @@ export async function streamTurn(input: {
readonly cwd: string
readonly start: TurnStart
readonly writeTextFile: boolean
readonly submit: (signal: AbortSignal) => Promise<Admission>
readonly action?: boolean
readonly submit: (signal: AbortSignal) => Promise<unknown>
readonly control: TurnControl
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
readonly connectionSignal?: AbortSignal
@@ -117,14 +114,6 @@ 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)) {
@@ -189,10 +178,9 @@ export async function streamTurn(input: {
if (!started) continue
if (event.type === "session.execution.started") {
if (mode === "turn" && control.cancelled) {
await input.client.session.interrupt({ sessionID: event.data.sessionID }).catch(() => {})
if (child) {
await notifyChild(child, { type: "status", status: "running" })
}
if (child) await notifyChild(child, { type: "status", status: "running" })
continue
}
@@ -354,15 +342,11 @@ 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 {
const admission = await input.submit(control.admission.signal).catch((error): Admission => {
await input.submit(control.admission.signal).catch((error) => {
if (!control.cancelled) throw error
// The request may have been admitted before the abort reached the server; keep observing.
return "prompt"
})
if (admission === "immediate") {
if (input.action) {
streamController.abort()
await completed.catch(() => {})
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
@@ -400,7 +384,6 @@ export async function streamTurn(input: {
await completed.catch(() => {})
throw error
} finally {
control.admission.signal.removeEventListener("abort", onCancel)
if (!handedOff) await closeStream()
}
}
+5 -17
View File
@@ -52,7 +52,6 @@ import {
ChildSessionUpdatesCapability,
replayMessages,
streamTurn,
type Admission,
type ChildSessionUpdate,
type TurnControl,
type TurnStart,
@@ -328,6 +327,7 @@ 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,12 +362,7 @@ 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,
): Promise<Admission> {
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
if (prompt.synthetic.length > 0) {
await client.session.synthetic({
sessionID: session.id,
@@ -377,30 +372,23 @@ async function submitPrompt(
resume: false,
})
}
if (prompt.start.type === "compaction") {
await client.session.compact({ sessionID: session.id, id: prompt.start.id })
return "prompt"
}
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
if (prompt.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(
return 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
}
await client.session.prompt(
return 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 {
+5 -2
View File
@@ -13,6 +13,7 @@ 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"
@@ -28,6 +29,9 @@ 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]), {
@@ -38,9 +42,8 @@ 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
@@ -0,0 +1,80 @@
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,
})
})
@@ -1,138 +0,0 @@
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
)
}
+5 -7
View File
@@ -656,7 +656,6 @@ describe("acp event behavior", () => {
{ signal },
)
submitted.resolve()
return "prompt"
},
})
@@ -695,9 +694,10 @@ 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 })
.then(() => "prompt" as const),
fixture.client.session.prompt(
{ sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
{ signal },
),
})
try {
@@ -784,9 +784,7 @@ 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 })
.then(() => "prompt" as const),
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
})
}
+4 -3
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" }).then(() => "prompt" as const),
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
})
expect(updates).toEqual([
@@ -122,7 +122,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
}
})
test("acp immediate admission resolves without prompt lifecycle events", async () => {
test("acp action resolves without prompt lifecycle events", async () => {
const encoder = new TextEncoder()
const server = Bun.serve({
port: 0,
@@ -150,8 +150,9 @@ test("acp immediate admission 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 () => "immediate",
submit: async () => {},
})
expect(response).toMatchObject({ stopReason: "end_turn" })
@@ -547,10 +547,7 @@ 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 })
.then(() => "prompt" as const),
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
})
}
+1 -2
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 Response.json({ data: { type: "immediate" } })
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/ses_routes/compact") {
const id = requestID(request)
@@ -42,7 +42,6 @@ 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",
+5 -48
View File
@@ -4,7 +4,6 @@ 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"
@@ -47,7 +46,6 @@ 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,
@@ -66,38 +64,7 @@ description: Verifier compatibility skill.
# Verifier Skill
`
/** 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 = {}) {
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
@@ -109,7 +76,7 @@ export async function createAcpFixture(options: FixtureOptions = {}) {
await Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), options.skill)
}
const requests: ChatRequest[] = []
const requests: unknown[] = []
const llm = Bun.serve({
hostname: "127.0.0.1",
port: 0,
@@ -117,19 +84,15 @@ export async function createAcpFixture(options: FixtureOptions = {}) {
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
const body = decodeChatRequest(await request.json())
requests.push(body)
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
requests.push(await request.json().catch(() => undefined))
return new Response(completion("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),
...options.config,
}),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
await Bun.write(models, "{}")
@@ -343,12 +306,6 @@ 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),
@@ -0,0 +1,68 @@
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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+2 -3
View File
@@ -15,7 +15,6 @@ 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"
@@ -30,6 +29,7 @@ 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,14 +266,13 @@ 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 = Command.Outcome
export type SessionCommandOutput = void
export type SessionCommandOperation<E = never> = (input: SessionCommandInput) => Effect.Effect<SessionCommandOutput, E>
export type SessionSkillInput = {
@@ -484,17 +484,13 @@ 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),
Effect.map((value) => value.data),
),
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionSkill = (raw: RawClient["server.session"]) => (input: SessionSkillInput) =>
@@ -682,25 +682,24 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
command: (input: SessionCommandInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionCommandOutput }>(
request<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: 200,
successStatus: 204,
declaredStatuses: [400, 401, 404, 500],
empty: false,
empty: true,
},
requestOptions,
).then((value) => value.data),
),
skill: (input: SessionSkillInput, requestOptions?: RequestOptions) =>
request<SessionSkillOutput>(
{
+1 -29
View File
@@ -161,8 +161,6 @@ 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 = {}
@@ -4109,7 +4107,6 @@ 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
@@ -4127,29 +4124,8 @@ 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
@@ -4169,7 +4145,6 @@ export type SessionCommandInput = {
}["text"]
readonly files?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4189,7 +4164,6 @@ export type SessionCommandInput = {
}["files"]
readonly agents?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4209,7 +4183,6 @@ export type SessionCommandInput = {
}["agents"]
readonly skills?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4229,7 +4202,6 @@ export type SessionCommandInput = {
}["skills"]
readonly delivery?: {
readonly name: string
readonly id?: string | null
readonly text: string
readonly files?: ReadonlyArray<{
readonly uri: string
@@ -4249,7 +4221,7 @@ export type SessionCommandInput = {
}["delivery"]
}
export type SessionCommandOutput = { data: CommandOutcome }["data"]
export type SessionCommandOutput = void
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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.4",
"version": "2.0.5",
"$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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.4",
"version": "2.0.5",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+3 -32
View File
@@ -4,7 +4,6 @@ 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"
@@ -12,23 +11,10 @@ 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
}
@@ -36,8 +22,7 @@ export interface Invocation {
export interface Definition {
readonly name: string
readonly description?: string
/** Resolve with an Outcome to report admitted work; `void` means the command finished immediately. */
readonly execute: (input: Invocation) => Effect.Effect<Outcome | void, unknown>
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Editor = {
@@ -60,7 +45,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly execute: (input: {
readonly name: string
readonly invocation: Invocation
}) => Effect.Effect<Outcome, NotFoundError | ExecutionError>
}) => Effect.Effect<void, NotFoundError | ExecutionError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
@@ -97,22 +82,10 @@ 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}` })
const outcome = yield* definition.execute(input.invocation).pipe(
return 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
}),
})
}),
@@ -124,8 +97,6 @@ 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
+4 -8
View File
@@ -11,7 +11,6 @@ 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"
@@ -105,10 +104,9 @@ export const Plugin = define({
agent: selected.id,
model: model ?? selected.info?.model ?? parent.model,
})
const admitted = yield* sessions.prompt({
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,
})
@@ -121,22 +119,20 @@ export const Plugin = define({
}
yield* subagents.start(recovery)
yield* subagents.background(recovery)
return Command.prompted(admitted)
return
}
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 })
const admitted = yield* ctx.session.prompt({
yield* ctx.session.prompt({
...input.prompt,
sessionID: input.sessionID,
id: input.messageID,
text,
delivery: input.delivery,
})
return Command.prompted(admitted)
}),
}).pipe(Effect.asVoid),
})
}
}
+4 -9
View File
@@ -3,7 +3,6 @@ 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"
@@ -35,11 +34,10 @@ 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.map(Command.prompted)),
.pipe(Effect.asVoid),
})
editor.add({
name: "review",
@@ -49,11 +47,10 @@ 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.map(Command.prompted)),
.pipe(Effect.asVoid),
})
for (const prompt of loaded.prompts) {
editor.add({
@@ -70,18 +67,16 @@ export const Plugin = define({
),
})
if (!result) return yield* Effect.fail(new Error(`MCP prompt not found: ${prompt.server}:${prompt.name}`))
const admitted = yield* ctx.session.prompt({
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,
})
return Command.prompted(admitted)
}),
}).pipe(Effect.asVoid),
})
}
})
+2 -1
View File
@@ -167,7 +167,8 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.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 = "websocket"
draft.transport = models.provider.get(model.providerID)?.provider.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 = "websocket"
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
})
}
})
+1 -2
View File
@@ -186,13 +186,12 @@ 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<Command.Outcome, NotFoundError | Command.NotFoundError | Command.ExecutionError>
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
+1 -4
View File
@@ -3,7 +3,6 @@ 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"
@@ -12,7 +11,6 @@ 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"]
@@ -21,11 +19,10 @@ 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))
return yield* commands.execute({
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,7 +100,6 @@ 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 =
@@ -124,7 +123,9 @@ export const layer = (options?: Options) =>
state,
text,
}),
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
// Restart notices must not revive idle owners of long-lived shells.
// Interrupted executions resume separately after their notices are admitted.
resume: false,
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -208,7 +209,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, suspended)
? recoverShell(background, recovery)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
+2 -34
View File
@@ -2,7 +2,6 @@ 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"
@@ -17,10 +16,7 @@ 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)),
})
})
@@ -29,41 +25,14 @@ 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,
}
expect(yield* command.execute({ name: "goal", invocation })).toEqual(Command.immediate)
yield* command.execute({ name: "goal", invocation })
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
@@ -91,7 +60,6 @@ describe("Command", () => {
name: "fail",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_fail"),
prompt: { text: "" },
delivery: "steer",
},
@@ -13,7 +13,6 @@ 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"
@@ -84,18 +83,12 @@ describe("command subagents", () => {
const gate = yield* llm.gate()
// This must return while the child's model is still blocked.
const outcome = yield* sessions.command({
sessionID: parent.id,
command: "review",
id: SessionMessage.ID.make("msg_review"),
text: "changes",
})
yield* sessions.command({ sessionID: parent.id, command: "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([])
+2 -5
View File
@@ -105,7 +105,7 @@ describe("ConfigCommandPlugin.Plugin", () => {
Effect.sync(() => {
prompts.push({ text: input.text, delivery: input.delivery })
return SessionInbox.User.make({
id: input.id ?? SessionMessage.ID.make("msg_test"),
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -129,7 +129,6 @@ describe("ConfigCommandPlugin.Plugin", () => {
name: "explain",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_explain"),
prompt: { text: item.input },
delivery: "queue",
},
@@ -177,7 +176,7 @@ Review files`,
Effect.sync(() => {
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: input.id ?? SessionMessage.ID.make("msg_test"),
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -211,7 +210,6 @@ 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",
},
@@ -238,7 +236,6 @@ Review files`,
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_latest"),
prompt: { text: "latest" },
delivery: "steer",
},
@@ -10,6 +10,9 @@ 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"
@@ -106,6 +109,50 @@ 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
+1 -7
View File
@@ -11,7 +11,6 @@ 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"
@@ -498,12 +497,7 @@ it.effect("reloading a plugin replaces its command implementation", () =>
])
const request = {
name: "greet",
invocation: {
sessionID: Session.ID.make("ses_plugin"),
messageID: SessionMessage.ID.make("msg_greet"),
prompt: { text: "" },
delivery: "steer" as const,
},
invocation: { sessionID: Session.ID.make("ses_plugin"), prompt: { text: "" }, delivery: "steer" as const },
}
yield* load("1", "before")
+3 -13
View File
@@ -37,7 +37,6 @@ 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"
@@ -52,9 +51,9 @@ describe("CommandPlugin.Plugin", () => {
session: {
prompt: (input) =>
Effect.sync(() => {
prompts.push({ id: input.id, text: input.text, files: input.files, delivery: input.delivery })
prompts.push({ text: input.text, files: input.files, delivery: input.delivery })
return SessionInbox.User.make({
id: input.id ?? SessionMessage.ID.make("msg_test"),
id: SessionMessage.ID.make("msg_test"),
sessionID: input.sessionID,
time: { created: DateTime.makeUnsafe(0) },
type: "user",
@@ -79,11 +78,10 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
})
const outcome = yield* command.execute({
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",
},
@@ -92,7 +90,6 @@ describe("CommandPlugin.Plugin", () => {
name: "review",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_review"),
prompt: { text: " branch $& $$ $` $' " },
delivery: "steer",
},
@@ -101,7 +98,6 @@ describe("CommandPlugin.Plugin", () => {
name: "init",
invocation: {
sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_init_empty"),
prompt: { text: "" },
delivery: "steer",
},
@@ -110,33 +106,27 @@ 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("9 minutes")
yield* TestClock.adjust("50 seconds")
yield* drain
expect(state.requests).toBe(1)
expect(rebuilds).toEqual(initial)
expect(yield* websearch.default()).toBeUndefined()
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("10 seconds")
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("10 minutes")
yield* TestClock.adjust("1 minute")
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("10 minutes")
yield* TestClock.adjust("1 minute")
yield* drain
expect(state.requests).toBe(4)
expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 })
+16 -3
View File
@@ -379,7 +379,7 @@ describe("SessionExecution lifecycle", () => {
})
describe("SessionRestart background recovery", () => {
it.effect("wakes idle shell owners and delivers recovered notices exactly once", () =>
it.effect("keeps shell owners idle until a user prompt delivers recovered notices exactly once", () =>
Effect.gen(function* () {
const database = yield* Database.Service
const store = yield* SessionStore.Service
@@ -416,6 +416,20 @@ 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([
{
@@ -509,7 +523,7 @@ describe("SessionRestart background recovery", () => {
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
yield* Context.get(context, SessionExecution.Service).awaitIdle(sessionID)
expect(drained).toEqual([sessionID])
expect(drained).toEqual([])
const inbox = yield* SessionInbox.list(database.db, sessionID)
expect(inbox).toMatchObject([
{
@@ -561,7 +575,6 @@ 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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.4",
"version": "2.0.5",
"$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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"scripts": {
+1 -11
View File
@@ -1,30 +1,20 @@
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<CommandOutcome | void, unknown>
readonly execute: (input: CommandInvocation) => Effect.Effect<void, unknown>
}
export interface CommandEditor {
+1 -11
View File
@@ -1,29 +1,19 @@
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<CommandOutcome | void>
readonly execute: (input: CommandInvocation) => Promise<void>
}
export interface CommandEditor {
+3 -60
View File
@@ -2066,22 +2066,8 @@
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
@@ -2134,7 +2120,7 @@
}
}
},
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"description": "Execute a slash command callback immediately.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2145,18 +2131,6 @@
"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"
},
@@ -12493,37 +12467,6 @@
"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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+5 -8
View File
@@ -33,7 +33,6 @@ 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"
@@ -415,13 +414,10 @@ 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: Schema.Struct({ data: Command.Outcome }),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, CommandNotFoundError, CommandExecutionError],
})
.middleware(sessionLocationMiddleware)
@@ -429,8 +425,7 @@ export const makeSessionGroup = <
OpenApi.annotations({
identifier: "session.command",
summary: "Run command",
description:
"Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
description: "Execute a slash command callback immediately.",
}),
),
)
@@ -563,7 +558,9 @@ 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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
-17
View File
@@ -3,7 +3,6 @@ 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: {} })
@@ -13,22 +12,6 @@ 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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+2 -3
View File
@@ -336,11 +336,10 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.command",
Effect.fn(function* (ctx) {
const outcome = yield* session
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,
@@ -366,7 +365,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
),
),
)
return { data: outcome }
return HttpApiSchema.NoContent.make()
}),
)
.handle(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"private": true,
"type": "module",
"license": "MIT",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel, statModel, statProvider } from "./model-normalization"
describe("inference stat normalization", () => {
@@ -38,6 +38,22 @@ 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")
@@ -94,6 +110,20 @@ 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,6 +7,7 @@ import {
MODEL_AUTHOR_RULES,
MODEL_NAME_ALIASES,
RETIRED_STAT_PROVIDERS,
STEALTH_MODELS,
statModel,
statProvider,
} from "./model-normalization"
@@ -263,6 +264,7 @@ ${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,8 +13,11 @@ 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",
}
@@ -45,7 +48,10 @@ export function statProvider(
providerModel: string | undefined,
provider: string | undefined,
) {
const modelAuthorValue = modelAuthor(statModel(model, providerModel))
const normalized = statModel(model, providerModel)
if (STEALTH_MODELS.has(normalized.toLowerCase())) return "unknown"
const modelAuthorValue = modelAuthor(normalized)
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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.enabled) return skipped("attention_disabled")
if (!input.config.attention.notifications && !input.config.attention.sound) return skipped("attention_disabled")
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
+3 -12
View File
@@ -253,20 +253,11 @@ 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: true,
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["system notifications", "desktop notifications", "alerts"],
@@ -275,10 +266,10 @@ export const settings: Setting[] = [
title: "Sounds",
category: "Alerts",
path: ["attention", "sound"],
default: true,
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["audio", "sound effects"],
keywords: ["audio", "sound effects", "alerts"],
},
{
title: "Volume",
+2 -5
View File
@@ -95,7 +95,6 @@ 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(
@@ -242,7 +241,6 @@ 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
@@ -285,9 +283,8 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
return {
...input,
attention: {
enabled: input.attention?.enabled ?? false,
notifications: input.attention?.notifications ?? true,
sound: input.attention?.sound ?? true,
notifications: input.attention?.notifications ?? false,
sound: input.attention?.sound ?? false,
volume: input.attention?.volume ?? 0.4,
sound_pack: input.attention?.sound_pack ?? "opencode.default",
sounds: input.attention?.sounds ?? {},
+1 -2
View File
@@ -1751,8 +1751,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
}
input.trace?.write("send.command", { sessionID: input.sessionID, messageID, command: command.name, delivery })
// Command outcomes are not inbox items; the event stream observes any admitted work.
await client.session.command(
return 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,11 +742,7 @@ 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 },
},
@@ -763,9 +759,7 @@ 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()
})
@@ -2935,7 +2929,10 @@ 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()
})
@@ -3409,7 +3406,7 @@ describe("V2 mini transport", () => {
data: { sessionID: "ses_1" },
})
})
return ok({ type: "prompt" as const, inboxID: "msg_cmd" })
return ok(undefined)
})
await transport.runPromptTurn({
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
"version": "2.0.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"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.4",
"version": "2.0.5",
"private": true,
"type": "module",
"scripts": {
+3 -60
View File
@@ -2066,22 +2066,8 @@
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
@@ -2134,7 +2120,7 @@
}
}
},
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"description": "Execute a slash command callback immediately.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2145,18 +2131,6 @@
"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"
},
@@ -12493,37 +12467,6 @@
"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": {
+3 -60
View File
@@ -2066,22 +2066,8 @@
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"data": {
"$ref": "#/components/schemas/Command.Outcome"
}
},
"required": ["data"],
"additionalProperties": false
}
}
}
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
@@ -2134,7 +2120,7 @@
}
}
},
"description": "Execute a slash command callback. Returns whether the command finished immediately or admitted session input whose execution continues after this request.",
"description": "Execute a slash command callback immediately.",
"summary": "Run command",
"requestBody": {
"content": {
@@ -2145,18 +2131,6 @@
"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"
},
@@ -12493,37 +12467,6 @@
"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, 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.
The current command transform is add-only. An executor receives the session, prompt attachments, and delivery mode.
```ts
effect: (ctx) =>
@@ -483,15 +483,12 @@ effect: (ctx) =>
name: "security-review",
description: "Review changes for security issues",
execute: (input) =>
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 }))),
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),
})
})
yield* command.reload()
@@ -419,48 +419,25 @@ Read the commands available at a location.
const commands = await ctx.command.list()
```
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.
Register commands with a transform. The executor receives the session, prompt attachments, and requested delivery mode.
```ts
await ctx.command.transform((editor) => {
editor.add({
name: "security-review",
description: "Review changes for security issues",
execute: async ({ sessionID, messageID, prompt, delivery }) => {
const admitted = await ctx.session.prompt({
execute: async ({ sessionID, prompt, delivery }) => {
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
@@ -470,7 +447,6 @@ 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
@@ -487,17 +463,14 @@ interface CommandEditor {
interface CommandDefinition {
name: string
description?: string
execute(input: CommandInvocation): Promise<CommandOutcome | void>
execute(input: CommandInvocation): Promise<void>
}
interface CommandInvocation {
sessionID: string
messageID: string
prompt: PromptInput
delivery: "steer" | "queue"
}
type CommandOutcome = { type: "immediate" } | { type: "prompt"; inboxID: string }
```
### Integrations
+2 -4
View File
@@ -108,12 +108,11 @@ Set scroll speed and acceleration:
## Attention
Configure notifications and sounds:
Configure notifications and sounds. Both are off by default and can be enabled independently:
```json title="cli.json"
{
"attention": {
"enabled": true,
"notifications": true,
"sound": true,
"volume": 0.4,
@@ -127,8 +126,7 @@ Configure notifications and sounds:
| Field | Values | Description |
| --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | Enables attention alerts. |
| `notifications` | boolean | Shows system notifications. |
| `notifications` | boolean | Shows system notifications when the terminal is not focused. |
| `sound` | boolean | Plays attention sounds. |
| `volume` | number from `0` to `1` | Sets sound volume. |
| `sound_pack` | string | Selects the active sound pack. |