Compare commits

..

1 Commits

Author SHA1 Message Date
opencode b084acc55e release: v2.0.6 2026-09-17 11:17:26 +00:00
128 changed files with 538 additions and 1736 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.7",
"version": "2.0.6",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.7",
"version": "2.0.6",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -1,18 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("home connection progress stays in the server row", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--home-connecting")
await component.getByRole("button", { name: "Projects" }).click()
await expect(page.getByRole("status", { name: "Connecting over SSH…" })).toBeVisible()
await expect(page.locator('[data-action="home-server-authenticate"]')).toHaveCount(0)
})
story("home authentication action appears when required", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--authentication-required")
await component.getByRole("button", { name: "Projects" }).click()
await expect(page.getByRole("button", { name: "Authenticate", exact: true })).toBeVisible()
})
story("settings menu reconnect retains its prompt handler across server updates", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--settings-reconnect")
await component.getByRole("button", { name: "More options" }).click()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.7",
"version": "2.0.6",
"description": "",
"type": "module",
"exports": {
+8 -2
View File
@@ -11,6 +11,7 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Button } from "@opencode/ui/button"
import { Spinner } from "@opencode/ui/spinner"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/shell/state/layout"
@@ -255,7 +256,7 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
collapsed={collapsed()}
health={props.serverHealth(item)}
/>
<Show when={authentication()}>
<Show when={authentication() || connecting()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<div class="px-1.5 py-1">
<Button
@@ -263,9 +264,14 @@ function HomeProjectsPanel(props: HomeProjectsViewProps) {
class="w-full"
size="small"
variant="neutral"
disabled={connecting()}
aria-busy={!!connecting()}
onClick={() => props.onAuthenticateServer?.(item)}
>
{props.language.t("ssh.action.authenticate")}
<Show when={connecting()}>
<Spinner class="size-3.5" />
</Show>
{props.language.t(connecting() ? "ssh.stage.connecting" : "ssh.action.authenticate")}
</Button>
</div>
</Show>
@@ -14,7 +14,6 @@ import { SshConnectionPanel } from "./connection-panel"
import { SshServerSettings } from "./settings"
function Fixture(props: {
home?: boolean
session?: boolean
settings?: boolean
incompatible?: boolean
@@ -26,15 +25,10 @@ function Fixture(props: {
const state: { item?: SshItem; before?: SshItem; step: number; timer?: ReturnType<typeof setTimeout> } = {
step: 0,
item:
props.initial === "required" || props.home
props.initial === "required"
? {
config: { id: "story", target: "ssh linuxbook", name: "" },
stage:
props.session || props.settings
? "disconnected"
: props.initial === "connecting"
? "connecting"
: "authentication",
stage: props.session || props.settings ? "disconnected" : "authentication",
saved: true,
detail: "",
}
@@ -147,7 +141,7 @@ function Fixture(props: {
<AuthenticationSettings />
) : props.session ? (
<AuthenticationSession />
) : props.initial === "required" || props.home ? (
) : props.initial === "required" ? (
<AuthenticationHome />
) : (
<Open initial={props.initial} />
@@ -283,7 +277,6 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const HomeConnecting = { render: () => <Fixture home initial="connecting" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import type { SshItem } from "@/servers/ssh/types"
import { settingsProjects, settingsServers } from "./inventory"
import { settingsServers } from "./inventory"
const ssh: SshItem = {
config: { id: "build", target: "dev@example.com", name: "Build server" },
@@ -17,30 +17,6 @@ const connection: ServerConnection.Ssh = {
http: { url: "http://127.0.0.1:4000", password: "secret" },
}
test("settings project inventory reads metadata without acquiring directory stores", () => {
const projects = Array.from({ length: 40 }, (_, index) => ({
id: `project-${index}`,
worktree: `/projects/${index}`,
name: `Project ${index}`,
icon: { color: "orange" },
commands: { start: "bun install" },
time: { created: 1, updated: 1 },
sandboxes: [],
worktrees: [],
}))
const tracked = { ...projects[0], expanded: true, icon: { override: "local-icon" } }
const inventory = settingsProjects({
projects: { list: () => [tracked], closed: () => [projects[1].worktree] },
sync: { data: { project: projects } },
})
expect(inventory).toHaveLength(39)
expect(inventory[0]).toBe(tracked)
expect(inventory.some((project) => project.id === projects[1].id)).toBe(false)
expect(inventory[1]).toEqual({ ...projects[2], expanded: false })
expect(inventory[38]).toEqual({ ...projects[39], expanded: false })
})
describe("settings server inventory", () => {
test("includes saved SSH servers before they connect", () => {
expect(settingsServers([], [], [ssh])).toEqual([
@@ -8,7 +8,7 @@ import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: {
projects: Pick<ServerCtx["projects"], "list" | "closed">
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
}) {
const tracked = context.projects.list()
@@ -16,10 +16,9 @@ export function settingsProjects(context: {
const closed = new Set(context.projects.closed().map(pathKey))
return [
...tracked,
// Inventory reads must not allocate directory stores: async cache hydration can trigger an eviction/reload loop.
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
.map((project) => ({ ...project, expanded: false })),
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"bin": {
+2 -8
View File
@@ -6,16 +6,10 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { UpdateArtifact } from "../../../script/update-artifact"
if (Script.channel !== "beta" && Script.channel !== "latest") {
throw new Error("AUR publishing requires the beta or latest channel")
}
if (Script.channel !== "beta") throw new Error("AUR publishing requires the beta channel")
const name = "opencode-beta"
const command = "opencode"
const valid =
Script.channel === "beta"
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
: /^\d+\.\d+\.\d+$/.test(Script.version)
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
if (!/^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)) throw new Error("Expected a beta release version")
const dir = fileURLToPath(new URL("..", import.meta.url))
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
+1 -1
View File
@@ -147,7 +147,7 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
await $`docker buildx build --platform linux/amd64,linux/arm64 --tag ghcr.io/anomalyco/opencode:${Script.version} --push .`
}
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
if (Script.channel === "beta" && Script.release) {
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
}
+35 -22
View File
@@ -1,28 +1,42 @@
import { agent, RequestError, type Stream } from "@agentclientprotocol/sdk"
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type DeleteSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/promise"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function connect(client: OpenCodeClient, stream: Stream) {
const connection = agent({ name: "opencode" })
.onRequest("initialize", (ctx) => run(service.initialize(ctx.params)))
.onRequest("authenticate", (ctx) => run(service.authenticate(ctx.params)))
.onRequest("session/new", (ctx) => run(service.newSession(ctx.params)))
.onRequest("session/load", (ctx) => run(service.loadSession(ctx.params)))
.onRequest("session/list", (ctx) => run(service.listSessions(ctx.params)))
.onRequest("session/delete", (ctx) => run(service.deleteSession(ctx.params)))
.onRequest("session/resume", (ctx) => run(service.resumeSession(ctx.params)))
.onRequest("session/close", (ctx) => run(service.closeSession(ctx.params)))
.onRequest("session/fork", (ctx) => run(service.forkSession(ctx.params)))
.onRequest("session/set_config_option", (ctx) => run(service.setSessionConfigOption(ctx.params)))
.onRequest("session/set_mode", (ctx) => run(service.setSessionMode(ctx.params)))
.onRequest("session/prompt", (ctx) => run(service.prompt(ctx.params, ctx.signal)))
.onNotification("session/cancel", (ctx) => run(service.cancel(ctx.params)))
.connect(stream)
// Inbound dispatch starts after the stream's async read loop yields, so handlers never observe this before assignment.
const service = ACPService.make({ client, connection: ACPConnection.make(connection) })
return connection
export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
}
async function run<A>(promise: Promise<A>) {
@@ -38,7 +52,6 @@ async function run<A>(promise: Promise<A>) {
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.SessionDirectoryMismatchError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
-31
View File
@@ -1,31 +0,0 @@
import {
methods,
type AgentConnection,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SendRequestOptions,
type SessionNotification,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
export type Connection = {
readonly signal?: AbortSignal
sessionUpdate(params: SessionNotification): Promise<void>
requestPermission(params: RequestPermissionRequest, options?: SendRequestOptions): Promise<RequestPermissionResponse>
writeTextFile?(params: WriteTextFileRequest, options?: SendRequestOptions): Promise<WriteTextFileResponse>
extNotification?(method: string, params: Record<string, unknown>): Promise<void>
}
export function make(connection: AgentConnection): Connection {
return {
signal: connection.signal,
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
requestPermission: (params, options) =>
connection.client.request(methods.client.session.requestPermission, params, options),
writeTextFile: (params, options) => connection.client.request(methods.client.fs.writeTextFile, params, options),
extNotification: (method, params) => connection.client.notify(method, params),
}
}
export * as ACPConnection from "./connection"
-11
View File
@@ -5,11 +5,6 @@ export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundErro
sessionId: Schema.String,
}) {}
export class SessionDirectoryMismatchError extends Schema.TaggedError<SessionDirectoryMismatchError>()(
"ACPSessionDirectoryMismatchError",
{ sessionId: Schema.String, cwd: Schema.String },
) {}
export class InvalidConfigOptionError extends Schema.TaggedError<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
@@ -42,7 +37,6 @@ export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>
export type Error =
| SessionNotFoundError
| SessionDirectoryMismatchError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
@@ -55,11 +49,6 @@ export function toRequestError(error: Error): RequestError {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPSessionDirectoryMismatchError":
return RequestError.invalidParams(
{ sessionId: error.sessionId, cwd: error.cwd },
`session ${error.sessionId} does not belong to cwd: ${error.cwd}`,
)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
+5 -8
View File
@@ -1,11 +1,10 @@
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistant,
SessionMessageInfo,
} from "@opencode/client/promise"
import type { ACPConnection } from "./connection"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { replyPermission, syncEditedFiles } from "./permission"
@@ -18,11 +17,11 @@ import {
type ToolInput,
} from "./tool"
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
export type TurnControl = {
cancelled: boolean
// Aborted whenever the turn is cancelled or closed, so it also cancels the turn's outbound client requests.
readonly admission: AbortController
}
@@ -161,7 +160,6 @@ export async function streamTurn(input: {
clientSessionID: input.sessionID,
cwd: input.cwd,
tool,
signal: control.admission.signal,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
})
continue
@@ -271,7 +269,6 @@ export async function streamTurn(input: {
toolName: current.name,
toolInput: current.input,
metadata: event.data.metadata ?? {},
signal: control.admission.signal,
}).catch(() => {})
await send({
sessionUpdate: "tool_call_update",
@@ -420,7 +417,7 @@ function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
}
export async function replayMessages(
connection: Pick<Connection, "sessionUpdate">,
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
@@ -429,7 +426,7 @@ export async function replayMessages(
}
async function replayMessage(
connection: Pick<Connection, "sessionUpdate">,
connection: Pick<AgentSideConnection, "sessionUpdate">,
sessionID: string,
cwd: string,
message: SessionMessageInfo,
+23 -31
View File
@@ -1,13 +1,12 @@
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
import { Patch } from "@opencode/util/patch"
import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import type { ACPConnection } from "./connection"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Connection = Pick<ACPConnection.Connection, "requestPermission" | "writeTextFile">
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Tool = { readonly name: string; readonly input: ToolInput }
const options: PermissionOption[] = [
@@ -26,34 +25,31 @@ export async function replyPermission(input: {
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
readonly signal?: AbortSignal
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const request = {
sessionId: input.clientSessionID ?? input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
}
// An already-cancelled turn skips the round-trip; the SDK would still send the request and then cancel it.
const result = input.signal?.aborted
? undefined
: await input.connection.requestPermission(request, { cancellationSignal: input.signal }).catch(() => undefined)
const result = await input.connection
.requestPermission({
sessionId: input.clientSessionID ?? input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
})
.catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
const reply = selected === "once" || selected === "always" ? selected : "reject"
await input.client.permission.reply({
@@ -70,14 +66,13 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
}
export async function syncEditedFiles(input: {
readonly connection: Pick<ACPConnection.Connection, "writeTextFile">
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly writeTextFile: boolean
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly metadata: Readonly<Record<string, unknown>>
readonly signal?: AbortSignal
}) {
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.metadata.files)
@@ -94,10 +89,7 @@ export async function syncEditedFiles(input: {
const target = resolvePath(path, input.cwd)
const file = Bun.file(target)
if (!(await file.exists())) return
await input.connection.writeTextFile?.(
{ sessionId: input.sessionID, path: target, content: await file.text() },
{ cancellationSignal: input.signal },
)
await input.connection.writeTextFile?.({ sessionId: input.sessionID, path: target, content: await file.text() })
}),
)
}
+31 -54
View File
@@ -7,9 +7,9 @@ import {
type SessionInfo,
type SessionMessageInfo,
} from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
AuthMethod,
@@ -46,7 +46,6 @@ import {
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import type { ACPConnection } from "./connection"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -61,6 +60,9 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
@@ -100,18 +102,15 @@ export interface Interface {
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
prompt(input: PromptRequest, signal?: AbortSignal): Promise<PromptResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
export function make(input: {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Connection
}): Interface {
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
const active = new Map<string, TurnControl>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => {
@@ -137,15 +136,6 @@ export function make(input: {
registeredMcp.delete(sessionID)
}
const cancelTurn = (sessionID: string) => {
const turn = active.get(sessionID)
if (turn) {
turn.control.cancelled = true
turn.control.admission.abort()
}
return input.client.session.interrupt({ sessionID })
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
sessions.get(session.id)?.abort.abort()
@@ -226,7 +216,7 @@ export function make(input: {
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId, params.cwd)
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
@@ -256,17 +246,18 @@ export function make(input: {
return {}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId, params.cwd)
const session = await getSession(input.client, params.sessionId)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
const turn = active.get(params.sessionId)
await cancelTurn(params.sessionId).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
await turn?.turn.catch(() => {})
detach(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
turn.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
return {}
},
forkSession: async (params) => {
@@ -312,7 +303,7 @@ export function make(input: {
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
return {}
},
prompt: async (params, signal) => {
prompt: async (params) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
@@ -328,9 +319,8 @@ export function make(input: {
capabilities.childSessionUpdates && extNotification
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
: undefined
// A `$/cancel_request` for this prompt behaves like `session/cancel` for its turn.
const cancel = () => void cancelTurn(state.id).catch(() => {})
const turn = streamTurn({
active.set(state.id, control)
const response = await streamTurn({
client: input.client,
connection: input.connection,
sessionID: state.id,
@@ -343,23 +333,19 @@ export function make(input: {
sessionSignal: state.abort.signal,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
.then(async (response) => {
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
})
.finally(() => {
signal?.removeEventListener("abort", cancel)
if (active.get(state.id)?.control === control) active.delete(state.id)
})
active.set(state.id, { control, turn })
signal?.addEventListener("abort", cancel, { once: true })
// The cancel may already be buffered behind the awaits above.
if (signal?.aborted) cancel()
return turn
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
},
cancel: async (params) => {
await cancelTurn(params.sessionId).catch(() => {})
const current = active.get(params.sessionId)
if (current) {
current.cancelled = true
current.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
},
}
}
@@ -486,15 +472,11 @@ async function selectMode(client: OpenCodeClient, state: Attached, modeID: strin
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
async function getSession(client: OpenCodeClient, sessionID: string, cwd: string) {
const session = await client.session.get({ sessionID }).catch((error) => {
async function getSession(client: OpenCodeClient, sessionID: string) {
return client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory)) {
throw new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
}
return session
}
async function messages(client: OpenCodeClient, sessionID: string) {
@@ -560,12 +542,7 @@ function stableStringify(value: unknown): string {
.join(",")}}`
}
async function sendUsageUpdate(
client: OpenCodeClient,
connection: ACPConnection.Connection,
session: Attached,
used?: number,
) {
async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
-10
View File
@@ -163,10 +163,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Argument.optional,
),
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
answer: Flag.string("answer").pipe(
Flag.withDescription("Provider form answer (key=value; repeat for multiple fields)"),
Flag.atMost(100),
),
},
}),
Spec.make("logout", {
@@ -488,12 +484,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
],
}),
Spec.make("reload", {
description: "Reload configuration",
params: {
...ServerParams,
},
}),
Spec.make("pair", {
description: "Show server pairing information",
params: {
+3 -2
View File
@@ -1,4 +1,4 @@
import { ndJsonStream } from "@agentclientprotocol/sdk"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/promise"
import { Service } from "@opencode/client/effect/service"
import { Effect } from "effect"
@@ -26,7 +26,8 @@ export default Runtime.handler(
process.stdin.on("error", (error) => controller.error(error))
},
})
const connection = ACP.connect(client, ndJsonStream(input, output))
const stream = ndJsonStream(input, output)
const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
process.stdin.resume()
yield* Effect.promise(() => connection.closed)
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
@@ -1,72 +1,29 @@
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
import { Effect, Schema } from "effect"
import { Form } from "@opencode/schema/form"
import { formValidateValue } from "@opencode/tui/util/form"
import { Effect } from "effect"
import type { FormAnswer, FormField, FormFields } from "@opencode/client"
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
const skip = Symbol("skip")
const custom = Symbol("custom")
const decodeAnswer = Schema.decodeUnknownOption(Schema.fromJsonString(Form.Value))
export const answerForm = Effect.fn("cli.auth.form")(function* (
fields: FormFields | undefined,
input: ReadonlyArray<string> = [],
) {
const supplied = new Map<string, string>()
for (const item of input) {
const separator = item.indexOf("=")
if (separator < 1) return yield* Effect.fail(new Error("Expected --answer key=value"))
const key = item.slice(0, separator)
if (!fields?.some((field) => field.key === key)) {
return yield* Effect.fail(new Error(`Unknown form field: ${key}`))
}
supplied.set(key, item.slice(separator + 1))
}
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
if (!fields) return undefined
yield* requireInteractive("Authentication form input requires an interactive terminal")
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) {
if (supplied.has(field.key)) {
return yield* Effect.fail(new Error(`Form field is not active: ${field.key}`))
}
continue
}
const provided = supplied.get(field.key)
const value =
provided !== undefined
? yield* suppliedAnswer(field, provided)
: field.type !== "external" && field.hidden
? field.default
: yield* answerField(field)
if (field.type !== "external") {
const invalid = formValidateValue(field, value)
if (invalid) return yield* Effect.fail(new Error(`${field.key}: ${invalid}`))
}
if (!active(field, answer)) continue
const value = yield* answerField(field)
if (value !== undefined) answer[field.key] = value
}
return answer
})
const suppliedAnswer = Effect.fn("cli.auth.form.answer")(function* (field: FormField, input: string) {
if (field.type === "external") {
return yield* Effect.fail(new Error(`External form field requires interactive confirmation: ${field.key}`))
}
if (field.type === "string") return input
const value = decodeAnswer(input)
if (value._tag === "None") {
return yield* Effect.fail(new Error(`Expected a JSON value for form field: ${field.key}`))
}
return typeof value.value === "object" ? [...value.value] : value.value
})
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
yield* requireInteractive("API key input requires an interactive terminal")
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
})
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
yield* requireInteractive("Authentication form input requires an interactive terminal")
const message = field.title ?? field.key
if (field.description) log.info(field.description)
if (field.type === "external") {
@@ -33,7 +33,6 @@ export default Runtime.handler(
login({
target: Option.getOrUndefined(input.target),
method: Option.getOrUndefined(input.method),
answer: input.answer,
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
@@ -43,7 +42,6 @@ export default Runtime.handler(
const login = Effect.fn("cli.auth.login.run")(function* (input: {
target?: string
method?: string
answer?: ReadonlyArray<string>
server?: string
standalone: boolean
}) {
@@ -55,7 +53,7 @@ const login = Effect.fn("cli.auth.login.run")(function* (input: {
const methods = connectMethods(integration)
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
const method = yield* chooseMethod(methods, input.method)
const answer = yield* answerForm(method.type === "command" ? undefined : method.form, input.answer)
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
yield* authenticate(client, integration, method, answer)
outro("Done")
})
@@ -1,23 +0,0 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.reload,
Effect.fn("cli.reload")(function* (input: Runtime.Input<typeof Commands.commands.reload>) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
yield* Effect.tryPromise({
try: (signal) => client.location.reload({ signal }),
catch: (cause) => cause,
})
process.stdout.write("Configuration reloaded" + EOL)
}),
)
-1
View File
@@ -55,7 +55,6 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
reload: () => import("./commands/handlers/reload"),
session: {
list: () => import("./commands/handlers/session/list"),
delete: () => import("./commands/handlers/session/delete"),
+16
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 { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -158,6 +159,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
yield* Updater.Service.pipe(
Effect.flatMap((updater) =>
Updater.pollUpdates({
check: updater.run().pipe(
Effect.flatMap((result) => {
if (!result) return Effect.void
if (result.type === "available") return server.updateAvailable(result.version)
return server.updated(result.version)
}),
),
}),
),
Effect.provide(Updater.layer),
Effect.forkScoped,
)
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"
+13 -1
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -24,6 +24,18 @@ export interface Interface {
| undefined
}
export const pollUpdates = Effect.fnUntraced(function* (input: {
readonly check: Effect.Effect<unknown>
readonly initialDelay?: Duration.Input
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
return yield* input.check.pipe(
Effect.repeat(Schedule.spaced(interval)),
Effect.delay(input.initialDelay ?? "1 minute"),
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
export function decodePolicy(text: string): Policy | undefined {
@@ -1,84 +0,0 @@
import type { CloseSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
// The mock model holds any completion whose request mentions "hold" until the test releases it,
// so cancellation can be exercised while the model turn is genuinely in flight.
function heldModel() {
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<string>()
return {
started: started.promise,
release: () => release.resolve("released"),
respond(request: unknown) {
if (!JSON.stringify(request).includes("hold")) return "accepted"
started.resolve()
return release.promise
},
}
}
describe("acp cancel subprocess", () => {
test("$/cancel_request cancels the in-flight prompt and the session stays usable", async () => {
const model = heldModel()
await using fixture = await createAcpFixture({ respond: model.respond })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hold" }],
})
await model.started
await acp.notify("$/cancel_request", { requestId: prompt.id })
expect(expectOk(await prompt.response).stopReason).toBe("cancelled")
model.release()
const next = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "again" }],
}),
)
expect(next.stopReason).toBe("end_turn")
}, 60_000)
test("session/close settles the active prompt before responding and leaves other sessions alone", async () => {
const model = heldModel()
await using fixture = await createAcpFixture({ respond: model.respond })
const acp = fixture.spawn()
await initialize(acp)
const closing = await newSession(acp, fixture.home)
const other = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: closing.sessionId,
prompt: [{ type: "text", text: "hold" }],
})
await model.started
const order: string[] = []
const close = acp
.request<CloseSessionResponse>("session/close", { sessionId: closing.sessionId })
.then((response) => {
order.push("close")
return response
})
const cancelled = await prompt.response.then((response) => {
order.push("prompt")
return response
})
expect(expectOk(cancelled).stopReason).toBe("cancelled")
expect(expectOk(await close)).toEqual({})
expect(order).toEqual(["prompt", "close"])
model.release()
const next = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: other.sessionId,
prompt: [{ type: "text", text: "still here" }],
}),
)
expect(next.stopReason).toBe("end_turn")
}, 60_000)
})
@@ -51,12 +51,6 @@ describe("acp lifecycle subprocess", () => {
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const mismatched = await acp.request<LoadSessionResponse>("session/load", {
cwd: fixture.root,
sessionId: session.sessionId,
mcpServers: [],
})
expect(mismatched.error?.code).toBe(-32602)
}, 60_000)
test("list request includes a live ACP-created session", async () => {
@@ -1,25 +1,20 @@
import { describe, expect, test } from "bun:test"
import type {
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
} from "@agentclientprotocol/sdk"
import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { ACPConnection } from "../../src/acp/connection"
import { streamTurn } from "../../src/acp/event"
import { syncEditedFiles } from "../../src/acp/permission"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = SessionNotification
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("does not sync edits when writeTextFile was not advertised", async () => {
const writes: WriteTextFileRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
await syncEditedFiles({
connection: {
@@ -221,7 +216,7 @@ describe("acp permission behavior", () => {
const file = path.join(cwd, "file.ts")
await fs.writeFile(file, "before")
const permissionRequests: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
@@ -311,7 +306,7 @@ describe("acp permission behavior", () => {
"*** End Patch",
].join("\n")
const permissionRequests: RequestPermissionRequest[] = []
const writes: WriteTextFileRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
@@ -541,55 +536,6 @@ describe("acp permission behavior", () => {
await fixture.stop()
}
})
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
const requested = Promise.withResolvers<AbortSignal | undefined>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
send(permissionAsked("ses_cancel", "perm_cancel"))
},
onPermissionReply({ send }) {
send(durableEvent("session.execution.interrupted", { sessionID: "ses_cancel", reason: "user" }))
},
})
const connection = {
sessionUpdate: async () => {},
// Behaves like a client answering the agent's `$/cancel_request` with a cancelled outcome.
requestPermission: (_request, options) =>
new Promise<RequestPermissionResponse>((resolve) => {
options?.cancellationSignal?.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), {
once: true,
})
requested.resolve(options?.cancellationSignal)
}),
} satisfies Connection
const control = { cancelled: false, admission: new AbortController() }
const result = streamTurn({
client: fixture.client,
connection,
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt({ sessionID: "ses_cancel", id: "input_cancel", text: "hello" }, { signal }),
})
try {
const signal = await withTimeout(requested.promise, "permission was not requested")
expect(signal?.aborted).toBe(false)
control.cancelled = true
control.admission.abort()
expect(await withTimeout(result, "cancelled turn did not finish")).toMatchObject({ stopReason: "cancelled" })
expect(permissionReplies(fixture)).toEqual([["perm_cancel", "reject"]])
} finally {
await result.catch(() => undefined)
await fixture.stop()
}
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
+2 -2
View File
@@ -1,4 +1,4 @@
import type { SessionNotification } from "@agentclientprotocol/sdk"
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
@@ -114,7 +114,7 @@ export function makeSession(
export function makeACPFixture(options: FixtureOptions = {}) {
const requests: FixtureRequest[] = []
const updates: SessionNotification[] = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
const models = options.models ?? [testModel, secondModel]
@@ -94,24 +94,20 @@ describe("acp service lifecycle", () => {
})
const loaded = await fixture.service.loadSession({
cwd: "/workspace",
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
const resumed = await fixture.service.resumeSession({
cwd: "/workspace",
cwd: "/ignored",
sessionId: "ses_resume",
mcpServers: [],
})
const forked = await fixture.service.forkSession({
cwd: "/workspace",
cwd: "/ignored",
sessionId: "ses_loaded",
mcpServers: [],
})
const mismatched = await fixture.service
.loadSession({ cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })
.catch((error: unknown) => error)
expect(mismatched).toMatchObject({ _tag: "ACPSessionDirectoryMismatchError", cwd: "/elsewhere" })
expect(currentValue(loaded, "model")).toBe("test/second-model")
expect(currentValue(loaded, "effort")).toBe("medium")
@@ -218,8 +214,7 @@ describe("acp service lifecycle", () => {
])
})
test("cancel preserves the attachment while close removes it and surfaces interrupt failures", async () => {
const interrupt = { fail: true }
test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -228,14 +223,8 @@ describe("acp service lifecycle", () => {
if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/missing/interrupt") {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID: "missing", message: "session not found" },
{ status: 404 },
)
}
if (request.method === "POST" && request.path.endsWith("/interrupt")) {
return interrupt.fail ? new Response(null, { status: 500 }) : Response.json({ interrupted: false })
return new Response(null, { status: 500 })
}
return undefined
},
@@ -250,9 +239,6 @@ describe("acp service lifecycle", () => {
})
expect(currentValue(updated, "effort")).toBe("high")
const failed = await fixture.service.closeSession({ sessionId: created.sessionId }).catch((error: unknown) => error)
expect(failed).toMatchObject({ name: "ClientError", reason: "UnexpectedStatus" })
interrupt.fail = false
expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
const missing = await fixture.service
.setSessionConfigOption({
@@ -266,7 +252,6 @@ describe("acp service lifecycle", () => {
expect(
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
).toEqual([
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/missing/interrupt",
+12 -30
View File
@@ -46,11 +46,6 @@ type Waiter = {
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly send: <T>(
method: string,
params?: unknown,
) => { readonly id: number; readonly response: Promise<JsonRpcResponse<T>> }
readonly notify: (method: string, params: unknown) => Promise<void>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
@@ -69,9 +64,7 @@ description: Verifier compatibility skill.
# Verifier Skill
`
export async function createAcpFixture(
options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise<string> } = {},
) {
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")
@@ -91,9 +84,8 @@ export async function createAcpFixture(
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
const body: unknown = await request.json().catch(() => undefined)
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" },
})
},
@@ -302,28 +294,18 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
})
}
const write = async (message: JsonRpcRequest | JsonRpcNotification<unknown>) => {
if (inputClosed) throw new Error("ACP stdin is closed")
await child.stdin.write(encoder.encode(`${JSON.stringify(message)}\n`))
await child.stdin.flush()
}
const send = <T>(method: string, params?: unknown) => {
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
const response = write(request).then(async () => {
return {
async request<T>(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
await child.stdin.flush()
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
})
return { id, response }
}
return {
request: <T>(method: string, params?: unknown) => send<T>(method, params).response,
send,
notify: (method: string, params: unknown) => write({ jsonrpc: "2.0", method, params }),
},
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),
-105
View File
@@ -1,105 +0,0 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import type { FormFields } from "@opencode/client"
import { answerForm } from "../src/commands/handlers/auth/form"
test("resolves hidden defaults without an interactive terminal", async () => {
const fields = [
{ type: "string", key: "server", hidden: true, default: "https://example.com/console" },
{ type: "boolean", key: "enabled", hidden: true, default: false },
{ type: "integer", key: "count", hidden: true, default: 0 },
{ type: "string", key: "optional", hidden: true },
] satisfies FormFields
expect(await Effect.runPromise(answerForm(fields))).toEqual({
server: "https://example.com/console",
enabled: false,
count: 0,
})
expect(await Effect.runPromise(answerForm(fields, ["server=https://staging.example.com/console"]))).toEqual({
server: "https://staging.example.com/console",
enabled: false,
count: 0,
})
})
test("parses typed answers and evaluates conditions in form order", async () => {
const fields = [
{ type: "string", key: "deployment", options: [{ value: "enterprise", label: "Enterprise" }] },
{ type: "string", key: "host", when: [{ key: "deployment", op: "eq", value: "enterprise" }] },
{ type: "boolean", key: "enabled" },
{ type: "integer", key: "count", minimum: 0 },
{ type: "number", key: "rate" },
{ type: "multiselect", key: "scopes", options: [{ value: "read", label: "Read" }] },
] satisfies FormFields
expect(
await Effect.runPromise(
answerForm(fields, [
"host=example.com",
"deployment=enterprise",
"enabled=false",
"count=0",
"rate=1.5",
'scopes=["read"]',
]),
),
).toEqual({ deployment: "enterprise", host: "example.com", enabled: false, count: 0, rate: 1.5, scopes: ["read"] })
})
test("preserves empty strings and equals signs in supplied text", async () => {
expect(
await Effect.runPromise(
answerForm(
[
{ type: "string", key: "server" },
{ type: "string", key: "optional" },
],
["server=https://example.com/console?tenant=a=b", "optional="],
),
),
).toEqual({ server: "https://example.com/console?tenant=a=b", optional: "" })
})
const invalidAnswers: { fields: FormFields | undefined; supplied: string[]; error: string }[] = [
{ fields: undefined, supplied: ["missing-separator"], error: "Expected --answer key=value" },
{ fields: undefined, supplied: ["=missing-key"], error: "Expected --answer key=value" },
{ fields: undefined, supplied: ["unknown=value"], error: "Unknown form field: unknown" },
{
fields: [{ type: "string", key: "known" }],
supplied: ["unknown=value"],
error: "Unknown form field: unknown",
},
{
fields: [
{ type: "boolean", key: "enabled", hidden: true, default: false },
{ type: "string", key: "host", when: [{ key: "enabled", op: "eq", value: true }] },
],
supplied: ["host=example.com"],
error: "Form field is not active: host",
},
{ fields: [{ type: "integer", key: "count" }], supplied: ["count=1.5"], error: "Expected an integer" },
{ fields: [{ type: "boolean", key: "enabled" }], supplied: ["enabled=yes"], error: "Expected a JSON value" },
{
fields: [{ type: "string", key: "server", format: "uri", hidden: true }],
supplied: ["server=not a URL"],
error: "Expected a URL",
},
{
fields: [{ type: "string", key: "required", hidden: true, required: true }],
supplied: [],
error: "Answer required",
},
{
fields: [{ type: "multiselect", key: "scopes", options: [{ value: "read", label: "Read" }] }],
supplied: ['scopes=["write"]'],
error: "Select only available options",
},
{
fields: [{ type: "external", key: "approve", url: "https://example.com" }],
supplied: ["approve=true"],
error: "requires interactive confirmation",
},
]
test.each(invalidAnswers)("rejects invalid form answers: $error", async (input) => {
await expect(Effect.runPromise(answerForm(input.fields, input.supplied))).rejects.toThrow(input.error)
})
+13 -49
View File
@@ -1,11 +1,6 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import type { FormFields } from "@opencode/client"
const consoleForm = [
{ type: "string", key: "server", hidden: true, format: "uri", default: "https://opencode.ai/console" },
] satisfies FormFields
describe("auth command", () => {
test("registers authentication commands", async () => {
@@ -33,7 +28,6 @@ describe("auth command", () => {
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
expect(login.stdout).toContain("--method")
expect(login.stdout).toContain("--answer")
expect(logout.exitCode).toBe(0)
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>] [<credential>]")
})
@@ -141,52 +135,23 @@ describe("auth command", () => {
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
})
test.each([
{ id: "openai", method: "browser", form: undefined, args: [], answer: undefined },
{
id: "opencode",
method: "device",
form: consoleForm,
args: [],
answer: { server: "https://opencode.ai/console" },
},
{
id: "opencode",
method: "device",
form: consoleForm,
args: ["--answer", "server=https://staging.example.com/console"],
answer: { server: "https://staging.example.com/console" },
},
{
id: "company",
method: "browser",
form: [
{ type: "string", key: "tenant", required: true },
{ type: "boolean", key: "enabled", required: true },
] satisfies FormFields,
args: ["--answer", "tenant=team=one", "--answer", "enabled=false"],
answer: { tenant: "team=one", enabled: false },
},
])("completes $id/$method OAuth with supplied answers $args", async (input) => {
test("completes automatic OAuth authentication", async () => {
const requests: Array<{ method: string; path: string }> = []
const bodies: unknown[] = []
const endpoint = `/api/integration/${input.id}/connect/oauth`
using server = authServer(async (request, url) => {
using server = authServer((request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: input.id,
name: input.id,
methods: [{ id: input.method, type: "oauth", label: "Browser", form: input.form }],
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
connections: [],
},
]),
)
}
if (url.pathname === endpoint && request.method === "POST") {
bodies.push(await request.json())
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
return Response.json(
located({
attemptID: "con_oauth",
@@ -197,23 +162,22 @@ describe("auth command", () => {
}),
)
}
if (url.pathname === `${endpoint}/con_oauth` && request.method === "GET") {
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === `${endpoint}/con_oauth` && request.method === "DELETE") {
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", input.id, "--server", server.url.toString(), ...input.args])
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("https://example.com/authorize")
expect(result.stdout).toContain(`Connected to ${input.id}`)
expect(bodies).toEqual([{ methodID: input.method, ...(input.answer ? { answer: input.answer } : {}) }])
expect(requests).toContainEqual({ method: "POST", path: endpoint })
expect(requests).toContainEqual({ method: "GET", path: `${endpoint}/con_oauth` })
expect(requests).toContainEqual({ method: "DELETE", path: `${endpoint}/con_oauth` })
expect(result.stdout).toContain("Connected to OpenAI")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
})
test("settles the OAuth spinner when status polling fails", async () => {
@@ -63,7 +63,6 @@ function stepStarted(): V2Event {
type: "session.step.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
started: 1,
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
+24
View File
@@ -0,0 +1,24 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("polls after 1 minute and every 10 minutes after that", () =>
Effect.gen(function* () {
const checks = yield* Queue.unbounded<void>()
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("59 seconds")
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("1 second")
yield* Queue.take(checks)
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
yield* Queue.take(checks)
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
-1
View File
@@ -817,7 +817,6 @@ export type SessionLogOutput =
readonly agent: Agent.ID
readonly model: Model.Ref
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly started: number
}
}
| {
+2 -59
View File
@@ -712,14 +712,7 @@ export type SessionStepStarted = {
type: "session.step.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
assistantMessageID: string
agent: string
model: ModelRef
snapshot?: string
started: number
}
data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string }
}
export type SessionStepStreamed = {
@@ -1367,7 +1360,6 @@ export type FormNumberField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "number"
minimum?: number | "Infinity" | "-Infinity" | "NaN"
@@ -1380,7 +1372,6 @@ export type FormIntegerField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "integer"
minimum?: number | "Infinity" | "-Infinity" | "NaN"
@@ -1393,7 +1384,6 @@ export type FormBooleanField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "boolean"
default?: boolean
@@ -1404,7 +1394,6 @@ export type FormStringField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
@@ -1422,7 +1411,6 @@ export type FormMultiselectField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "multiselect"
options: Array<FormOption>
@@ -1580,7 +1568,6 @@ export type FormStringField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
@@ -1598,7 +1585,6 @@ export type FormNumberField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "number"
minimum?: number
@@ -1611,7 +1597,6 @@ export type FormIntegerField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "integer"
minimum?: number
@@ -1624,7 +1609,6 @@ export type FormBooleanField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "boolean"
default?: boolean
@@ -1635,7 +1619,6 @@ export type FormMultiselectField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "multiselect"
options: Array<FormOption>
@@ -2135,7 +2118,7 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }>
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
}
@@ -4468,7 +4451,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4493,7 +4475,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4509,7 +4490,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4525,7 +4505,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4539,7 +4518,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4570,7 +4548,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4595,7 +4572,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4611,7 +4587,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4627,7 +4602,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4641,7 +4615,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4679,7 +4652,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4704,7 +4676,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4720,7 +4691,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4736,7 +4706,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4750,7 +4719,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4781,7 +4749,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4806,7 +4773,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4822,7 +4788,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4838,7 +4803,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4852,7 +4816,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4890,7 +4853,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4915,7 +4877,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4931,7 +4892,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4947,7 +4907,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4961,7 +4920,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4992,7 +4950,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5017,7 +4974,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5033,7 +4989,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5049,7 +5004,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5063,7 +5017,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5101,7 +5054,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5126,7 +5078,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5142,7 +5093,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5158,7 +5108,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5172,7 +5121,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5203,7 +5151,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5228,7 +5175,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5244,7 +5190,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5260,7 +5205,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5274,7 +5218,6 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
+1 -2
View File
@@ -847,7 +847,6 @@ export function createData(config: CreateDataInput) {
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
@@ -866,7 +865,7 @@ export function createData(config: CreateDataInput) {
metadata: event.metadata,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.data.started },
time: { created: event.created },
})
})
return
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.7",
"version": "2.0.6",
"$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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.7",
"version": "2.0.6",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+4 -22
View File
@@ -12,34 +12,16 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
const policies = () =>
loaded.entries
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
for (const record of providers.list()) {
const policy = policies().findLast(
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
)
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
if (policy?.effect === "deny") providers.remove(record.provider.id)
}
})
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
const current = policies()
const denied = event.resources.some((resource) => {
const policy = current.findLast(
(policy) =>
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
)
return policy?.effect === "deny"
})
if (!denied) return
event.effect = "deny"
event.message = "Blocked by configuration policy"
}),
)
}),
})
@@ -45,15 +45,6 @@ function oauth(http: HttpClient.HttpClient) {
id: methodID,
type: "oauth",
label: "OpenCode Console account",
form: [
{
key: "server",
type: "string",
format: "uri",
hidden: true,
default: defaultServer,
},
],
},
authorize: (answer) =>
Effect.gen(function* () {
+1 -2
View File
@@ -223,7 +223,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
@@ -247,7 +246,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
time: { created: DateTime.makeUnsafe(event.data.started) },
time: { created },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
@@ -20,7 +20,6 @@ type Input = {
readonly model: Model.Ref
readonly providerMetadataKey: string
readonly snapshot?: Snapshot.ID
readonly started: number
readonly assistantMessageID: SessionMessage.ID
}
@@ -105,7 +104,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
model: input.model,
assistantMessageID,
snapshot: input.snapshot,
started: input.started,
})
return assistantMessageID
})
+1 -2
View File
@@ -10,7 +10,7 @@ import {
type ToolCall,
} from "@opencode/ai"
import type { Agent } from "@opencode/schema/agent"
import { Cause, Clock, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
import { SessionError } from "@opencode/schema/session-error"
import { Bus } from "../../bus.js"
import { Permission } from "../../permission.js"
@@ -78,7 +78,6 @@ export const make = Effect.gen(function* () {
model: input.model.ref,
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
snapshot: startSnapshot,
started: yield* Clock.currentTimeMillis,
})
const toolRuns: Array<{
readonly call: ToolCall
@@ -113,9 +113,6 @@ describe("OpencodePlugin", () => {
id: Integration.MethodID.make("device"),
type: "oauth",
label: "OpenCode Console account",
form: [
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://opencode.ai/console" },
],
},
{ type: "key", label: "API key (service account)" },
])
@@ -353,7 +350,7 @@ describe("OpencodePlugin", () => {
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Integration.AuthorizationError)
expect(String(error.cause)).toContain("Expected string for form field: server")
expect(String(error.cause)).toContain("Invalid OpenCode server URL: expected string")
}),
)
@@ -709,7 +709,6 @@ describe("Session.create", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model,
started: 0,
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID: parent.id,
@@ -820,7 +819,6 @@ describe("Session.create", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model,
started: 0,
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: parent.id,
@@ -1232,7 +1230,6 @@ describe("SessionTransfer", () => {
assistantMessageID: SessionMessage.ID.create(),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }),
started: 0,
})
yield* bus.publish(SessionEvent.Shell.Started, {
sessionID: source.id,
-1
View File
@@ -92,7 +92,6 @@ describe("Session.diff", () => {
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
started: 0,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
@@ -275,7 +275,6 @@ it.effect(
assistantMessageID: settledAssistant,
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
started: 0,
})
yield* bus.publish(SessionEvent.Text.Started, {
sessionID,
@@ -294,7 +293,6 @@ it.effect(
assistantMessageID: activeAssistant,
agent: Agent.ID.make("build"),
model: { id: ID.make("generate-model"), providerID: Provider.ID.make("test") },
started: 0,
})
yield* bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
-1
View File
@@ -177,7 +177,6 @@ describe("Session-owned handles", () => {
assistantMessageID: messageID,
agent: Agent.ID.make("build"),
model: { ...model, id: Model.ID.make("initial-model") },
started: 0,
})
yield* fixture.bus.publish(SessionEvent.Step.Ended, {
sessionID,
+2 -15
View File
@@ -485,7 +485,6 @@ describe("SessionProjector", () => {
assistantMessageID: id,
agent: build,
model,
started: 0,
})
.pipe(Effect.exit)
@@ -502,13 +501,7 @@ describe("SessionProjector", () => {
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: first,
agent: build,
model,
started: 0,
})
yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: build, model })
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: first,
@@ -530,13 +523,7 @@ describe("SessionProjector", () => {
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
})
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
assistantMessageID: second,
agent: build,
model,
started: 0,
})
yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: build, model })
yield* bus.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: second,
@@ -76,7 +76,6 @@ describe("Session.revert files", () => {
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
started: 0,
})
yield* Effect.promise(() => fs.rename(original, renamed))
const after = yield* snapshot.capture()
@@ -60,7 +60,6 @@ const capture = (
providerID: Provider.ID.opencode,
},
providerMetadataKey,
started: 0,
assistantMessageID: SessionMessage.ID.create(),
}),
}
@@ -164,7 +163,6 @@ testEffect(
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.opencode },
providerMetadataKey: "openai",
started: 0,
},
)
yield* publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))
@@ -4132,7 +4132,6 @@ describe("SessionRunnerLLM", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
started: 0,
})
yield* s.bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
@@ -4179,7 +4178,6 @@ describe("SessionRunnerLLM", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
started: 0,
})
yield* s.bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
@@ -4245,7 +4243,6 @@ describe("SessionRunnerLLM", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
started: 0,
})
yield* s.bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
@@ -4293,7 +4290,6 @@ describe("SessionRunnerLLM", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model: { id: ID.make("fake-model"), providerID: Provider.ID.make("fake") },
started: 0,
})
yield* s.bus.publish(SessionEvent.Tool.Input.Started, {
sessionID,
-1
View File
@@ -441,7 +441,6 @@ it.effect("regenerates an existing title using the title agent", () =>
assistantMessageID,
agent: Agent.ID.make("build"),
model: Model.Ref.make({ id: Model.ID.make("title-model"), providerID: Provider.ID.make("test") }),
started: 0,
})
yield* events.publish(SessionEvent.Reasoning.Started, { sessionID, assistantMessageID, ordinal: 0 })
yield* events.publish(SessionEvent.Reasoning.Ended, {
@@ -58,7 +58,6 @@ describe("Session tool progress", () => {
assistantMessageID,
agent: Agent.ID.make("build"),
model,
started: 0,
})
const readAssistant = Effect.gen(function* () {
const row = yield* db
-1
View File
@@ -92,7 +92,6 @@ const executionNode = makeGlobalNode({
assistantMessageID,
agent: session.agent ?? Agent.ID.make("code"),
model: sessionModel,
started: 0,
})
yield* bus.publish(SessionEvent.Text.Started, {
sessionID: id,
-1
View File
@@ -76,7 +76,6 @@ const executionNode = makeGlobalNode({
assistantMessageID,
agent: Agent.ID.make("reviewer"),
model: childModel,
started: 0,
})
yield* bus.publish(SessionEvent.Text.Started, {
sessionID,
@@ -18,18 +18,6 @@ const channels = [
] as const
for (const channel of channels) {
test(`disables security code AutoFill by default for ${channel.channel}`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel.channel
try {
const config = (await import(`./electron-builder.config.ts?autofill=${channel.channel}`)).default as Configuration
expect(config.mac?.extendInfo?.NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac).toBe(true)
} finally {
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
}
})
test(`includes the Windows sandbox permission hook for ${channel.channel}`, async () => {
const previous = process.env.OPENCODE_CHANNEL
process.env.OPENCODE_CHANNEL = channel.channel
@@ -104,9 +104,6 @@ const getBase = (appId: string): Configuration => ({
mac: {
category: "public.app-category.developer-tools",
icon: `resources/icons/icon.icns`,
extendInfo: {
NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac: true,
},
hardenedRuntime: true,
gatekeeperAssess: false,
entitlements: "resources/entitlements.plist",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
-1
View File
@@ -27,7 +27,6 @@ export async function prepareDevElectron() {
await $`plutil -replace ${key} -string ${"OpenCode Dev"} ${plist}`
}
await $`plutil -replace CFBundleIdentifier -string ai.opencode.desktop.dev ${plist}`
await $`plutil -insert NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac -bool true ${plist}`
await Bun.write(join(bundle, "Contents/Resources/electron.icns"), icon)
// Changing the bundle resources invalidates Electron's signature.
await $`codesign --force --deep --sign - ${bundle}`
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.7",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.7",
"version": "2.0.6",
"$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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -129
View File
@@ -163,7 +163,7 @@
}
},
"description": "Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
"summary": "Reload configuration"
"summary": "Reload locations"
}
},
"/api/agent": {
@@ -8440,109 +8440,6 @@
"summary": "Find files"
}
},
"/api/experimental/fs/write": {
"post": {
"tags": ["filesystem"],
"operationId": "experimental.fs.write",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
},
{
"name": "path",
"in": "query",
"schema": {
"type": "string",
"description": "An absolute path or a path relative to the requested location. Missing parent directories are created."
},
"required": true
}
],
"security": [],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"location": {
"$ref": "#/components/schemas/Location.PublicRef"
},
"data": {
"$ref": "#/components/schemas/FileSystem.Write"
}
},
"required": ["location", "data"],
"additionalProperties": false
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Write the raw request body to an absolute path or a path relative to the requested location, creating parent directories, and return the resolved absolute path. Unlike read, the target is not confined to the location. Experimental: may change without compatibility guarantees.",
"summary": "Write file",
"requestBody": {
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
},
"required": true
}
}
},
"/api/command": {
"get": {
"tags": ["command"],
@@ -13640,16 +13537,6 @@
"required": ["path", "type"],
"additionalProperties": false
},
"FileSystem.Write": {
"type": "object",
"properties": {
"path": {
"type": "string"
}
},
"required": ["path"],
"additionalProperties": false
},
"ForbiddenErrorEncoded": {
"type": "object",
"properties": {
@@ -13685,9 +13572,6 @@
"required": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
},
"when": {
"type": "array",
"items": {
@@ -13877,9 +13761,6 @@
"required": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
},
"when": {
"type": "array",
"items": {
@@ -13945,9 +13826,6 @@
"required": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
},
"when": {
"type": "array",
"items": {
@@ -14000,9 +13878,6 @@
"required": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
},
"when": {
"type": "array",
"items": {
@@ -14131,9 +14006,6 @@
"required": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
},
"when": {
"type": "array",
"items": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -54,7 +54,7 @@ export const makeLocationGroup = <LocationId extends HttpApiMiddleware.AnyId, Lo
}).annotateMerge(
OpenApi.annotations({
identifier: "location.reload",
summary: "Reload configuration",
summary: "Reload locations",
description:
"Shut down and rebuild every loaded location. Pending permissions and forms are cancelled; running sessions continue with fresh services at the next step boundary. Emits location.shutdown for client recovery and responds once all replacement builds settle.",
}),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -6,7 +6,7 @@ export const Effect = Schema.Literals(["allow", "deny"])
export type Effect = typeof Effect.Type
export const Info = Schema.Struct({
action: Schema.Literals(["provider.use", "permission"]),
action: Schema.Literal("provider.use"),
resource: Schema.String,
effect: Effect,
})
-3
View File
@@ -41,9 +41,6 @@ const FieldBase = {
title: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
required: Schema.Boolean.pipe(optional),
hidden: Schema.Boolean.pipe(optional).annotate({
description: "Skip the interactive authentication prompt and use the default unless an answer is supplied",
}),
when: Schema.Array(When).pipe(optional),
}
-2
View File
@@ -328,8 +328,6 @@ export namespace Step {
agent: Agent.ID,
model: Model.Ref,
snapshot: Snapshot.ID.pipe(optional),
/** Request dispatch time, before waiting for provider output. */
started: NonNegativeInt,
},
})
export type Started = typeof Started.Type
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
@@ -1,5 +1,4 @@
import { Integration } from "@opencode/core/integration"
import { Plugin } from "@opencode/core/plugin"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -30,7 +29,6 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
.handle(
"integration.list",
Effect.fn(function* () {
yield* Plugin.awaitActivation
const service = yield* Integration.Service
return yield* response(service.list())
}),
+2 -8
View File
@@ -1,14 +1,14 @@
import { expect } from "bun:test"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { Plugin } from "@opencode/plugin/effect"
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
import { Context, Deferred, Effect, Layer } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
it.live(
"provider reads stay nonblocking while authentication discovery waits for plugins",
"lists and gets providers without blocking on plugin initialization",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped("opencode-provider-endpoints-")
@@ -65,7 +65,6 @@ it.live(
// Config providers activate after SDK plugins; reads must return the current snapshot without waiting.
const list = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
yield* Deferred.await(started)
const integrations = yield* request("GET", "/api/integration").pipe(Effect.forkScoped)
expect(list.status).toBe(200)
expect(yield* Effect.promise(() => list.json())).toMatchObject({
location: { directory: tmp.path },
@@ -78,11 +77,6 @@ it.live(
providerID: "custom",
})
yield* Deferred.succeed(release, undefined)
const discovered = yield* Fiber.join(integrations)
expect(discovered.status).toBe(200)
expect(yield* Effect.promise(() => discovered.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ id: "opencode" })]),
})
}),
15_000,
)
@@ -37,7 +37,6 @@ it.live("serves turn diffs by user message with range validation", () =>
assistantMessageID: ids.assistant,
agent: Agent.defaultID,
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
started: 0,
})
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/stats-server",
"version": "2.0.7",
"version": "2.0.6",
"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.7",
"version": "2.0.6",
"type": "module",
"license": "MIT",
"repository": {
+1 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
"version": "2.0.7",
"version": "2.0.6",
"private": true,
"type": "module",
"license": "MIT",
@@ -41,7 +41,6 @@
"./parsers-config": "./src/parsers-config.ts",
"./util/error": "./src/util/error.ts",
"./util/filetype": "./src/util/filetype.ts",
"./util/form": "./src/util/form.ts",
"./util/locale": "./src/util/locale.ts",
"./util/persistence": "./src/util/persistence.ts",
"./util/record": "./src/util/record.ts",
+4 -6
View File
@@ -158,7 +158,6 @@ const appBindingCommands = [
"opencode.update",
"server.pair",
"service.restart",
"location.reload",
"opencode.debug",
"theme.switch",
"theme.switch_mode",
@@ -968,8 +967,7 @@ function App(props: { pair?: DialogPairCredentials }) {
{
name: "opencode.update",
title: "Update OpenCode",
description: "Update OpenCode (upgrade)",
slash: { name: "update" },
slash: { name: "update", aliases: ["upgrade"] },
run: () => updater.open?.("manual"),
category: "System",
},
@@ -1007,15 +1005,15 @@ function App(props: { pair?: DialogPairCredentials }) {
: []),
{
name: "location.reload",
title: "Reload configuration",
title: "Reload locations",
slash: { name: "reload" },
run: async () => {
dialog.clear()
toast.show({ variant: "info", message: "Reloading configuration…", duration: 30000 })
toast.show({ variant: "info", message: "Reloading all locations…", duration: 30000 })
await client.api.location
.reload()
.then(() => {
toast.show({ variant: "success", message: "Configuration reloaded" })
toast.show({ variant: "success", message: "Locations reloaded" })
})
.catch(toast.error)
},
@@ -766,7 +766,7 @@ async function formAnswer(dialog: ReturnType<typeof useDialog>, title: string, f
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) continue
const value = field.type !== "external" && field.hidden ? field.default : await fieldAnswer(dialog, title, field)
const value = await fieldAnswer(dialog, title, field)
if (value === CANCELLED) return null
if (value !== undefined) answer[field.key] = value
}
+2 -6
View File
@@ -21,8 +21,6 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?:
const timer = setInterval(() => setFrame((value) => (value + 1) % SPINNER_FRAMES.length), 80)
onCleanup(() => clearInterval(timer))
})
// A bare spinner beside a long sibling shrinks to a fractional width that rounds to 0, so the
// glyph paints on top of the row gap. Only a labeled spinner may shrink (to wrap its text).
return (
<Show
when={config.animations ?? true}
@@ -31,10 +29,8 @@ export function Spinner(props: { children?: JSX.Element; color?: RGBA; shimmer?:
<Show
when={props.shimmer}
fallback={
<box flexDirection="row" gap={1} flexShrink={props.children ? 1 : 0}>
<box flexShrink={0}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
</box>
<box flexDirection="row" gap={1}>
<spinner frames={SPINNER_FRAMES} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
-1
View File
@@ -60,7 +60,6 @@ export const Definitions = {
"opencode.settings": keybind("none", "Open settings"),
"server.pair": keybind("none", "Pair device"),
"service.restart": keybind("none", "Restart service"),
"location.reload": keybind("none", "Reload configuration"),
"diff.open": keybind("none", "Open diff viewer"),
"diff.close": keybind("escape,q", "Close diff viewer"),
"diff.down": keybind("j,down", "Move diff viewer down"),

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