diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b4d6abd15eb..b3f82d9e43aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,9 @@ permissions: contents: read checks: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: unit: name: unit (linux) @@ -28,6 +31,11 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun @@ -80,6 +88,11 @@ jobs: ref: ${{ github.event.pull_request.head.sha || github.sha }} token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun diff --git a/bun.lock b/bun.lock index 5259082cf50c..b39c4e0e7801 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -81,7 +81,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -115,7 +115,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -142,7 +142,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -166,7 +166,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -190,7 +190,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -223,7 +223,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "effect": "catalog:", "electron-context-menu": "4.1.2", @@ -266,7 +266,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -295,7 +295,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -321,7 +321,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.4.1", + "version": "1.4.3", "bin": { "opencode": "./bin/opencode", }, @@ -457,7 +457,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -491,7 +491,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "cross-spawn": "catalog:", }, @@ -506,7 +506,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -541,7 +541,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -590,7 +590,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "zod": "catalog:", }, @@ -601,7 +601,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 9febc4b3ff4d..a03d1d437504 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") { throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) } +function done(proc: ReturnType) { + return proc.exitCode !== null || proc.signalCode !== null +} + async function waitExit(proc: ReturnType, timeout = 10_000) { - if (proc.exitCode !== null) return + if (done(proc)) return await Promise.race([ new Promise((resolve) => proc.once("exit", () => resolve())), new Promise((resolve) => setTimeout(resolve, timeout)), @@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }): return { url, async stop() { - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGTERM") await waitExit(proc) } - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGKILL") await waitExit(proc) } diff --git a/packages/app/package.json b/packages/app/package.json index a052793d8238..2ac271df232b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.1", + "version": "1.4.3", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 786c2baeda95..bcb02a907919 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 5184c2fc0a80..e60da9d45346 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.4.1", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b34fa7377759..d6ad86b8f7af 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.4.1", + "version": "1.4.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 04a0a06bae9b..898387d01b93 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.4.1", + "version": "1.4.3", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index f8274a4759f8..694382045d7d 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 016a205bdbb6..8815bf7bcef6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/scripts/finalize-latest-json.ts b/packages/desktop/scripts/finalize-latest-json.ts index a2b95d2c4771..855c6a3878cf 100644 --- a/packages/desktop/scripts/finalize-latest-json.ts +++ b/packages/desktop/scripts/finalize-latest-json.ts @@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE if (!releaseId) throw new Error("OPENCODE_RELEASE is required") const version = process.env.OPENCODE_VERSION -if (!releaseId) throw new Error("OPENCODE_VERSION is required") +if (!version) throw new Error("OPENCODE_VERSION is required") const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") @@ -54,7 +54,10 @@ const assets = release.assets ?? [] const assetByName = new Map(assets.map((asset) => [asset.name, asset])) const latestAsset = assetByName.get("latest.json") -if (!latestAsset) throw new Error("latest.json asset not found") +if (!latestAsset) { + console.log("latest.json not found, skipping tauri finalization") + process.exit(0) +} const latestRes = await fetch(latestAsset.url, { headers: { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index c9d98dc03908..db3da877b9a6 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.1", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7c49722a587e..c13d6c1eb086 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.4.1" +version = "1.4.3" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.1/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index baeee69438f7..76262c25a577 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.4.1", + "version": "1.4.3", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f842a97bf592..7cce13190860 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.1", + "version": "1.4.3", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 37baf34e9323..0aca857822e8 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -461,28 +461,11 @@ export namespace Account { return Option.getOrUndefined(await runPromise((service) => service.active())) } - export async function list(): Promise { - return runPromise((service) => service.list()) - } - - export async function activeOrg(): Promise { - return Option.getOrUndefined(await runPromise((service) => service.activeOrg())) - } - export async function orgsByAccount(): Promise { return runPromise((service) => service.orgsByAccount()) } - export async function orgs(accountID: AccountID): Promise { - return runPromise((service) => service.orgs(accountID)) - } - export async function switchOrg(accountID: AccountID, orgID: OrgID) { return runPromise((service) => service.use(accountID, Option.some(orgID))) } - - export async function token(accountID: AccountID): Promise { - const t = await runPromise((service) => service.token(accountID)) - return Option.getOrUndefined(t) - } } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 0c6fe6ec91c8..2a8bed092799 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -341,6 +341,10 @@ export namespace Agent { ) const existing = yield* InstanceState.useEffect(state, (s) => s.list()) + // TODO: clean this up so provider specific logic doesnt bleed over + const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) + const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth" + const params = { experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry, @@ -350,12 +354,14 @@ export namespace Agent { }, temperature: 0.3, messages: [ - ...system.map( - (item): ModelMessage => ({ - role: "system", - content: item, - }), - ), + ...(isOpenaiOauth + ? [] + : system.map( + (item): ModelMessage => ({ + role: "system", + content: item, + }), + )), { role: "user", content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, @@ -369,13 +375,12 @@ export namespace Agent { }), } satisfies Parameters[0] - // TODO: clean this up so provider specific logic doesnt bleed over - const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie) - if (model.providerID === "openai" && authInfo?.type === "oauth") { + if (isOpenaiOauth) { return yield* Effect.promise(async () => { const result = streamObject({ ...params, providerOptions: ProviderTransform.providerOptions(resolved, { + instructions: system.join("\n"), store: false, }), onError: () => {}, @@ -393,11 +398,13 @@ export namespace Agent { }), ) - export const defaultLayer = layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Provider.defaultLayer), + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Skill.defaultLayer), + ), ) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index e8f3e6a11e1c..8b693e79ae20 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -21,6 +21,7 @@ import { cmd } from "./cmd" import { ModelsDev } from "../../provider/models" import { Instance } from "@/project/instance" import { bootstrap } from "../bootstrap" +import { SessionShare } from "@/share/session" import { Session } from "../../session" import type { SessionID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema" @@ -559,7 +560,7 @@ export const GithubRunCommand = cmd({ shareId = await (async () => { if (share === false) return if (!share && repoData.data.private) return - await Session.share(session.id) + await SessionShare.share(session.id) return session.id.slice(-8) })() console.log("opencode session", session.id) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c45b9e55d0f8..41e498102e7e 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async () => {}, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d4ae8db61c28..32a9d13367d1 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -155,7 +155,7 @@ export function Session() { const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide") const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true) const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true) - const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true) + const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false) const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word") const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true) const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f3300a744acc..1422eca4d9af 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -400,6 +400,10 @@ export namespace Config { .describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."), clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"), scope: z.string().optional().describe("OAuth scopes to request during authorization"), + redirectUri: z + .string() + .optional() + .describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."), }) .strict() .meta({ diff --git a/packages/opencode/src/effect/cross-spawn-spawner.ts b/packages/opencode/src/effect/cross-spawn-spawner.ts index 92e5b3ba2d07..76982a613367 100644 --- a/packages/opencode/src/effect/cross-spawn-spawner.ts +++ b/packages/opencode/src/effect/cross-spawn-spawner.ts @@ -499,4 +499,3 @@ const rt = lazy(async () => { type RT = Awaited> export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any])) -export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any])) diff --git a/packages/opencode/src/effect/instance-state.ts b/packages/opencode/src/effect/instance-state.ts index cc5901fb5eac..a379d3afc030 100644 --- a/packages/opencode/src/effect/instance-state.ts +++ b/packages/opencode/src/effect/instance-state.ts @@ -73,10 +73,4 @@ export namespace InstanceState { Effect.gen(function* () { return yield* ScopedCache.invalidate(self.cache, yield* directory) }) - - /** - * Effect finalizers run on the fiber scheduler after the original async - * boundary, so ALS reads like Instance.directory can be gone by then. - */ - export const withALS = (fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn)) } diff --git a/packages/opencode/src/effect/runner.ts b/packages/opencode/src/effect/runner.ts index cb12b4c52ba9..38c45a6342eb 100644 --- a/packages/opencode/src/effect/runner.ts +++ b/packages/opencode/src/effect/runner.ts @@ -1,10 +1,10 @@ -import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect" export interface Runner { readonly state: Runner.State readonly busy: boolean readonly ensureRunning: (work: Effect.Effect) => Effect.Effect - readonly startShell: (work: (signal: AbortSignal) => Effect.Effect) => Effect.Effect + readonly startShell: (work: Effect.Effect) => Effect.Effect readonly cancel: Effect.Effect } @@ -20,7 +20,6 @@ export namespace Runner { interface ShellHandle { id: number fiber: Fiber.Fiber - abort: AbortController } interface PendingHandle { @@ -100,13 +99,7 @@ export namespace Runner { }), ).pipe(Effect.flatten) - const stopShell = (shell: ShellHandle) => - Effect.gen(function* () { - shell.abort.abort() - const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis")) - if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber) - yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid) - }) + const stopShell = (shell: ShellHandle) => Fiber.interrupt(shell.fiber) const ensureRunning = (work: Effect.Effect) => SynchronizedRef.modifyEffect( @@ -138,7 +131,7 @@ export namespace Runner { ), ) - const startShell = (work: (signal: AbortSignal) => Effect.Effect) => + const startShell = (work: Effect.Effect) => SynchronizedRef.modifyEffect( ref, Effect.fnUntraced(function* (st) { @@ -153,9 +146,8 @@ export namespace Runner { } yield* busy const id = next() - const abort = new AbortController() - const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) - const shell = { id, fiber, abort } satisfies ShellHandle + const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild) + const shell = { id, fiber } satisfies ShellHandle return [ Effect.gen(function* () { const exit = yield* Fiber.await(fiber) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index cdcf80a99e50..47d15fbb0019 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -11,7 +11,6 @@ import path from "path" import z from "zod" import { Global } from "../global" import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Protected } from "./protected" import { Ripgrep } from "./ripgrep" @@ -344,6 +343,7 @@ export namespace File { Service, Effect.gen(function* () { const appFs = yield* AppFileSystem.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("File.state")(() => @@ -410,6 +410,10 @@ export namespace File { cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) }) + const gitText = Effect.fnUntraced(function* (args: string[]) { + return (yield* git.run(args, { cwd: Instance.directory })).text() + }) + const init = Effect.fn("File.init")(function* () { yield* ensure() }) @@ -417,100 +421,87 @@ export namespace File { const status = Effect.fn("File.status")(function* () { if (Instance.project.vcs !== "git") return [] - return yield* Effect.promise(async () => { - const diffOutput = ( - await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: Instance.directory, + const diffOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--numstat", + "HEAD", + ]) + + const changed: File.Info[] = [] + + if (diffOutput.trim()) { + for (const line of diffOutput.trim().split("\n")) { + const [added, removed, file] = line.split("\t") + changed.push({ + path: file, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", }) - ).text() - - const changed: File.Info[] = [] - - if (diffOutput.trim()) { - for (const line of diffOutput.trim().split("\n")) { - const [added, removed, file] = line.split("\t") - changed.push({ - path: file, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } } + } - const untrackedOutput = ( - await Git.run( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "ls-files", - "--others", - "--exclude-standard", - ], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (untrackedOutput.trim()) { - for (const file of untrackedOutput.trim().split("\n")) { - try { - const content = await Filesystem.readText(path.join(Instance.directory, file)) - changed.push({ - path: file, - added: content.split("\n").length, - removed: 0, - status: "added", - }) - } catch { - continue - } - } + const untrackedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ]) + + if (untrackedOutput.trim()) { + for (const file of untrackedOutput.trim().split("\n")) { + const content = yield* appFs + .readFileString(path.join(Instance.directory, file)) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (content === undefined) continue + changed.push({ + path: file, + added: content.split("\n").length, + removed: 0, + status: "added", + }) } + } - const deletedOutput = ( - await Git.run( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--name-only", - "--diff-filter=D", - "HEAD", - ], - { - cwd: Instance.directory, - }, - ) - ).text() - - if (deletedOutput.trim()) { - for (const file of deletedOutput.trim().split("\n")) { - changed.push({ - path: file, - added: 0, - removed: 0, - status: "deleted", - }) - } + const deletedOutput = yield* gitText([ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ]) + + if (deletedOutput.trim()) { + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, + added: 0, + removed: 0, + status: "deleted", + }) } + } - return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) - return { - ...item, - path: path.relative(Instance.directory, full), - } - }) + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path) + return { + ...item, + path: path.relative(Instance.directory, full), + } }) }) - const read = Effect.fn("File.read")(function* (file: string) { + const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { using _ = log.time("read", { file }) const full = path.join(Instance.directory, file) @@ -558,27 +549,19 @@ export namespace File { ) if (Instance.project.vcs === "git") { - return yield* Effect.promise(async (): Promise => { - let diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory }) - ).text() - if (!diff.trim()) { - diff = ( - await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { - cwd: Instance.directory, - }) - ).text() - } - if (diff.trim()) { - const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - return { type: "text", content, patch, diff: formatPatch(patch) } - } - return { type: "text", content } - }) + let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) + if (!diff.trim()) { + diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) + } + if (diff.trim()) { + const original = yield* git.show(Instance.directory, "HEAD", file) + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + return { type: "text" as const, content, patch, diff: formatPatch(patch) } + } + return { type: "text" as const, content } } return { type: "text" as const, content } @@ -660,7 +643,7 @@ export namespace File { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index b78b3a33a086..dd8b5798ca92 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -71,6 +71,7 @@ export namespace FileWatcher { Service, Effect.gen(function* () { const config = yield* Config.Service + const git = yield* Git.Service const state = yield* InstanceState.make( Effect.fn("FileWatcher.state")( @@ -131,11 +132,9 @@ export namespace FileWatcher { } if (Instance.project.vcs === "git") { - const result = yield* Effect.promise(() => - Git.run(["rev-parse", "--git-dir"], { - cwd: Instance.project.worktree, - }), - ) + const result = yield* git.run(["rev-parse", "--git-dir"], { + cwd: Instance.project.worktree, + }) const vcsDir = result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { @@ -161,7 +160,7 @@ export namespace FileWatcher { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 2b3a8a9b050c..10c96d560bdf 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -265,39 +265,7 @@ export namespace Git { return runPromise((git) => git.run(args, opts)) } - export async function branch(cwd: string) { - return runPromise((git) => git.branch(cwd)) - } - - export async function prefix(cwd: string) { - return runPromise((git) => git.prefix(cwd)) - } - export async function defaultBranch(cwd: string) { return runPromise((git) => git.defaultBranch(cwd)) } - - export async function hasHead(cwd: string) { - return runPromise((git) => git.hasHead(cwd)) - } - - export async function mergeBase(cwd: string, base: string, head?: string) { - return runPromise((git) => git.mergeBase(cwd, base, head)) - } - - export async function show(cwd: string, ref: string, file: string, prefix?: string) { - return runPromise((git) => git.show(cwd, ref, file, prefix)) - } - - export async function status(cwd: string) { - return runPromise((git) => git.status(cwd)) - } - - export async function diff(cwd: string, ref: string) { - return runPromise((git) => git.diff(cwd, ref)) - } - - export async function stats(cwd: string, ref: string) { - return runPromise((git) => git.stats(cwd, ref)) - } } diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index f50c858e912f..abfb31ead0eb 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -105,17 +105,7 @@ export namespace LSPServer { if (!tsserver) return const bin = await Npm.which("typescript-language-server") if (!bin) return - - const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver] - - if ( - !(await pathExists(path.join(root, "tsconfig.json"))) && - !(await pathExists(path.join(root, "jsconfig.json"))) - ) { - args.push("--ignore-node-modules") - } - - const proc = spawn(bin, args, { + const proc = spawn(bin, ["--stdio"], { cwd: root, env: { ...process.env, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 45e3e2567a28..2599a8dec904 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -286,6 +286,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -716,13 +717,16 @@ export namespace MCP { if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`) if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`) - yield* Effect.promise(() => McpOAuthCallback.ensureRunning()) + // OAuth config is optional - if not provided, we'll use auto-discovery + const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined + + // Start the callback server with custom redirectUri if configured + yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri)) const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32))) .map((b) => b.toString(16).padStart(2, "0")) .join("") yield* auth.updateOAuthState(mcpName, oauthState) - const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined let capturedUrl: URL | undefined const authProvider = new McpOAuthProvider( mcpName, @@ -731,6 +735,7 @@ export namespace MCP { clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, }, { onRedirect: async (url) => { @@ -901,9 +906,6 @@ export namespace MCP { export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name)) - export const getPrompt = async (clientName: string, name: string, args?: Record) => - runPromise((svc) => svc.getPrompt(clientName, name, args)) - export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName)) export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName)) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index dd1d886fc1b4..b5b6a7a6ebbc 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,10 +1,14 @@ import { createConnection } from "net" import { createServer } from "http" import { Log } from "../util/log" -import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider" +import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" const log = Log.create({ service: "mcp.oauth-callback" }) +// Current callback server configuration (may differ from defaults if custom redirectUri is used) +let currentPort = OAUTH_CALLBACK_PORT +let currentPath = OAUTH_CALLBACK_PATH + const HTML_SUCCESS = ` @@ -71,9 +75,9 @@ export namespace McpOAuthCallback { } function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { - const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`) + const url = new URL(req.url || "/", `http://localhost:${currentPort}`) - if (url.pathname !== OAUTH_CALLBACK_PATH) { + if (url.pathname !== currentPath) { res.writeHead(404) res.end("Not found") return @@ -135,19 +139,31 @@ export namespace McpOAuthCallback { res.end(HTML_SUCCESS) } - export async function ensureRunning(): Promise { + export async function ensureRunning(redirectUri?: string): Promise { + // Parse the redirect URI to get port and path (uses defaults if not provided) + const { port, path } = parseRedirectUri(redirectUri) + + // If server is running on a different port/path, stop it first + if (server && (currentPort !== port || currentPath !== path)) { + log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port }) + await stop() + } + if (server) return - const running = await isPortInUse() + const running = await isPortInUse(port) if (running) { - log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT }) + log.info("oauth callback server already running on another instance", { port }) return } + currentPort = port + currentPath = path + server = createServer(handleRequest) await new Promise((resolve, reject) => { - server!.listen(OAUTH_CALLBACK_PORT, () => { - log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT }) + server!.listen(currentPort, () => { + log.info("oauth callback server started", { port: currentPort, path: currentPath }) resolve() }) server!.on("error", reject) @@ -182,9 +198,9 @@ export namespace McpOAuthCallback { } } - export async function isPortInUse(): Promise { + export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise { return new Promise((resolve) => { - const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1") + const socket = createConnection(port, "127.0.0.1") socket.on("connect", () => { socket.destroy() resolve(true) diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index b4da73169e1f..d675fc71e469 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -17,6 +17,7 @@ export interface McpOAuthConfig { clientId?: string clientSecret?: string scope?: string + redirectUri?: string } export interface McpOAuthCallbacks { @@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider { ) {} get redirectUrl(): string { + if (this.config.redirectUri) { + return this.config.redirectUri + } return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}` } @@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider { } export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } + +/** + * Parse a redirect URI to extract port and path for the callback server. + * Returns defaults if the URI can't be parsed. + */ +export function parseRedirectUri(redirectUri?: string): { port: number; path: string } { + if (!redirectUri) { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } + + try { + const url = new URL(redirectUri) + const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80 + const path = url.pathname || OAUTH_CALLBACK_PATH + return { port, path } + } catch { + return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH } + } +} diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index bdeef9823ff2..1e127fae5489 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.4", "gpt-5.4-mini", ]) - for (const modelId of Object.keys(provider.models)) { + for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue - if (allowedModels.has(modelId)) continue + if (allowedModels.has(model.api.id)) continue delete provider.models[modelId] } diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index ec6e415c8207..d31dff6a9797 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -161,39 +161,37 @@ export namespace Vcs { const bus = yield* Bus.Service const state = yield* InstanceState.make( - Effect.fn("Vcs.state")((ctx) => - Effect.gen(function* () { - if (ctx.project.vcs !== "git") { - return { current: undefined, root: undefined } - } - - const get = Effect.fnUntraced(function* () { - return yield* git.branch(ctx.directory) - }) - const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { - concurrency: 2, - }) - const value = { current, root } - log.info("initialized", { branch: value.current, default_branch: value.root?.name }) - - yield* bus.subscribe(FileWatcher.Event.Updated).pipe( - Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), - Stream.runForEach((_evt) => - Effect.gen(function* () { - const next = yield* get() - if (next !== value.current) { - log.info("branch changed", { from: value.current, to: next }) - value.current = next - yield* bus.publish(Event.BranchUpdated, { branch: next }) - } - }), - ), - Effect.forkScoped, - ) + Effect.fn("Vcs.state")(function* (ctx) { + if (ctx.project.vcs !== "git") { + return { current: undefined, root: undefined } + } - return value - }), - ), + const get = Effect.fnUntraced(function* () { + return yield* git.branch(ctx.directory) + }) + const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], { + concurrency: 2, + }) + const value = { current, root } + log.info("initialized", { branch: value.current, default_branch: value.root?.name }) + + yield* bus.subscribe(FileWatcher.Event.Updated).pipe( + Stream.filter((evt) => evt.properties.file.endsWith("HEAD")), + Stream.runForEach((_evt) => + Effect.gen(function* () { + const next = yield* get() + if (next !== value.current) { + log.info("branch changed", { from: value.current, to: next }) + value.current = next + yield* bus.publish(Event.BranchUpdated, { branch: next }) + } + }), + ), + Effect.forkScoped, + ) + + return value + }), ) return Service.of({ diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 23f61d804e9c..2d787588b0b5 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -22,6 +22,27 @@ export namespace ModelsDev { ) const ttl = 5 * 60 * 1000 + type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[] + + const JsonValue: z.ZodType = z.lazy(() => + z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]), + ) + + const Cost = z.object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + context_over_200k: z + .object({ + input: z.number(), + output: z.number(), + cache_read: z.number().optional(), + cache_write: z.number().optional(), + }) + .optional(), + }) + export const Model = z.object({ id: z.string(), name: z.string(), @@ -41,22 +62,7 @@ export namespace ModelsDev { .strict(), ]) .optional(), - cost: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - context_over_200k: z - .object({ - input: z.number(), - output: z.number(), - cache_read: z.number().optional(), - cache_write: z.number().optional(), - }) - .optional(), - }) - .optional(), + cost: Cost.optional(), limit: z.object({ context: z.number(), input: z.number().optional(), @@ -68,7 +74,24 @@ export namespace ModelsDev { output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])), }) .optional(), - experimental: z.boolean().optional(), + experimental: z + .object({ + modes: z + .record( + z.string(), + z.object({ + cost: Cost.optional(), + provider: z + .object({ + body: z.record(z.string(), JsonValue).optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + }) + .optional(), status: z.enum(["alpha", "beta", "deprecated"]).optional(), provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8d5c9f2ced1c..004fb77f91a0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -926,6 +926,28 @@ export namespace Provider { export class Service extends ServiceMap.Service()("@opencode/Provider") {} + function cost(c: ModelsDev.Model["cost"]): Model["cost"] { + const result: Model["cost"] = { + input: c?.input ?? 0, + output: c?.output ?? 0, + cache: { + read: c?.cache_read ?? 0, + write: c?.cache_write ?? 0, + }, + } + if (c?.context_over_200k) { + result.experimentalOver200K = { + cache: { + read: c.context_over_200k.cache_read ?? 0, + write: c.context_over_200k.cache_write ?? 0, + }, + input: c.context_over_200k.input, + output: c.context_over_200k.output, + } + } + return result + } + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const m: Model = { id: ModelID.make(model.id), @@ -940,24 +962,7 @@ export namespace Provider { status: model.status ?? "active", headers: {}, options: {}, - cost: { - input: model.cost?.input ?? 0, - output: model.cost?.output ?? 0, - cache: { - read: model.cost?.cache_read ?? 0, - write: model.cost?.cache_write ?? 0, - }, - experimentalOver200K: model.cost?.context_over_200k - ? { - cache: { - read: model.cost.context_over_200k.cache_read ?? 0, - write: model.cost.context_over_200k.cache_write ?? 0, - }, - input: model.cost.context_over_200k.input, - output: model.cost.context_over_200k.output, - } - : undefined, - }, + cost: cost(model.cost), limit: { context: model.limit.context, input: model.limit.input, @@ -994,13 +999,31 @@ export namespace Provider { } export function fromModelsDevProvider(provider: ModelsDev.Provider): Info { + const models: Record = {} + for (const [key, model] of Object.entries(provider.models)) { + models[key] = fromModelsDevModel(provider, model) + for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) { + const id = `${model.id}-${mode}` + const m = fromModelsDevModel(provider, model) + m.id = ModelID.make(id) + m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}` + if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost)) + // convert body params to camelCase for ai sdk compatibility + if (opts.provider?.body) + m.options = Object.fromEntries( + Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]), + ) + if (opts.provider?.headers) m.headers = opts.provider.headers + models[id] = m + } + } return { id: ProviderID.make(provider.id), source: "custom", name: provider.name, env: provider.env ?? [], options: {}, - models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)), + models, } } diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 0321b9800ba5..7695b9ce6a85 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -371,10 +371,6 @@ export namespace Pty { return runPromise((svc) => svc.get(id)) } - export async function resize(id: PtyID, cols: number, rows: number) { - return runPromise((svc) => svc.resize(id, cols, rows)) - } - export async function write(id: PtyID, data: string) { return runPromise((svc) => svc.write(id, data)) } diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 68be1a6a5a2d..83658987e3c7 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -6,13 +6,16 @@ import z from "zod" import { Session } from "../../session" import { MessageV2 } from "../../session/message-v2" import { SessionPrompt } from "../../session/prompt" +import { SessionRunState } from "@/session/run-state" import { SessionCompaction } from "../../session/compaction" import { SessionRevert } from "../../session/revert" +import { SessionShare } from "@/share/session" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "../../session/todo" import { Agent } from "../../agent/agent" import { Snapshot } from "@/snapshot" +import { Command } from "../../command" import { Log } from "../../util/log" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" @@ -121,7 +124,6 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - log.info("SEARCH", { url: c.req.url }) const session = await Session.get(sessionID) return c.json(session) }, @@ -205,10 +207,10 @@ export const SessionRoutes = lazy(() => }, }, }), - validator("json", Session.create.schema.optional()), + validator("json", Session.create.schema), async (c) => { const body = c.req.valid("json") ?? {} - const session = await Session.create(body) + const session = await SessionShare.create(body) return c.json(session) }, ) @@ -292,6 +294,7 @@ export const SessionRoutes = lazy(() => return c.json(session) }, ) + // TODO(v2): remove this dedicated route and rely on the normal `/init` command flow. .post( "/:sessionID/init", describeRoute({ @@ -317,11 +320,24 @@ export const SessionRoutes = lazy(() => sessionID: SessionID.zod, }), ), - validator("json", Session.initialize.schema.omit({ sessionID: true })), + validator( + "json", + z.object({ + modelID: ModelID.zod, + providerID: ProviderID.zod, + messageID: MessageID.zod, + }), + ), async (c) => { const sessionID = c.req.valid("param").sessionID const body = c.req.valid("json") - await Session.initialize({ ...body, sessionID }) + await SessionPrompt.command({ + sessionID, + messageID: body.messageID, + model: body.providerID + "/" + body.modelID, + command: Command.Default.INIT, + arguments: "", + }) return c.json(true) }, ) @@ -411,7 +427,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.share(sessionID) + await SessionShare.share(sessionID) const session = await Session.get(sessionID) return c.json(session) }, @@ -476,12 +492,12 @@ export const SessionRoutes = lazy(() => validator( "param", z.object({ - sessionID: Session.unshare.schema, + sessionID: SessionID.zod, }), ), async (c) => { const sessionID = c.req.valid("param").sessionID - await Session.unshare(sessionID) + await SessionShare.unshare(sessionID) const session = await Session.get(sessionID) return c.json(session) }, @@ -699,7 +715,7 @@ export const SessionRoutes = lazy(() => ), async (c) => { const params = c.req.valid("param") - await SessionPrompt.assertNotBusy(params.sessionID) + await SessionRunState.assertNotBusy(params.sessionID) await Session.removeMessage({ sessionID: params.sessionID, messageID: params.messageID, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 0961c20a6e1a..937aa7132530 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -377,17 +377,15 @@ When constructing the summary, try to stick to this template: }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Provider.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), ), ) @@ -401,17 +399,6 @@ When constructing the summary, try to stick to this template: return runPromise((svc) => svc.prune(input)) } - export const process = fn( - z.object({ - parentID: MessageID.zod, - messages: z.custom(), - sessionID: SessionID.zod, - auto: z.boolean(), - overflow: z.boolean().optional(), - }), - (input) => runPromise((svc) => svc.process(input)), - ) - export const create = fn( z.object({ sessionID: SessionID.zod, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96252..bbd6693c53ac 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -5,14 +5,13 @@ import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" import { type ProviderMetadata } from "ai" -import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Installation } from "../installation" import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db" import { SyncEvent } from "../sync" import type { SQL } from "../storage/db" -import { SessionTable } from "./session.sql" +import { PartTable, SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" import { Log } from "../util/log" @@ -20,20 +19,17 @@ import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" import { Instance } from "../project/instance" import { InstanceState } from "@/effect/instance-state" -import { SessionPrompt } from "./prompt" import { fn } from "@/util/fn" -import { Command } from "../command" import { Snapshot } from "@/snapshot" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" import { Permission } from "@/permission" import { Global } from "@/global" import type { LanguageModelV2Usage } from "@ai-sdk/provider" -import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Effect, Layer, ServiceMap } from "effect" import { makeRuntime } from "@/effect/run-service" export namespace Session { @@ -322,8 +318,6 @@ export namespace Session { readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect readonly touch: (sessionID: SessionID) => Effect.Effect readonly get: (id: SessionID) => Effect.Effect - readonly share: (id: SessionID) => Effect.Effect<{ url: string }> - readonly unshare: (id: SessionID) => Effect.Effect readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect @@ -345,6 +339,11 @@ export namespace Session { messageID: MessageID partID: PartID }) => Effect.Effect + readonly getPart: (input: { + sessionID: SessionID + messageID: MessageID + partID: PartID + }) => Effect.Effect readonly updatePart: (part: T) => Effect.Effect readonly updatePartDelta: (input: { sessionID: SessionID @@ -353,12 +352,6 @@ export namespace Session { field: string delta: string }) => Effect.Effect - readonly initialize: (input: { - sessionID: SessionID - modelID: ModelID - providerID: ProviderID - messageID: MessageID - }) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/Session") {} @@ -368,12 +361,10 @@ export namespace Session { const db = (fn: (d: Parameters[0] extends (trx: infer D) => any ? D : never) => T) => Effect.sync(() => Database.use(fn)) - export const layer: Layer.Layer = Layer.effect( + export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const bus = yield* Bus.Service - const config = yield* Config.Service - const scope = yield* Scope.Scope const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -403,11 +394,6 @@ export namespace Session { yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result })) - const cfg = yield* config.get() - if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) { - yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) - } - if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) { // This only exist for backwards compatibility. We should not be // manually publishing this event; it is a sync event now @@ -426,25 +412,6 @@ export namespace Session { return fromRow(row) }) - const share = Effect.fn("Session.share")(function* (id: SessionID) { - const cfg = yield* config.get() - if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration") - const result = yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - return ShareNext.create(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } })) - return result - }) - - const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) { - yield* Effect.promise(async () => { - const { ShareNext } = await import("@/share/share-next") - await ShareNext.remove(id) - }) - yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })) - }) - const children = Effect.fn("Session.children")(function* (parentID: SessionID) { const ctx = yield* InstanceState.context const rows = yield* db((d) => @@ -464,7 +431,6 @@ export namespace Session { for (const child of kids) { yield* remove(child.id) } - yield* unshare(sessionID).pipe(Effect.ignore) yield* Effect.sync(() => { SyncEvent.run(Event.Deleted, { sessionID, info: session }) SyncEvent.remove(sessionID) @@ -492,6 +458,29 @@ export namespace Session { return part }).pipe(Effect.withSpan("Session.updatePart")) + const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) { + const row = Database.use((db) => + db + .select() + .from(PartTable) + .where( + and( + eq(PartTable.session_id, input.sessionID), + eq(PartTable.message_id, input.messageID), + eq(PartTable.id, input.partID), + ), + ) + .get(), + ) + if (!row) return + return { + ...row.data, + id: row.id, + sessionID: row.session_id, + messageID: row.message_id, + } as MessageV2.Part + }) + const create = Effect.fn("Session.create")(function* (input?: { parentID?: SessionID title?: string @@ -588,7 +577,7 @@ export namespace Session { const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { return yield* Effect.tryPromise(() => Storage.read(["session_diff", sessionID])).pipe( - Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]), + Effect.orElseSucceed((): Snapshot.FileDiff[] => []), ) }) @@ -637,30 +626,11 @@ export namespace Session { yield* bus.publish(MessageV2.Event.PartDelta, input) }) - const initialize = Effect.fn("Session.initialize")(function* (input: { - sessionID: SessionID - modelID: ModelID - providerID: ProviderID - messageID: MessageID - }) { - yield* Effect.promise(() => - SessionPrompt.command({ - sessionID: input.sessionID, - messageID: input.messageID, - model: input.providerID + "/" + input.modelID, - command: Command.Default.INIT, - arguments: "", - }), - ) - }) - return Service.of({ create, fork, touch, get, - share, - unshare, setTitle, setArchived, setPermission, @@ -675,13 +645,13 @@ export namespace Session { removeMessage, removePart, updatePart, + getPart, updatePartDelta, - initialize, }) }), ) - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) @@ -701,10 +671,7 @@ export namespace Session { runPromise((svc) => svc.fork(input)), ) - export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id))) export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id))) - export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id))) - export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id))) export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) => runPromise((svc) => svc.setTitle(input)), @@ -714,24 +681,12 @@ export namespace Session { runPromise((svc) => svc.setArchived(input)), ) - export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) => - runPromise((svc) => svc.setPermission(input)), - ) - export const setRevert = fn( z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }), (input) => runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })), ) - export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id))) - - export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) => - runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })), - ) - - export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id))) - export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) => runPromise((svc) => svc.messages(input)), ) @@ -879,9 +834,4 @@ export namespace Session { }), (input) => runPromise((svc) => svc.updatePartDelta(input)), ) - - export const initialize = fn( - z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }), - (input) => runPromise((svc) => svc.initialize(input)), - ) } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6387edf03879..78d9d6ccf363 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Effect, Layer, ServiceMap } from "effect" +import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -20,6 +20,7 @@ import { SessionSummary } from "./summary" import type { Provider } from "@/provider/provider" import { Question } from "@/question" import { detectRepetition, REPETITION_THRESHOLD } from "./repetition" +import { errorMessage } from "@/util/error" import { isRecord } from "@/util/record" export namespace SessionProcessor { @@ -39,7 +40,19 @@ export namespace SessionProcessor { export interface Handle { readonly message: MessageV2.Assistant - readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined + readonly updateToolCall: ( + toolCallID: string, + update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + ) => Effect.Effect + readonly completeToolCall: ( + toolCallID: string, + output: { + title: string + metadata: Record + output: string + attachments?: MessageV2.FilePart[] + }, + ) => Effect.Effect readonly process: (streamInput: LLM.StreamInput) => Effect.Effect } @@ -53,8 +66,15 @@ export namespace SessionProcessor { readonly create: (input: Input) => Effect.Effect } + type ToolCall = { + partID: MessageV2.ToolPart["id"] + messageID: MessageV2.ToolPart["messageID"] + sessionID: MessageV2.ToolPart["sessionID"] + done: Deferred.Deferred + } + interface ProcessorContext extends Input { - toolcalls: Record + toolcalls: Record shouldBreak: boolean snapshot: string | undefined blocked: boolean @@ -135,6 +155,88 @@ export namespace SessionProcessor { aborted, }) + const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { + const done = ctx.toolcalls[toolCallID]?.done + delete ctx.toolcalls[toolCallID] + if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore) + }) + + const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { + const call = ctx.toolcalls[toolCallID] + if (!call) return + const part = yield* session.getPart({ + partID: call.partID, + messageID: call.messageID, + sessionID: call.sessionID, + }) + if (!part || part.type !== "tool") { + delete ctx.toolcalls[toolCallID] + return + } + return { call, part } + }) + + const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( + toolCallID: string, + update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, + ) { + const match = yield* readToolCall(toolCallID) + if (!match) return + const part = yield* session.updatePart(update(match.part)) + ctx.toolcalls[toolCallID] = { + ...match.call, + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } + return part + }) + + const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* ( + toolCallID: string, + output: { + title: string + metadata: Record + output: string + attachments?: MessageV2.FilePart[] + }, + ) { + const match = yield* readToolCall(toolCallID) + if (!match || match.part.state.status !== "running") return + yield* session.updatePart({ + ...match.part, + state: { + status: "completed", + input: match.part.state.input, + output: output.output, + metadata: output.metadata, + title: output.title, + time: { start: match.part.state.time.start, end: Date.now() }, + attachments: output.attachments, + }, + }) + yield* settleToolCall(toolCallID) + }) + + const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) { + const match = yield* readToolCall(toolCallID) + if (!match || match.part.state.status !== "running") return false + yield* session.updatePart({ + ...match.part, + state: { + status: "error", + input: match.part.state.input, + error: errorMessage(error), + time: { start: match.part.state.time.start, end: Date.now() }, + }, + }) + if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) { + ctx.blocked = ctx.shouldBreak + } + yield* settleToolCall(toolCallID) + return true + }) + const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) { switch (value.type) { case "start": @@ -184,8 +286,8 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - ctx.toolcalls[value.id] = yield* session.updatePart({ - id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(), + const part = yield* session.updatePart({ + id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, type: "tool", @@ -194,6 +296,12 @@ export namespace SessionProcessor { state: { status: "pending", input: {}, raw: "" }, metadata: value.providerExecuted ? { providerExecuted: true } : undefined, } satisfies MessageV2.ToolPart) + ctx.toolcalls[value.id] = { + done: yield* Deferred.make(), + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } return case "tool-input-delta": @@ -206,16 +314,19 @@ export namespace SessionProcessor { if (ctx.assistantMessage.summary) { throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) } - const match = ctx.toolcalls[value.toolCallId] - if (!match) return - ctx.toolcalls[value.toolCallId] = yield* session.updatePart({ + yield* updateToolCall(value.toolCallId, (match) => ({ ...match, tool: value.toolName, - state: { status: "running", input: value.input, time: { start: Date.now() } }, + state: { + ...match.state, + status: "running", + input: value.input, + time: { start: Date.now() }, + }, metadata: match.metadata?.providerExecuted ? { ...value.providerMetadata, providerExecuted: true } : value.providerMetadata, - } satisfies MessageV2.ToolPart) + })) // Fire-and-forget: track bash commands for memory extraction if (value.toolName === "bash" && value.input && typeof value.input === "object" && "command" in value.input) { @@ -251,47 +362,19 @@ export namespace SessionProcessor { } case "tool-result": { - const match = ctx.toolcalls[value.toolCallId] - if (!match || match.state.status !== "running") return - yield* session.updatePart({ - ...match, - state: { - status: "completed", - input: value.input ?? match.state.input, - output: value.output.output, - metadata: value.output.metadata, - title: value.output.title, - time: { start: match.state.time.start, end: Date.now() }, - attachments: value.output.attachments, - }, - }) + yield* completeToolCall(value.toolCallId, value.output) // Fire-and-forget: track successful tool results as potential fixes if (value.output.output) { memoryExtract(() => MemoryExtractor.trackFix(ctx.sessionID, String(value.output.output))) } - delete ctx.toolcalls[value.toolCallId] return } case "tool-error": { - const match = ctx.toolcalls[value.toolCallId] - if (!match || match.state.status !== "running") return - yield* session.updatePart({ - ...match, - state: { - status: "error", - input: value.input ?? match.state.input, - error: value.error instanceof Error ? value.error.message : String(value.error), - time: { start: match.state.time.start, end: Date.now() }, - }, - }) - if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) { - ctx.blocked = ctx.shouldBreak - } + yield* failToolCall(value.toolCallId, value.error) // Fire-and-forget: track tool errors for memory extraction const errorMsg = value.error instanceof Error ? value.error.message : String(value.error) memoryExtract(() => MemoryExtractor.trackError(ctx.sessionID, errorMsg)) - delete ctx.toolcalls[value.toolCallId] return } @@ -447,7 +530,16 @@ export namespace SessionProcessor { } ctx.reasoningMap = {} - for (const part of Object.values(ctx.toolcalls)) { + yield* Effect.forEach( + Object.values(ctx.toolcalls), + (call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore), + { concurrency: "unbounded" }, + ) + + for (const toolCallID of Object.keys(ctx.toolcalls)) { + const match = yield* readToolCall(toolCallID) + if (!match) continue + const part = match.part const end = Date.now() const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {} yield* session.updatePart({ @@ -539,9 +631,8 @@ export namespace SessionProcessor { get message() { return ctx.assistantMessage }, - partFromToolCall(toolCallID: string) { - return ctx.toolcalls[toolCallID] - }, + updateToolCall, + completeToolCall, process, } satisfies Handle }) @@ -550,19 +641,17 @@ export namespace SessionProcessor { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))), - Layer.provide(Bus.layer), - Layer.provide(Config.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(LLM.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), ), ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index caa8fdeace7e..9eaee951657c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -22,7 +22,6 @@ import PROMPT_PLAN from "../session/prompt/plan.txt" import BUILD_SWITCH from "../session/prompt/build-switch.txt" import MAX_STEPS from "../session/prompt/max-steps.txt" import { ToolRegistry } from "../tool/registry" -import { Runner } from "@/effect/runner" import { MCP } from "../mcp" import { LSP } from "../lsp" import { FileTime } from "../file/time" @@ -52,6 +51,7 @@ import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { TaskTool } from "@/tool/task" +import { SessionRunState } from "./run-state" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -70,7 +70,6 @@ export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export interface Interface { - readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: z.infer) => Effect.Effect @@ -104,55 +103,12 @@ export namespace SessionPrompt { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service - - const state = yield* InstanceState.make( - Effect.fn("SessionPrompt.state")(function* () { - const runners = new Map>() - yield* Effect.addFinalizer( - Effect.fnUntraced(function* () { - yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true }) - runners.clear() - }), - ) - return { runners } - }), - ) - - const getRunner = (runners: Map>, sessionID: SessionID) => { - const existing = runners.get(sessionID) - if (existing) return existing - const runner = Runner.make(scope, { - onIdle: Effect.gen(function* () { - runners.delete(sessionID) - yield* status.set(sessionID, { type: "idle" }) - }), - onBusy: status.set(sessionID, { type: "busy" }), - onInterrupt: latestAssistant(sessionID), - busy: () => { - throw new Session.BusyError(sessionID) - }, - }) - runners.set(sessionID, runner) - return runner - } - - const assertNotBusy: (sessionID: SessionID) => Effect.Effect = Effect.fn( - "SessionPrompt.assertNotBusy", - )(function* (sessionID: SessionID) { - const s = yield* InstanceState.get(state) - const runner = s.runners.get(sessionID) - if (runner?.busy) throw new Session.BusyError(sessionID) - }) + const state = yield* SessionRunState.Service + const revert = yield* SessionRevert.Service const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) { log.info("cancel", { sessionID }) - const s = yield* InstanceState.get(state) - const runner = s.runners.get(sessionID) - if (!runner || !runner.busy) { - yield* status.set(sessionID, { type: "idle" }) - return - } - yield* runner.cancel + yield* state.cancel(sessionID) }) const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) { @@ -403,7 +359,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the model: Provider.Model session: Session.Info tools?: Record - processor: Pick + processor: Pick bypassAgentCheck: boolean messages: MessageV2.WithParts[] }) { @@ -420,10 +376,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the messages: input.messages, metadata: (val) => Effect.runPromise( - Effect.gen(function* () { - const match = input.processor.partFromToolCall(options.toolCallId) - if (!match || !["running", "pending"].includes(match.state.status)) return - yield* sessions.updatePart({ + input.processor.updateToolCall(options.toolCallId, (match) => { + if (!["running", "pending"].includes(match.state.status)) return match + return { ...match, state: { title: val.title, @@ -432,7 +387,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the input: args, time: { start: Date.now() }, }, - }) + } }), ), ask: (req) => @@ -527,7 +482,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the finalOutput = `${finalOutput}\n\n\n${postHookResult.message}\n` } - return { ...output, output: finalOutput } + const merged = { ...output, output: finalOutput } + if (options.abortSignal?.aborted) { + yield* input.processor.completeToolCall(options.toolCallId, merged) + } + return merged }), ) }, @@ -638,7 +597,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mcpOutput = `${mcpOutput}\n\n\n${mcpPostResult.message}\n` } - return { + const output = { title: "", metadata, output: mcpOutput, @@ -650,6 +609,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the })), content: result.content, } + if (opts.abortSignal?.aborted) { + yield* input.processor.completeToolCall(opts.toolCallId, output) + } + return output }), ) tools[key] = item @@ -852,11 +815,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the } satisfies MessageV2.TextPart) }) - const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) { + const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) { const ctx = yield* InstanceState.context const session = yield* sessions.get(input.sessionID) if (session.revert) { - yield* Effect.promise(() => SessionRevert.cleanup(session)) + yield* revert.cleanup(session) } const agent = yield* agents.get(input.agent) if (!agent) { @@ -1417,7 +1380,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID) - yield* Effect.promise(() => SessionRevert.cleanup(session)) + yield* revert.cleanup(session) const message = yield* createUserMessage(input) yield* sessions.touch(input.sessionID) @@ -1690,16 +1653,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the const loop: (input: z.infer) => Effect.Effect = Effect.fn( "SessionPrompt.loop", )(function* (input: z.infer) { - const s = yield* InstanceState.get(state) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.ensureRunning(runLoop(input.sessionID)) + return yield* state.ensureRunning(input.sessionID, latestAssistant(input.sessionID), runLoop(input.sessionID)) }) const shell: (input: ShellInput) => Effect.Effect = Effect.fn("SessionPrompt.shell")( function* (input: ShellInput) { - const s = yield* InstanceState.get(state) - const runner = getRunner(s.runners, input.sessionID) - return yield* runner.startShell((signal) => shellImpl(input, signal)) + return yield* state.startShell(input.sessionID, latestAssistant(input.sessionID), shellImpl(input)) }, ) @@ -1820,7 +1779,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) return Service.of({ - assertNotBusy, cancel, prompt, loop, @@ -1831,37 +1789,31 @@ NOTE: At any point in time through this workflow you should feel free to ask the }), ) - const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(SessionStatus.layer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Config.defaultLayer), - ), + const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(SessionStatus.defaultLayer), + Layer.provide(SessionCompaction.defaultLayer), + Layer.provide(SessionProcessor.defaultLayer), + Layer.provide(Command.defaultLayer), + Layer.provide(Permission.defaultLayer), + Layer.provide(MCP.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(ToolRegistry.defaultLayer), + Layer.provide(Truncate.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(Layer.merge(Bus.layer, Layer.merge(CrossSpawnSpawner.defaultLayer, Config.defaultLayer))), ), ) const { runPromise } = makeRuntime(Service, defaultLayer) - export async function assertNotBusy(sessionID: SessionID) { - return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID))) - } - export const PromptInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional(), diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 9df3f36eb8c7..1216362ca104 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -9,8 +9,9 @@ import { Log } from "../util/log" import { Session } from "." import { MessageV2 } from "./message-v2" import { SessionID, MessageID, PartID } from "./schema" -import { SessionPrompt } from "./prompt" +import { SessionRunState } from "./run-state" import { SessionSummary } from "./summary" +import { SessionStatus } from "./status" export namespace SessionRevert { const log = Log.create({ service: "session.revert" }) @@ -38,9 +39,10 @@ export namespace SessionRevert { const storage = yield* Storage.Service const bus = yield* Bus.Service const summary = yield* SessionSummary.Service + const state = yield* SessionRunState.Service const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) { - yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) + yield* state.assertNotBusy(input.sessionID) const all = yield* sessions.messages({ sessionID: input.sessionID }) let lastUser: MessageV2.User | undefined const session = yield* sessions.get(input.sessionID) @@ -93,7 +95,7 @@ export namespace SessionRevert { const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) { log.info("unreverting", input) - yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID)) + yield* state.assertNotBusy(input.sessionID) const session = yield* sessions.get(input.sessionID) if (!session.revert) return session if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!) @@ -148,15 +150,14 @@ export namespace SessionRevert { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - Layer.provide(SessionSummary.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), + Layer.provide(SessionSummary.defaultLayer), ), ) diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts new file mode 100644 index 000000000000..3c2022bd00bf --- /dev/null +++ b/packages/opencode/src/session/run-state.ts @@ -0,0 +1,114 @@ +import { InstanceState } from "@/effect/instance-state" +import { Runner } from "@/effect/runner" +import { makeRuntime } from "@/effect/run-service" +import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Session } from "." +import { MessageV2 } from "./message-v2" +import { SessionID } from "./schema" +import { SessionStatus } from "./status" + +export namespace SessionRunState { + export interface Interface { + readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect + readonly cancel: (sessionID: SessionID) => Effect.Effect + readonly ensureRunning: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect + readonly startShell: ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/SessionRunState") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const status = yield* SessionStatus.Service + + const state = yield* InstanceState.make( + Effect.fn("SessionRunState.state")(function* () { + const scope = yield* Scope.Scope + const runners = new Map>() + yield* Effect.addFinalizer( + Effect.fnUntraced(function* () { + yield* Effect.forEach(runners.values(), (runner) => runner.cancel, { + concurrency: "unbounded", + discard: true, + }) + runners.clear() + }), + ) + return { runners, scope } + }), + ) + + const runner = Effect.fn("SessionRunState.runner")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + ) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (existing) return existing + const next = Runner.make(data.scope, { + onIdle: Effect.gen(function* () { + data.runners.delete(sessionID) + yield* status.set(sessionID, { type: "idle" }) + }), + onBusy: status.set(sessionID, { type: "busy" }), + onInterrupt, + busy: () => { + throw new Session.BusyError(sessionID) + }, + }) + data.runners.set(sessionID, next) + return next + }) + + const assertNotBusy = Effect.fn("SessionRunState.assertNotBusy")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (existing?.busy) throw new Session.BusyError(sessionID) + }) + + const cancel = Effect.fn("SessionRunState.cancel")(function* (sessionID: SessionID) { + const data = yield* InstanceState.get(state) + const existing = data.runners.get(sessionID) + if (!existing || !existing.busy) { + yield* status.set(sessionID, { type: "idle" }) + return + } + yield* existing.cancel + }) + + const ensureRunning = Effect.fn("SessionRunState.ensureRunning")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) { + return yield* (yield* runner(sessionID, onInterrupt)).ensureRunning(work) + }) + + const startShell = Effect.fn("SessionRunState.startShell")(function* ( + sessionID: SessionID, + onInterrupt: Effect.Effect, + work: Effect.Effect, + ) { + return yield* (yield* runner(sessionID, onInterrupt)).startShell(work) + }) + + return Service.of({ assertNotBusy, cancel, ensureRunning, startShell }) + }), + ) + + export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer)) + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function assertNotBusy(sessionID: SessionID) { + return runPromise((svc) => svc.assertNotBusy(sessionID)) + } +} diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 34a79eed112c..16fccaf3e831 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -85,7 +85,7 @@ export namespace SessionStatus { }), ) - const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) + export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) export async function get(sessionID: SessionID) { diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index f2b53f3baf58..2f07a0f5d01f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -150,14 +150,12 @@ export namespace SessionSummary { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Bus.layer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Session.defaultLayer), + Layer.provide(Snapshot.defaultLayer), + Layer.provide(Storage.defaultLayer), + Layer.provide(Bus.layer), ), ) diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index 2d85ad224f1b..4adfc7bc5fb3 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -85,10 +85,6 @@ export namespace Todo { export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) const { runPromise } = makeRuntime(Service, defaultLayer) - export async function update(input: { sessionID: SessionID; todos: Info[] }) { - return runPromise((svc) => svc.update(input)) - } - export async function get(sessionID: SessionID) { return runPromise((svc) => svc.get(sessionID)) } diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts new file mode 100644 index 000000000000..1446b5bb4def --- /dev/null +++ b/packages/opencode/src/share/session.ts @@ -0,0 +1,67 @@ +import { makeRuntime } from "@/effect/run-service" +import { Session } from "@/session" +import { SessionID } from "@/session/schema" +import { SyncEvent } from "@/sync" +import { fn } from "@/util/fn" +import { Effect, Layer, Scope, ServiceMap } from "effect" +import { Config } from "../config/config" +import { Flag } from "../flag/flag" +import { ShareNext } from "./share-next" + +export namespace SessionShare { + export interface Interface { + readonly create: (input?: Parameters[0]) => Effect.Effect + readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown> + readonly unshare: (sessionID: SessionID) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/SessionShare") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const cfg = yield* Config.Service + const session = yield* Session.Service + const shareNext = yield* ShareNext.Service + const scope = yield* Scope.Scope + + const share = Effect.fn("SessionShare.share")(function* (sessionID: SessionID) { + const conf = yield* cfg.get() + if (conf.share === "disabled") throw new Error("Sharing is disabled in configuration") + const result = yield* shareNext.create(sessionID) + yield* Effect.sync(() => + SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: result.url } } }), + ) + return result + }) + + const unshare = Effect.fn("SessionShare.unshare")(function* (sessionID: SessionID) { + yield* shareNext.remove(sessionID) + yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } })) + }) + + const create = Effect.fn("SessionShare.create")(function* (input?: Parameters[0]) { + const result = yield* session.create(input) + if (result.parentID) return result + const conf = yield* cfg.get() + if (!(Flag.OPENCODE_AUTO_SHARE || conf.share === "auto")) return result + yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope)) + return result + }) + + return Service.of({ create, share, unshare }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(ShareNext.defaultLayer), + Layer.provide(Session.defaultLayer), + Layer.provide(Config.defaultLayer), + ) + + const { runPromise } = makeRuntime(Service, defaultLayer) + + export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input))) + export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID))) + export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID))) +} diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 0cd0055c85d2..26b2d2570a32 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -159,7 +159,10 @@ export namespace ShareNext { if (disabled) return cache - const watch = (def: D, fn: (evt: { properties: any }) => Effect.Effect) => + const watch = ( + def: D, + fn: (evt: { properties: any }) => Effect.Effect, + ) => bus.subscribe(def as never).pipe( Stream.runForEach((evt) => fn(evt).pipe( @@ -194,6 +197,7 @@ export namespace ShareNext { yield* watch(Session.Event.Diff, (evt) => sync(evt.properties.sessionID, [{ type: "session_diff", data: evt.properties.diff }]), ) + yield* watch(Session.Event.Deleted, (evt) => remove(evt.properties.sessionID)) return cache }), diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 4cb0dbc3e184..78320ac0af1d 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -29,7 +29,7 @@ const log = Log.create({ service: "db" }) export namespace Database { export function getChannelPath() { - if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) + if (["latest", "beta", "prod"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB) return path.join(Global.Path.data, "opencode.db") const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-") return path.join(Global.Path.data, `opencode-${safe}.db`) diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index 0d0dce7264cf..c30089a18c9a 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -11,7 +11,11 @@ import { Git } from "@/git" export namespace Storage { const log = Log.create({ service: "storage" }) - type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect + type Migration = ( + dir: string, + fs: AppFileSystem.Interface, + git: Git.Interface, + ) => Effect.Effect export const NotFoundError = NamedError.create( "NotFoundError", @@ -83,7 +87,7 @@ export namespace Storage { } const MIGRATIONS: Migration[] = [ - Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) { + Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface, git: Git.Interface) { const project = path.resolve(dir, "../project") if (!(yield* fs.isDir(project))) return const projectDirs = yield* fs.glob("*", { @@ -110,11 +114,9 @@ export namespace Storage { } if (!worktree) continue if (!(yield* fs.isDir(worktree))) continue - const result = yield* Effect.promise(() => - Git.run(["rev-list", "--max-parents=0", "--all"], { - cwd: worktree, - }), - ) + const result = yield* git.run(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }) const [id] = result .text() .split("\n") @@ -220,6 +222,7 @@ export namespace Storage { Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service + const git = yield* Git.Service const locks = yield* RcMap.make({ lookup: () => TxReentrantLock.make(), idleTimeToLive: 0, @@ -236,7 +239,7 @@ export namespace Storage { for (let i = migration; i < MIGRATIONS.length; i++) { log.info("running migration", { index: i }) const step = MIGRATIONS[i]! - const exit = yield* Effect.exit(step(dir, fs)) + const exit = yield* Effect.exit(step(dir, fs, git)) if (Exit.isFailure(exit)) { log.error("failed to run migration", { index: i, cause: exit.cause }) break @@ -327,7 +330,7 @@ export namespace Storage { }), ) - export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) + export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 800c45ced0c0..9c0771b8df78 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -5,12 +5,12 @@ import { EditTool } from "./edit" import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { ReadTool } from "./read" -import { TaskDescription, TaskTool } from "./task" +import { TaskTool } from "./task" import { TodoWriteTool } from "./todo" import { WebFetchTool } from "./webfetch" import { WriteTool } from "./write" import { InvalidTool } from "./invalid" -import { SkillDescription, SkillTool } from "./skill" +import { SkillTool } from "./skill" import { Tool } from "./tool" import { Config } from "../config/config" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" @@ -38,6 +38,8 @@ import { FileTime } from "../file/time" import { Instruction } from "../session/instruction" import { AppFileSystem } from "../filesystem" import { Agent } from "../agent/agent" +import { Skill } from "../skill" +import { Permission } from "@/permission" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -73,6 +75,7 @@ export namespace ToolRegistry { | Question.Service | Todo.Service | Agent.Service + | Skill.Service | LSP.Service | FileTime.Service | Instruction.Service @@ -82,6 +85,8 @@ export namespace ToolRegistry { Effect.gen(function* () { const config = yield* Config.Service const plugin = yield* Plugin.Service + const agents = yield* Agent.Service + const skill = yield* Skill.Service const task = yield* TaskTool const read = yield* ReadTool @@ -199,6 +204,40 @@ export namespace ToolRegistry { return (yield* all()).map((tool) => tool.id) }) + const describeSkill = Effect.fn("ToolRegistry.describeSkill")(function* (agent: Agent.Info) { + const list = yield* skill.available(agent) + if (list.length === 0) return "No skills are currently available." + return [ + "Load a specialized skill that provides domain-specific instructions and workflows.", + "", + "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", + "", + "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", + "", + 'Tool output includes a `` block with the loaded content.', + "", + "The following skills provide specialized sets of instructions for particular tasks", + "Invoke this tool to load a skill when a task matches one of the available skills listed below:", + "", + Skill.fmt(list, { verbose: false }), + ].join("\n") + }) + + const describeTask = Effect.fn("ToolRegistry.describeTask")(function* (agent: Agent.Info) { + const items = (yield* agents.list()).filter((item) => item.mode !== "primary") + const filtered = items.filter( + (item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny", + ) + const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) + const description = list + .map( + (item) => + `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, + ) + .join("\n") + return ["Available agent types and the tools they have access to:", description].join("\n") + }) + const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) { const filtered = (yield* all()).filter((tool) => { if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) { @@ -227,8 +266,8 @@ export namespace ToolRegistry { id: tool.id, description: [ output.description, - tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, - tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, + tool.id === TaskTool.id ? yield* describeTask(input.agent) : undefined, + tool.id === SkillTool.id ? yield* describeSkill(input.agent) : undefined, ] .filter(Boolean) .join("\n"), @@ -250,19 +289,18 @@ export namespace ToolRegistry { }), ) - export const defaultLayer = Layer.unwrap( - Effect.sync(() => - layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(FileTime.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - ), + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(Question.defaultLayer), + Layer.provide(Todo.defaultLayer), + Layer.provide(Skill.defaultLayer), + Layer.provide(Agent.defaultLayer), + Layer.provide(LSP.defaultLayer), + Layer.provide(FileTime.defaultLayer), + Layer.provide(Instruction.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), ), ) diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts index 276f3931d012..e0777d00f74a 100644 --- a/packages/opencode/src/tool/skill.ts +++ b/packages/opencode/src/tool/skill.ts @@ -1,4 +1,3 @@ -import { Effect } from "effect" import path from "path" import { pathToFileURL } from "url" import z from "zod" @@ -98,23 +97,3 @@ export const SkillTool = Tool.define("skill", async () => { }, } }) - -export const SkillDescription: Tool.DynamicDescription = (agent) => - Effect.gen(function* () { - const list = yield* Effect.promise(() => Skill.available(agent)) - if (list.length === 0) return "No skills are currently available." - return [ - "Load a specialized skill that provides domain-specific instructions and workflows.", - "", - "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.", - "", - "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.", - "", - 'Tool output includes a `` block with the loaded content.', - "", - "The following skills provide specialized sets of instructions for particular tasks", - "Invoke this tool to load a skill when a task matches one of the available skills listed below:", - "", - Skill.fmt(list, { verbose: false }), - ].join("\n") - }) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 73b55a2fbad3..900938f0d3a4 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -7,8 +7,8 @@ import { MessageV2 } from "../session/message-v2" import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { Config } from "../config/config" -import { Permission } from "@/permission" import { Effect } from "effect" +import { Log } from "@/util/log" const id = "task" @@ -175,18 +175,3 @@ export const TaskTool = Tool.defineEffect( } }), ) - -export const TaskDescription: Tool.DynamicDescription = (agent) => - Effect.gen(function* () { - const items = yield* Effect.promise(() => - Agent.list().then((items) => items.filter((item) => item.mode !== "primary")), - ) - const filtered = items.filter((item) => Permission.evaluate(id, item.name, agent.permission).action !== "deny") - const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name)) - const description = list - .map( - (item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`, - ) - .join("\n") - return ["Available agent types and the tools they have access to:", description].join("\n") - }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b34364ccd871..54986d65cd57 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -171,7 +171,7 @@ export namespace Worktree { export const layer: Layer.Layer< Service, never, - AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service + AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Git.Service | Project.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -179,6 +179,7 @@ export namespace Worktree { const fs = yield* AppFileSystem.Service const pathSvc = yield* Path.Path const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const gitSvc = yield* Git.Service const project = yield* Project.Service const git = Effect.fnUntraced( @@ -516,7 +517,7 @@ export namespace Worktree { const worktreePath = entry.path - const base = yield* Effect.promise(() => Git.defaultBranch(Instance.worktree)) + const base = yield* gitSvc.defaultBranch(Instance.worktree) if (!base) { throw new ResetFailedError({ message: "Default branch not found" }) } @@ -583,6 +584,7 @@ export namespace Worktree { ) const defaultLayer = layer.pipe( + Layer.provide(Git.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(Project.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), diff --git a/packages/opencode/test/effect/runner.test.ts b/packages/opencode/test/effect/runner.test.ts index 9dc395876ee0..a91df76ebf27 100644 --- a/packages/opencode/test/effect/runner.test.ts +++ b/packages/opencode/test/effect/runner.test.ts @@ -250,7 +250,7 @@ describe("Runner", () => { Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) - const result = yield* runner.startShell((_signal) => Effect.succeed("shell-done")) + const result = yield* runner.startShell(Effect.succeed("shell-done")) expect(result).toBe("shell-done") expect(runner.busy).toBe(false) }), @@ -264,7 +264,7 @@ describe("Runner", () => { const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("x"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("nope")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("nope")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel @@ -279,12 +279,10 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("first"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("first"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* Deferred.succeed(gate, undefined) @@ -302,37 +300,26 @@ describe("Runner", () => { }, }) - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") - const exit = yield* runner.startShell((_s) => Effect.succeed("second")).pipe(Effect.exit) + const exit = yield* runner.startShell(Effect.succeed("second")).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) yield* runner.cancel const done = yield* Fiber.await(sh) - expect(Exit.isSuccess(done)).toBe(true) + expect(Exit.isFailure(done)).toBe(true) }), ) it.live( - "cancel interrupts shell that ignores abort signal", + "cancel interrupts shell", Effect.gen(function* () { const s = yield* Scope.Scope const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ignored"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ignored"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const stop = yield* runner.cancel.pipe(Effect.forkChild) @@ -356,9 +343,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell-result"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell-result"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.state._tag).toBe("Shell") @@ -384,9 +369,7 @@ describe("Runner", () => { const calls = yield* Ref.make(0) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("shell"))) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("shell"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const work = Effect.gen(function* () { @@ -414,16 +397,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const sh = yield* runner - .startShell((signal) => - Effect.promise( - () => - new Promise((resolve) => { - signal.addEventListener("abort", () => resolve("aborted"), { once: true }) - }), - ), - ) - .pipe(Effect.forkChild) + const sh = yield* runner.startShell(Effect.never.pipe(Effect.as("aborted"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") const run = yield* runner.ensureRunning(Effect.succeed("y")).pipe(Effect.forkChild) @@ -478,7 +452,7 @@ describe("Runner", () => { const runner = Runner.make(s, { onBusy: Ref.update(count, (n) => n + 1), }) - yield* runner.startShell((_signal) => Effect.succeed("done")) + yield* runner.startShell(Effect.succeed("done")) expect(yield* Ref.get(count)).toBe(1) }), ) @@ -509,9 +483,7 @@ describe("Runner", () => { const runner = Runner.make(s) const gate = yield* Deferred.make() - const fiber = yield* runner - .startShell((_signal) => Deferred.await(gate).pipe(Effect.as("ok"))) - .pipe(Effect.forkChild) + const fiber = yield* runner.startShell(Deferred.await(gate).pipe(Effect.as("ok"))).pipe(Effect.forkChild) yield* Effect.sleep("10 millis") expect(runner.busy).toBe(true) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2224a80e680e..0c8968d94b05 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from "../fixture/fixture" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { FileWatcher } from "../../src/file/watcher" +import { Git } from "../../src/git" import { Instance } from "../../src/project/instance" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) @@ -32,6 +33,7 @@ function withWatcher(directory: string, body: Effect.Effect) { fn: async () => { const layer: Layer.Layer = FileWatcher.layer.pipe( Layer.provide(Config.defaultLayer), + Layer.provide(Git.defaultLayer), Layer.provide(watcherConfigLayer), ) const rt = ManagedRuntime.make(layer) diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index cfab72d83471..7e514e39b159 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,8 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test" import path from "path" -import fs from "fs/promises" import * as Lsp from "../../src/lsp/index" -import * as launch from "../../src/lsp/launch" import { LSPServer } from "../../src/lsp/server" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" @@ -54,80 +52,4 @@ describe("lsp.spawn", () => { await Instance.disposeAll() } }) - - test("spawns builtin Typescript LSP with correct arguments", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--tsserver-path") - expect(args).toContain("--tsserver-log-verbosity") - expect(args).toContain("off") - } finally { - spawnSpy.mockRestore() - } - }) - - test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - // NO tsconfig.json or jsconfig.json created here - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - - try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--ignore-node-modules") - } finally { - spawnSpy.mockRestore() - } - }) }) diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts new file mode 100644 index 000000000000..58a4fa8c86cc --- /dev/null +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -0,0 +1,34 @@ +import { test, expect, describe, afterEach } from "bun:test" +import { McpOAuthCallback } from "../../src/mcp/oauth-callback" +import { parseRedirectUri } from "../../src/mcp/oauth-provider" + +describe("parseRedirectUri", () => { + test("returns defaults when no URI provided", () => { + const result = parseRedirectUri() + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) + + test("parses port and path from URI", () => { + const result = parseRedirectUri("http://127.0.0.1:8080/oauth/callback") + expect(result.port).toBe(8080) + expect(result.path).toBe("/oauth/callback") + }) + + test("returns defaults for invalid URI", () => { + const result = parseRedirectUri("not-a-valid-url") + expect(result.port).toBe(19876) + expect(result.path).toBe("/mcp/oauth/callback") + }) +}) + +describe("McpOAuthCallback.ensureRunning", () => { + afterEach(async () => { + await McpOAuthCallback.stop() + }) + + test("starts server with custom redirectUri port and path", async () => { + await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") + expect(McpOAuthCallback.isRunning()).toBe(true) + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 88e9ea64c9de..9cadc391a1f1 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "../fixture/fixture" import { Global } from "../../src/global" import { Instance } from "../../src/project/instance" import { Plugin } from "../../src/plugin/index" +import { ModelsDev } from "../../src/provider/models" import { Provider } from "../../src/provider/provider" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" @@ -1823,6 +1824,73 @@ test("custom model inherits api.url from models.dev provider", async () => { }) }) +test("mode cost preserves over-200k pricing from base model", () => { + const provider = { + id: "openai", + name: "OpenAI", + env: [], + api: "https://api.openai.com/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-03-05", + attachment: true, + reasoning: true, + temperature: false, + tool_call: true, + cost: { + input: 2.5, + output: 15, + cache_read: 0.25, + context_over_200k: { + input: 5, + output: 22.5, + cache_read: 0.5, + }, + }, + limit: { + context: 1_050_000, + input: 922_000, + output: 128_000, + }, + experimental: { + modes: { + fast: { + cost: { + input: 5, + output: 30, + cache_read: 0.5, + }, + provider: { + body: { + service_tier: "priority", + }, + }, + }, + }, + }, + }, + }, + } as ModelsDev.Provider + + const model = Provider.fromModelsDevProvider(provider).models["gpt-5.4-fast"] + expect(model.cost.input).toEqual(5) + expect(model.cost.output).toEqual(30) + expect(model.cost.cache.read).toEqual(0.5) + expect(model.cost.cache.write).toEqual(0) + expect(model.options["serviceTier"]).toEqual("priority") + expect(model.cost.experimentalOver200K).toEqual({ + input: 5, + output: 22.5, + cache: { + read: 0.5, + write: 0, + }, + }) +}) + test("model variants are generated for reasoning models", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/scenario/harness.ts b/packages/opencode/test/scenario/harness.ts index 5f8fa87745a1..99e038be221a 100644 --- a/packages/opencode/test/scenario/harness.ts +++ b/packages/opencode/test/scenario/harness.ts @@ -23,7 +23,10 @@ import { Instruction } from "../../src/session/instruction" import { LLM } from "../../src/session/llm" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" +import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -131,6 +134,9 @@ function make() { Layer.provideMerge(reg), Layer.provideMerge(trunc), Layer.provide(Instruction.defaultLayer), + Layer.provide(SessionRunState.defaultLayer), + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Skill.defaultLayer), Layer.provideMerge(deps), ), ) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index 004c2900a208..4ab485965ea2 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -5,6 +5,7 @@ import { Session } from "../../src/session" import { ModelID, ProviderID } from "../../src/provider/schema" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRunState } from "../../src/session/run-state" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -64,7 +65,7 @@ describe("session action routes", () => { fn: async () => { const session = await Session.create({}) const msg = await user(session.id, "hello") - const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id)) + const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id)) const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id) const app = Server.Default().app diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c37371d9f871..76a83c34da00 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -139,17 +139,8 @@ function fake( get message() { return msg }, - partFromToolCall() { - return { - id: PartID.ascending(), - messageID: msg.id, - sessionID: msg.sessionID, - type: "tool", - callID: "fake", - tool: "fake", - state: { status: "pending", input: {}, raw: "" }, - } - }, + updateToolCall: Effect.fn("TestSessionProcessor.updateToolCall")(() => Effect.succeed(undefined)), + completeToolCall: Effect.fn("TestSessionProcessor.completeToolCall")(() => Effect.void), process: Effect.fn("TestSessionProcessor.process")(() => Effect.succeed(result)), } satisfies SessionProcessorModule.SessionProcessor.Handle } diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 5e45ebf83263..f7e52f1721c1 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -25,8 +25,11 @@ import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +import { SessionRunState } from "../../src/session/run-state" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" +import { Skill } from "../../src/skill" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "../../src/tool/registry" @@ -143,6 +146,7 @@ const filetime = Layer.succeed( ) const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) function makeHttp() { const deps = Layer.mergeAll( @@ -164,6 +168,7 @@ function makeHttp() { const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), @@ -174,6 +179,8 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), @@ -328,9 +335,10 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service const chat = yield* sessions.create(input ?? { title: "Pinned" }) - return { prompt, sessions, chat } + return { prompt, run, sessions, chat } }) // Loop semantics @@ -614,6 +622,93 @@ it.live("failed subtask preserves metadata on error tool state", () => ), ) +it.live( + "running subtask preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running subtask metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBeDefined() + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + +it.live( + "running task tool preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "build") + const tool = assistant?.parts.find( + (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", + ) + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running task metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBe("inspect bug") + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + it.live( "loop sets status to busy then idle", () => @@ -789,7 +884,7 @@ it.live("concurrent loop callers get same result", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() yield* seed(chat.id, { finish: "stop" }) const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { @@ -798,7 +893,7 @@ it.live("concurrent loop callers get same result", () => expect(a.info.id).toBe(b.info.id) expect(a.info.role).toBe("assistant") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true }, ), @@ -911,6 +1006,7 @@ it.live( provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service yield* llm.hang @@ -920,7 +1016,7 @@ it.live( const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) yield* llm.wait(1) - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) @@ -938,11 +1034,11 @@ it.live("assertNotBusy succeeds when idle", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service const sessions = yield* Session.Service const chat = yield* sessions.create({}) - const exit = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isSuccess(exit)).toBe(true) }), { git: true }, @@ -983,7 +1079,7 @@ unix("shell captures stdout and stderr in completed tool output", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -998,7 +1094,7 @@ unix("shell captures stdout and stderr in completed tool output", () => expect(tool.state.output).toContain("err") expect(tool.state.metadata.output).toContain("out") expect(tool.state.metadata.output).toContain("err") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1008,7 +1104,7 @@ unix("shell completes a fast command on the preferred shell", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -1022,7 +1118,7 @@ unix("shell completes a fast command on the preferred shell", () => expect(tool.state.input.command).toBe("pwd") expect(tool.state.output).toContain(dir) expect(tool.state.metadata.output).toContain(dir) - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1032,7 +1128,7 @@ unix("shell lists files from the project directory", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) const result = yield* prompt.shell({ @@ -1048,7 +1144,7 @@ unix("shell lists files from the project directory", () => expect(tool.state.input.command).toBe("command ls") expect(tool.state.output).toContain("README.md") expect(tool.state.metadata.output).toContain("README.md") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1058,7 +1154,7 @@ unix("shell captures stderr from a failing command", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const result = yield* prompt.shell({ sessionID: chat.id, agent: "build", @@ -1071,7 +1167,7 @@ unix("shell captures stderr from a failing command", () => expect(tool.state.output).toContain("not found") expect(tool.state.metadata.output).toContain("not found") - yield* prompt.assertNotBusy(chat.id) + yield* run.assertNotBusy(chat.id) }), { git: true, config: cfg }, ), @@ -1196,7 +1292,7 @@ unix( provideTmpdirInstance( (dir) => Effect.gen(function* () { - const { prompt, chat } = yield* boot() + const { prompt, run, chat } = yield* boot() const sh = yield* prompt .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) @@ -1207,7 +1303,7 @@ unix( const status = yield* SessionStatus.Service expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* prompt.assertNotBusy(chat.id).pipe(Effect.exit) + const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) expect(Exit.isSuccess(busy)).toBe(true) const exit = yield* Fiber.await(sh) @@ -1258,6 +1354,57 @@ unix( 30_000, ) +unix( + "cancel finalizes interrupted bash tool output through normal truncation", + () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Interrupted bash truncation", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "run bash" }], + }) + + yield* llm.tool("bash", { + command: + 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', + description: "Print many lines", + timeout: 30_000, + workdir: path.resolve(dir), + }) + + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* Effect.sleep(150) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isFailure(exit)) return + + const tool = completedTool(exit.value.parts) + if (!tool) return + + expect(tool.state.metadata.truncated).toBe(true) + expect(typeof tool.state.metadata.outputPath).toBe("string") + expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.") + expect(tool.state.output).toContain("Full output saved to:") + expect(tool.state.output).not.toContain("Tool execution aborted") + }), + { git: true, config: providerCfg }, + ), + 30_000, +) + unix( "cancel interrupts loop queued behind shell", () => diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index c192a446bd49..ae67983bf69f 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -18,6 +18,7 @@ import path from "path" import { Session } from "../../src/session" import { LLM } from "../../src/session/llm" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" import { SessionSummary } from "../../src/session/summary" import { MessageV2 } from "../../src/session/message-v2" import { Log } from "../../src/util/log" @@ -39,10 +40,12 @@ import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "../../src/provider/provider" import { Question } from "../../src/question" +import { Skill } from "../../src/skill" import { Todo } from "../../src/session/todo" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" +import { SessionRunState } from "../../src/session/run-state" import { SessionStatus } from "../../src/session/status" import { Shell } from "../../src/shell/shell" import { Snapshot } from "../../src/snapshot" @@ -107,6 +110,7 @@ const filetime = Layer.succeed( ) const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) function makeHttp() { @@ -129,6 +133,7 @@ function makeHttp() { const question = Question.layer.pipe(Layer.provideMerge(deps)) const todo = Todo.layer.pipe(Layer.provideMerge(deps)) const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), Layer.provideMerge(todo), Layer.provideMerge(question), Layer.provideMerge(deps), @@ -139,6 +144,8 @@ function makeHttp() { return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provideMerge(run), Layer.provideMerge(compact), Layer.provideMerge(proc), Layer.provideMerge(registry), diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index e5a04c082dc5..1ff40b4b99ef 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -3,6 +3,7 @@ import fs from "fs/promises" import path from "path" import { Effect, Layer, ManagedRuntime } from "effect" import { AppFileSystem } from "../../src/filesystem" +import { Git } from "../../src/git" import { Global } from "../../src/global" import { Storage } from "../../src/storage/storage" import { tmpdir } from "../fixture/fixture" @@ -47,7 +48,7 @@ async function withStorage( root: string, fn: (run: (body: Effect.Effect) => Promise) => Promise, ) { - const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)))) + const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root)), Layer.provide(Git.defaultLayer))) try { return await fn((body) => rt.runPromise(body)) } finally { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index e6269a4f389f..ea9aeeaf9efe 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -5,7 +5,8 @@ import { pathToFileURL } from "url" import type { Permission } from "../../src/permission" import type { Tool } from "../../src/tool/tool" import { Instance } from "../../src/project/instance" -import { SkillTool, SkillDescription } from "../../src/tool/skill" +import { SkillTool } from "../../src/tool/skill" +import { ToolRegistry } from "../../src/tool/registry" import { tmpdir } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" @@ -49,9 +50,11 @@ description: Skill for tool tests. await Instance.provide({ directory: tmp.path, fn: async () => { - const desc = await Effect.runPromise( - SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }), - ) + const desc = await ToolRegistry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent: { name: "build", mode: "primary" as const, permission: [], options: {} }, + }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") expect(desc).toContain(`**tool-skill**: Skill for tool tests.`) }, }) @@ -92,8 +95,14 @@ description: ${description} directory: tmp.path, fn: async () => { const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } - const first = await Effect.runPromise(SkillDescription(agent)) - const second = await Effect.runPromise(SkillDescription(agent)) + const load = () => + ToolRegistry.tools({ + providerID: "opencode" as any, + modelID: "gpt-5" as any, + agent, + }).then((tools) => tools.find((tool) => tool.id === SkillTool.id)?.description ?? "") + const first = await load() + const second = await load() expect(first).toBe(second) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 8ebfa59d2313..e3e6d58d3c1d 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -9,7 +9,8 @@ import { MessageV2 } from "../../src/session/message-v2" import { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID } from "../../src/session/schema" import { ModelID, ProviderID } from "../../src/provider/schema" -import { TaskDescription, TaskTool } from "../../src/tool/task" +import { TaskTool } from "../../src/tool/task" +import { ToolRegistry } from "../../src/tool/registry" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -23,7 +24,13 @@ const ref = { } const it = testEffect( - Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer), + Layer.mergeAll( + Agent.defaultLayer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + ToolRegistry.defaultLayer, + ), ) const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { @@ -92,8 +99,13 @@ describe("tool.task", () => { Effect.gen(function* () { const agent = yield* Agent.Service const build = yield* agent.get("build") - const first = yield* TaskDescription(build) - const second = yield* TaskDescription(build) + const registry = yield* ToolRegistry.Service + const get = Effect.fnUntraced(function* () { + const tools = yield* registry.tools({ ...ref, agent: build }) + return tools.find((tool) => tool.id === TaskTool.id)?.description ?? "" + }) + const first = yield* get() + const second = yield* get() expect(first).toBe(second) @@ -130,7 +142,9 @@ describe("tool.task", () => { Effect.gen(function* () { const agent = yield* Agent.Service const build = yield* agent.get("build") - const description = yield* TaskDescription(build) + const registry = yield* ToolRegistry.Service + const description = + (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? "" expect(description).toContain("- alpha: Alpha agent") expect(description).not.toContain("- zebra: Zebra agent") diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 0b52bbd47cf7..ced1523dacd4 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a3aa709a712e..47e09dfab45d 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e7098f264315..0ea9e38eb0d1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -123,110 +123,95 @@ export type EventPermissionReplied = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } +export type SnapshotFileDiff = { + file: string + patch: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} -export type EventSessionStatus = { - type: "session.status" +export type EventSessionDiff = { + type: "session.diff" properties: { sessionID: string - status: SessionStatus + diff: Array } } -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string +export type ProviderAuthError = { + name: "ProviderAuthError" + data: { + providerID: string + message: string } } -export type QuestionOption = { - /** - * Display text (1-5 words, concise) - */ - label: string - /** - * Explanation of choice - */ - description: string +export type UnknownError = { + name: "UnknownError" + data: { + message: string + } } -export type QuestionInfo = { - /** - * Complete question - */ - question: string - /** - * Very short label (max 30 chars) - */ - header: string - /** - * Available choices - */ - options: Array - /** - * Allow selecting multiple choices - */ - multiple?: boolean - /** - * Allow typing a custom answer (default: true) - */ - custom?: boolean +export type MessageOutputLengthError = { + name: "MessageOutputLengthError" + data: { + [key: string]: unknown + } } -export type QuestionRequest = { - id: string - sessionID: string - /** - * Questions to ask - */ - questions: Array - tool?: { - messageID: string - callID: string +export type MessageAbortedError = { + name: "MessageAbortedError" + data: { + message: string } } -export type EventQuestionAsked = { - type: "question.asked" - properties: QuestionRequest +export type StructuredOutputError = { + name: "StructuredOutputError" + data: { + message: string + retries: number + } } -export type QuestionAnswer = Array - -export type EventQuestionReplied = { - type: "question.replied" - properties: { - sessionID: string - requestID: string - answers: Array +export type ContextOverflowError = { + name: "ContextOverflowError" + data: { + message: string + responseBody?: string } } -export type EventQuestionRejected = { - type: "question.rejected" - properties: { - sessionID: string - requestID: string +export type ApiError = { + name: "APIError" + data: { + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: { + [key: string]: string + } + responseBody?: string + metadata?: { + [key: string]: string + } } } -export type EventSessionCompacted = { - type: "session.compacted" +export type EventSessionError = { + type: "session.error" properties: { - sessionID: string + sessionID?: string + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | StructuredOutputError + | ContextOverflowError + | ApiError } } @@ -245,26 +230,10 @@ export type EventFileWatcherUpdated = { } } -export type Todo = { - /** - * Brief description of the task - */ - content: string - /** - * Current status of the task: pending, in_progress, completed, cancelled - */ - status: string - /** - * Priority level of the task: high, medium, low - */ - priority: string -} - -export type EventTodoUpdated = { - type: "todo.updated" +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated" properties: { - sessionID: string - todos: Array + branch?: string } } @@ -347,116 +316,147 @@ export type EventCommandExecuted = { } } -export type SnapshotFileDiff = { - file: string - patch: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" -} - -export type EventSessionDiff = { - type: "session.diff" +export type EventWorkspaceReady = { + type: "workspace.ready" properties: { - sessionID: string - diff: Array + name: string } } -export type ProviderAuthError = { - name: "ProviderAuthError" - data: { - providerID: string +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { message: string } } -export type UnknownError = { - name: "UnknownError" - data: { - message: string - } +export type QuestionOption = { + /** + * Display text (1-5 words, concise) + */ + label: string + /** + * Explanation of choice + */ + description: string } -export type MessageOutputLengthError = { - name: "MessageOutputLengthError" - data: { - [key: string]: unknown - } +export type QuestionInfo = { + /** + * Complete question + */ + question: string + /** + * Very short label (max 30 chars) + */ + header: string + /** + * Available choices + */ + options: Array + /** + * Allow selecting multiple choices + */ + multiple?: boolean + /** + * Allow typing a custom answer (default: true) + */ + custom?: boolean } -export type MessageAbortedError = { - name: "MessageAbortedError" - data: { - message: string +export type QuestionRequest = { + id: string + sessionID: string + /** + * Questions to ask + */ + questions: Array + tool?: { + messageID: string + callID: string } } -export type StructuredOutputError = { - name: "StructuredOutputError" - data: { - message: string - retries: number +export type EventQuestionAsked = { + type: "question.asked" + properties: QuestionRequest +} + +export type QuestionAnswer = Array + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + requestID: string + answers: Array } } -export type ContextOverflowError = { - name: "ContextOverflowError" - data: { - message: string - responseBody?: string +export type EventQuestionRejected = { + type: "question.rejected" + properties: { + sessionID: string + requestID: string } } -export type ApiError = { - name: "APIError" - data: { - message: string - statusCode?: number - isRetryable: boolean - responseHeaders?: { - [key: string]: string +export type SessionStatus = + | { + type: "idle" } - responseBody?: string - metadata?: { - [key: string]: string + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" } - } -} -export type EventSessionError = { - type: "session.error" +export type EventSessionStatus = { + type: "session.status" properties: { - sessionID?: string - error?: - | ProviderAuthError - | UnknownError - | MessageOutputLengthError - | MessageAbortedError - | StructuredOutputError - | ContextOverflowError - | ApiError + sessionID: string + status: SessionStatus } } -export type EventVcsBranchUpdated = { - type: "vcs.branch.updated" +export type EventSessionIdle = { + type: "session.idle" properties: { - branch?: string + sessionID: string } } -export type EventWorkspaceReady = { - type: "workspace.ready" +export type EventSessionCompacted = { + type: "session.compacted" properties: { - name: string + sessionID: string } } -export type EventWorkspaceFailed = { - type: "workspace.failed" +export type Todo = { + /** + * Brief description of the task + */ + content: string + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string + /** + * Priority level of the task: high, medium, low + */ + priority: string +} + +export type EventTodoUpdated = { + type: "todo.updated" properties: { - message: string + sessionID: string + todos: Array } } @@ -974,15 +974,11 @@ export type Event = | EventMessagePartDelta | EventPermissionAsked | EventPermissionReplied - | EventSessionStatus - | EventSessionIdle - | EventQuestionAsked - | EventQuestionReplied - | EventQuestionRejected - | EventSessionCompacted + | EventSessionDiff + | EventSessionError | EventFileEdited | EventFileWatcherUpdated - | EventTodoUpdated + | EventVcsBranchUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -990,11 +986,15 @@ export type Event = | EventMcpToolsChanged | EventMcpBrowserOpenFailed | EventCommandExecuted - | EventSessionDiff - | EventSessionError - | EventVcsBranchUpdated | EventWorkspaceReady | EventWorkspaceFailed + | EventQuestionAsked + | EventQuestionReplied + | EventQuestionRejected + | EventSessionStatus + | EventSessionIdle + | EventSessionCompacted + | EventTodoUpdated | EventPtyCreated | EventPtyUpdated | EventPtyExited @@ -1375,6 +1375,10 @@ export type McpOAuthConfig = { * OAuth scopes to request during authorization */ scope?: string + /** + * OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback). + */ + redirectUri?: string } export type McpRemoteConfig = { diff --git a/packages/sdk/js/src/v2/index.ts b/packages/sdk/js/src/v2/index.ts index d514784bc292..9615eacc7abe 100644 --- a/packages/sdk/js/src/v2/index.ts +++ b/packages/sdk/js/src/v2/index.ts @@ -6,7 +6,6 @@ import { createOpencodeServer } from "./server.js" import type { ServerOptions } from "./server.js" export * as data from "./data.js" -import * as data from "./data.js" export async function createOpencode(options?: ServerOptions) { const server = await createOpencodeServer({ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a0672df2d764..450de5131931 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7414,55 +7414,34 @@ }, "required": ["type", "properties"] }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] + "SnapshotFileDiff": { + "type": "object", + "properties": { + "file": { + "type": "string" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { - "type": "string" - }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] + "patch": { + "type": "string" }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "busy" - } - }, - "required": ["type"] + "additions": { + "type": "number" + }, + "deletions": { + "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } - ] + }, + "required": ["file", "patch", "additions", "deletions"] }, - "Event.session.status": { + "Event.session.diff": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.status" + "const": "session.diff" }, "properties": { "type": "object", @@ -7471,191 +7450,188 @@ "type": "string", "pattern": "^ses.*" }, - "status": { - "$ref": "#/components/schemas/SessionStatus" + "diff": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SnapshotFileDiff" + } } }, - "required": ["sessionID", "status"] + "required": ["sessionID", "diff"] } }, "required": ["type", "properties"] }, - "Event.session.idle": { + "ProviderAuthError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "session.idle" + "const": "ProviderAuthError" }, - "properties": { + "data": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "providerID": { + "type": "string" + }, + "message": { + "type": "string" } }, - "required": ["sessionID"] + "required": ["providerID", "message"] } }, - "required": ["type", "properties"] + "required": ["name", "data"] }, - "QuestionOption": { + "UnknownError": { "type": "object", "properties": { - "label": { - "description": "Display text (1-5 words, concise)", - "type": "string" + "name": { + "type": "string", + "const": "UnknownError" }, - "description": { - "description": "Explanation of choice", - "type": "string" + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"] } }, - "required": ["label", "description"] + "required": ["name", "data"] }, - "QuestionInfo": { + "MessageOutputLengthError": { "type": "object", "properties": { - "question": { - "description": "Complete question", - "type": "string" - }, - "header": { - "description": "Very short label (max 30 chars)", - "type": "string" - }, - "options": { - "description": "Available choices", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionOption" - } - }, - "multiple": { - "description": "Allow selecting multiple choices", - "type": "boolean" + "name": { + "type": "string", + "const": "MessageOutputLengthError" }, - "custom": { - "description": "Allow typing a custom answer (default: true)", - "type": "boolean" + "data": { + "type": "object", + "properties": {} } }, - "required": ["question", "header", "options"] + "required": ["name", "data"] }, - "QuestionRequest": { + "MessageAbortedError": { "type": "object", "properties": { - "id": { - "type": "string", - "pattern": "^que.*" - }, - "sessionID": { + "name": { "type": "string", - "pattern": "^ses.*" - }, - "questions": { - "description": "Questions to ask", - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionInfo" - } + "const": "MessageAbortedError" }, - "tool": { + "data": { "type": "object", "properties": { - "messageID": { - "type": "string", - "pattern": "^msg.*" - }, - "callID": { + "message": { "type": "string" } }, - "required": ["messageID", "callID"] + "required": ["message"] } }, - "required": ["id", "sessionID", "questions"] + "required": ["name", "data"] }, - "Event.question.asked": { + "StructuredOutputError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "question.asked" + "const": "StructuredOutputError" }, - "properties": { - "$ref": "#/components/schemas/QuestionRequest" + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "retries": { + "type": "number" + } + }, + "required": ["message", "retries"] } }, - "required": ["type", "properties"] - }, - "QuestionAnswer": { - "type": "array", - "items": { - "type": "string" - } + "required": ["name", "data"] }, - "Event.question.replied": { + "ContextOverflowError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "question.replied" + "const": "ContextOverflowError" }, - "properties": { + "data": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "requestID": { - "type": "string", - "pattern": "^que.*" + "message": { + "type": "string" }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } + "responseBody": { + "type": "string" } }, - "required": ["sessionID", "requestID", "answers"] + "required": ["message"] } }, - "required": ["type", "properties"] + "required": ["name", "data"] }, - "Event.question.rejected": { + "APIError": { "type": "object", "properties": { - "type": { + "name": { "type": "string", - "const": "question.rejected" + "const": "APIError" }, - "properties": { + "data": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" + "message": { + "type": "string" }, - "requestID": { - "type": "string", - "pattern": "^que.*" + "statusCode": { + "type": "number" + }, + "isRetryable": { + "type": "boolean" + }, + "responseHeaders": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "responseBody": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } } }, - "required": ["sessionID", "requestID"] + "required": ["message", "isRetryable"] } }, - "required": ["type", "properties"] + "required": ["name", "data"] }, - "Event.session.compacted": { + "Event.session.error": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.compacted" + "const": "session.error" }, "properties": { "type": "object", @@ -7663,9 +7639,33 @@ "sessionID": { "type": "string", "pattern": "^ses.*" + }, + "error": { + "anyOf": [ + { + "$ref": "#/components/schemas/ProviderAuthError" + }, + { + "$ref": "#/components/schemas/UnknownError" + }, + { + "$ref": "#/components/schemas/MessageOutputLengthError" + }, + { + "$ref": "#/components/schemas/MessageAbortedError" + }, + { + "$ref": "#/components/schemas/StructuredOutputError" + }, + { + "$ref": "#/components/schemas/ContextOverflowError" + }, + { + "$ref": "#/components/schemas/APIError" + } + ] } - }, - "required": ["sessionID"] + } } }, "required": ["type", "properties"] @@ -7724,46 +7724,20 @@ }, "required": ["type", "properties"] }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { + "Event.vcs.branch.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "todo.updated" + "const": "vcs.branch.updated" }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } + "branch": { + "type": "string" } - }, - "required": ["sessionID", "todos"] + } } }, "required": ["type", "properties"] @@ -7954,224 +7928,243 @@ }, "required": ["type", "properties"] }, - "SnapshotFileDiff": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "patch": { - "type": "string" - }, - "additions": { - "type": "number" - }, - "deletions": { - "type": "number" - }, - "status": { - "type": "string", - "enum": ["added", "deleted", "modified"] - } - }, - "required": ["file", "patch", "additions", "deletions"] - }, - "Event.session.diff": { + "Event.workspace.ready": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.diff" + "const": "workspace.ready" }, "properties": { "type": "object", "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "diff": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SnapshotFileDiff" - } + "name": { + "type": "string" } }, - "required": ["sessionID", "diff"] + "required": ["name"] } }, "required": ["type", "properties"] }, - "ProviderAuthError": { + "Event.workspace.failed": { "type": "object", "properties": { - "name": { + "type": { "type": "string", - "const": "ProviderAuthError" + "const": "workspace.failed" }, - "data": { + "properties": { "type": "object", "properties": { - "providerID": { - "type": "string" - }, "message": { "type": "string" } }, - "required": ["providerID", "message"] + "required": ["message"] } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "UnknownError": { + "QuestionOption": { "type": "object", "properties": { - "name": { - "type": "string", - "const": "UnknownError" + "label": { + "description": "Display text (1-5 words, concise)", + "type": "string" }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"] + "description": { + "description": "Explanation of choice", + "type": "string" } }, - "required": ["name", "data"] + "required": ["label", "description"] }, - "MessageOutputLengthError": { + "QuestionInfo": { "type": "object", "properties": { - "name": { - "type": "string", - "const": "MessageOutputLengthError" + "question": { + "description": "Complete question", + "type": "string" }, - "data": { - "type": "object", - "properties": {} + "header": { + "description": "Very short label (max 30 chars)", + "type": "string" + }, + "options": { + "description": "Available choices", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionOption" + } + }, + "multiple": { + "description": "Allow selecting multiple choices", + "type": "boolean" + }, + "custom": { + "description": "Allow typing a custom answer (default: true)", + "type": "boolean" } }, - "required": ["name", "data"] + "required": ["question", "header", "options"] }, - "MessageAbortedError": { + "QuestionRequest": { "type": "object", "properties": { - "name": { + "id": { "type": "string", - "const": "MessageAbortedError" + "pattern": "^que.*" }, - "data": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "questions": { + "description": "Questions to ask", + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionInfo" + } + }, + "tool": { "type": "object", "properties": { - "message": { + "messageID": { + "type": "string", + "pattern": "^msg.*" + }, + "callID": { "type": "string" } }, - "required": ["message"] + "required": ["messageID", "callID"] + } + }, + "required": ["id", "sessionID", "questions"] + }, + "Event.question.asked": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.asked" + }, + "properties": { + "$ref": "#/components/schemas/QuestionRequest" } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "StructuredOutputError": { + "QuestionAnswer": { + "type": "array", + "items": { + "type": "string" + } + }, + "Event.question.replied": { "type": "object", "properties": { - "name": { + "type": { "type": "string", - "const": "StructuredOutputError" + "const": "question.replied" }, - "data": { + "properties": { "type": "object", "properties": { - "message": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" }, - "retries": { - "type": "number" + "requestID": { + "type": "string", + "pattern": "^que.*" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } } }, - "required": ["message", "retries"] + "required": ["sessionID", "requestID", "answers"] } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "ContextOverflowError": { + "Event.question.rejected": { "type": "object", "properties": { - "name": { + "type": { "type": "string", - "const": "ContextOverflowError" + "const": "question.rejected" }, - "data": { + "properties": { "type": "object", "properties": { - "message": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" }, - "responseBody": { - "type": "string" + "requestID": { + "type": "string", + "pattern": "^que.*" } }, - "required": ["message"] + "required": ["sessionID", "requestID"] } }, - "required": ["name", "data"] + "required": ["type", "properties"] }, - "APIError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "const": "APIError" + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] }, - "data": { + { "type": "object", "properties": { - "message": { - "type": "string" + "type": { + "type": "string", + "const": "retry" }, - "statusCode": { + "attempt": { "type": "number" }, - "isRetryable": { - "type": "boolean" - }, - "responseHeaders": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "responseBody": { + "message": { "type": "string" }, - "metadata": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } + "next": { + "type": "number" } }, - "required": ["message", "isRetryable"] + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] } - }, - "required": ["name", "data"] + ] }, - "Event.session.error": { + "Event.session.status": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.error" + "const": "session.status" }, "properties": { "type": "object", @@ -8180,88 +8173,95 @@ "type": "string", "pattern": "^ses.*" }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ProviderAuthError" - }, - { - "$ref": "#/components/schemas/UnknownError" - }, - { - "$ref": "#/components/schemas/MessageOutputLengthError" - }, - { - "$ref": "#/components/schemas/MessageAbortedError" - }, - { - "$ref": "#/components/schemas/StructuredOutputError" - }, - { - "$ref": "#/components/schemas/ContextOverflowError" - }, - { - "$ref": "#/components/schemas/APIError" - } - ] + "status": { + "$ref": "#/components/schemas/SessionStatus" } - } + }, + "required": ["sessionID", "status"] } }, "required": ["type", "properties"] }, - "Event.vcs.branch.updated": { + "Event.session.idle": { "type": "object", "properties": { "type": { "type": "string", - "const": "vcs.branch.updated" + "const": "session.idle" }, "properties": { "type": "object", "properties": { - "branch": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" } - } + }, + "required": ["sessionID"] } }, "required": ["type", "properties"] }, - "Event.workspace.ready": { + "Event.session.compacted": { "type": "object", "properties": { "type": { "type": "string", - "const": "workspace.ready" + "const": "session.compacted" }, "properties": { "type": "object", "properties": { - "name": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" } }, - "required": ["name"] + "required": ["sessionID"] } }, "required": ["type", "properties"] }, - "Event.workspace.failed": { + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + } + }, + "required": ["content", "status", "priority"] + }, + "Event.todo.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "workspace.failed" + "const": "todo.updated" }, "properties": { "type": "object", "properties": { - "message": { - "type": "string" + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } } }, - "required": ["message"] + "required": ["sessionID", "todos"] } }, "required": ["type", "properties"] @@ -9817,22 +9817,10 @@ "$ref": "#/components/schemas/Event.permission.replied" }, { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" - }, - { - "$ref": "#/components/schemas/Event.question.asked" - }, - { - "$ref": "#/components/schemas/Event.question.replied" - }, - { - "$ref": "#/components/schemas/Event.question.rejected" + "$ref": "#/components/schemas/Event.session.diff" }, { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.session.error" }, { "$ref": "#/components/schemas/Event.file.edited" @@ -9841,7 +9829,7 @@ "$ref": "#/components/schemas/Event.file.watcher.updated" }, { - "$ref": "#/components/schemas/Event.todo.updated" + "$ref": "#/components/schemas/Event.vcs.branch.updated" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" @@ -9865,19 +9853,31 @@ "$ref": "#/components/schemas/Event.command.executed" }, { - "$ref": "#/components/schemas/Event.session.diff" + "$ref": "#/components/schemas/Event.workspace.ready" }, { - "$ref": "#/components/schemas/Event.session.error" + "$ref": "#/components/schemas/Event.workspace.failed" }, { - "$ref": "#/components/schemas/Event.vcs.branch.updated" + "$ref": "#/components/schemas/Event.question.asked" }, { - "$ref": "#/components/schemas/Event.workspace.ready" + "$ref": "#/components/schemas/Event.question.replied" }, { - "$ref": "#/components/schemas/Event.workspace.failed" + "$ref": "#/components/schemas/Event.question.rejected" + }, + { + "$ref": "#/components/schemas/Event.session.status" + }, + { + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" }, { "$ref": "#/components/schemas/Event.pty.created" @@ -10882,6 +10882,10 @@ "scope": { "description": "OAuth scopes to request during authorization", "type": "string" + }, + "redirectUri": { + "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", + "type": "string" } }, "additionalProperties": false diff --git a/packages/slack/package.json b/packages/slack/package.json index 4e3e54800be5..892c7172ed93 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 8de6ea0d43bf..12325ecc7d74 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.1", + "version": "1.4.3", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 105098595eb1..bee3d082ac34 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.4.1", + "version": "1.4.3", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index de36ca6574a3..df003f79fdb3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.1", + "version": "1.4.3", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index afe506d0ea11..290834180d72 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.1", + "version": "1.4.3", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/specs/v2/session.md b/specs/v2/session.md new file mode 100644 index 000000000000..cae90ba7c883 --- /dev/null +++ b/specs/v2/session.md @@ -0,0 +1,17 @@ +# Session API + +## Remove Dedicated `session.init` Route + +The dedicated `POST /session/:sessionID/init` endpoint exists only as a compatibility wrapper around the normal `/init` command flow. + +Current behavior: + +- the route calls `SessionPrompt.command(...)` +- it sends `Command.Default.INIT` +- it does not provide distinct session-core behavior beyond running the existing init command in an existing session + +V2 plan: + +- remove the dedicated `session.init` endpoint +- rely on the normal `/init` command flow instead +- avoid reintroducing `Session.initialize`-style special cases in the session service layer