Compare commits

...

14 Commits

Author SHA1 Message Date
Kit Langton
a79e2c672a refactor(tool): convert question tool internals to Effect 2026-04-09 22:45:09 -04:00
Kit Langton
eca11ca71a refactor(effect): use SessionRevert service in prompt (#21796) 2026-04-09 22:28:11 -04:00
Kit Langton
17bd16667c refactor(effect): move tool descriptions into registry (#21795) 2026-04-09 22:20:27 -04:00
Kit Langton
16c60c9ee7 refactor(session): extract sharing orchestration (#21759) 2026-04-09 21:47:48 -04:00
Dax Raad
0970b102e1 Merge remote-tracking branch 'origin/dev' into dev 2026-04-09 21:35:09 -04:00
Dax Raad
04074d3f4a core: enable prod channel to use shared production database
Ensures users on the prod channel have their data persisted to the same
database as latest and beta channels, preventing data fragmentation
across different release channels.
2026-04-09 21:34:52 -04:00
Luke Parker
b16ee08fd5 ci use node 24 in test workflow fixing random ECONNRESET (#21782) 2026-04-10 01:00:21 +00:00
Luke Parker
98874a09f7 fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) 2026-04-10 01:00:21 +00:00
opencode
877be7e8e0 release: v1.4.3 2026-04-10 01:00:12 +00:00
Dax Raad
eac50f9151 ci: prevent beta branch builds from triggering production release steps
Skip Windows and Linux code signing, along with artifact downloads for
the beta branch to ensure beta builds don't go through production
release processes.
2026-04-09 17:06:53 -04:00
Dax Raad
1a902b291c ci: skip winget publish on beta and ensure finalize always runs
Beta releases no longer trigger unnecessary Winget submissions, and release
finalization now completes even when some build artifacts are missing.
2026-04-09 16:34:05 -04:00
opencode-agent[bot]
bbe4a04f9f chore: generate 2026-04-09 20:29:48 +00:00
Kit Langton
b2f621b897 refactor(session): inline init route orchestration (#21754) 2026-04-09 16:28:42 -04:00
Aiden Cline
7202b3a325 fix: ensure that openai oauth works for agent create cmd, use temporary hack (#21749)
Co-authored-by: OpeOginni <brightoginni123@gmail.com>
2026-04-09 15:25:59 -05:00
47 changed files with 1175 additions and 1084 deletions

View File

@@ -114,7 +114,7 @@ jobs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,6 +213,7 @@ jobs:
needs:
- build-cli
- version
if: github.ref_name != 'beta'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -389,6 +390,7 @@ jobs:
needs:
- build-cli
- version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -421,7 +423,6 @@ jobs:
target: aarch64-unknown-linux-gnu
platform_flag: --linux
runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps:
- uses: actions/checkout@v3
@@ -547,6 +548,7 @@ jobs:
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
@@ -589,12 +591,13 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
if: needs.version.outputs.release && github.ref_name != 'beta'
with:
pattern: latest-yml-*
path: /tmp/latest-yml

View File

@@ -17,6 +17,9 @@ permissions:
contents: read
checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
unit:
name: unit (${{ matrix.settings.name }})
@@ -38,6 +41,11 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun
@@ -102,6 +110,11 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun
uses: ./.github/actions/setup-bun

View File

@@ -27,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.2",
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"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.2",
"version": "1.4.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -311,7 +311,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.2",
"version": "1.4.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -447,7 +447,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -481,7 +481,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -496,7 +496,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -531,7 +531,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -580,7 +580,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"zod": "catalog:",
},
@@ -591,7 +591,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -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<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return
if (done(proc)) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((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)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.2",
"version": "1.4.3",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.2",
"version": "1.4.3",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.2",
"version": "1.4.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.2",
"version": "1.4.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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: {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.2",
"version": "1.4.3",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.2"
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.2/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.2/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.2/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.2/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.2/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.2",
"version": "1.4.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.2",
"version": "1.4.3",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -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<typeof generateObject>[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: () => {},

View File

@@ -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)

View File

@@ -9,11 +9,13 @@ 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"
@@ -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)
},

View File

@@ -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),
),
)

View File

@@ -5,7 +5,6 @@ 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"
@@ -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<Info>
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
readonly get: (id: SessionID) => Effect.Effect<Info>
readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
readonly unshare: (id: SessionID) => Effect.Effect<void>
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
@@ -358,12 +352,6 @@ export namespace Session {
field: string
delta: string
}) => Effect.Effect<void>
readonly initialize: (input: {
sessionID: SessionID
modelID: ModelID
providerID: ProviderID
messageID: MessageID
}) => Effect.Effect<void>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
@@ -373,12 +361,10 @@ export namespace Session {
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Bus.Service> = 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
@@ -408,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
@@ -431,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) =>
@@ -469,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)
@@ -616,7 +577,7 @@ export namespace Session {
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]),
Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
)
})
@@ -665,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,
@@ -705,12 +647,11 @@ export namespace Session {
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)
@@ -731,8 +672,6 @@ export namespace Session {
)
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)),
@@ -895,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)),
)
}

View File

@@ -594,19 +594,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),
),
)
}

View File

@@ -99,6 +99,7 @@ export namespace SessionPrompt {
const scope = yield* Scope.Scope
const instruction = yield* Instruction.Service
const state = yield* SessionRunState.Service
const revert = yield* SessionRevert.Service
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
log.info("cancel", { sessionID })
@@ -708,7 +709,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
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) {
@@ -1269,7 +1270,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = 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)
@@ -1665,29 +1666,28 @@ 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(SessionRunState.layer),
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),
),
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(Bus.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
),
)
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -150,17 +150,14 @@ export namespace SessionRevert {
}),
)
export const defaultLayer = Layer.unwrap(
Effect.sync(() =>
layer.pipe(
Layer.provide(SessionRunState.layer),
Layer.provide(SessionStatus.layer),
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),
),
)

View File

@@ -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),
),
)

View File

@@ -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<typeof Session.create>[0]) => Effect.Effect<Session.Info>
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@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<typeof Session.create>[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)))
}

View File

@@ -159,7 +159,10 @@ export namespace ShareNext {
if (disabled) return cache
const watch = <D extends { type: string }>(def: D, fn: (evt: { properties: any }) => Effect.Effect<void>) =>
const watch = <D extends { type: string }>(
def: D,
fn: (evt: { properties: any }) => Effect.Effect<void, unknown>,
) =>
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
}),

View File

@@ -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`)

View File

@@ -20,27 +20,26 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) {
const answers = await question
.ask({
execute: (params: z.infer<typeof parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const answers = yield* question.ask({
sessionID: ctx.sessionID,
questions: params.questions,
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
.pipe(Effect.runPromise)
const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ")
const formatted = params.questions
.map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)
.join(", ")
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
},
return {
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
metadata: {
answers,
},
}
}).pipe(Effect.runPromise),
}
}),
)

View File

@@ -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 `<skill_content name="...">` 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),
),
)

View File

@@ -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 `<skill_content name="...">` 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")
})

View File

@@ -7,7 +7,6 @@ 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"
@@ -176,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")
})

View File

@@ -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),

View File

@@ -25,9 +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"
@@ -166,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),
@@ -176,6 +179,7 @@ function makeHttp() {
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provideMerge(run),
Layer.provideMerge(compact),
Layer.provideMerge(proc),

View File

@@ -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,6 +40,7 @@ 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"
@@ -131,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),
@@ -141,6 +144,7 @@ function makeHttp() {
return Layer.mergeAll(
TestLLMServer.layer,
SessionPrompt.layer.pipe(
Layer.provide(SessionRevert.defaultLayer),
Layer.provideMerge(run),
Layer.provideMerge(compact),
Layer.provideMerge(proc),

View File

@@ -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)

View File

@@ -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")

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -94,35 +94,6 @@ export type EventMessagePartDelta = {
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type PermissionRequest = {
id: string
sessionID: string
@@ -152,201 +123,6 @@ export type EventPermissionReplied = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
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: {
sessionID: string
todos: Array<Todo>
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type EventTuiSessionSelect = {
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventMcpBrowserOpenFailed = {
type: "mcp.browser.open.failed"
properties: {
mcpName: string
url: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type SnapshotFileDiff = {
file: string
patch: string
@@ -439,6 +215,21 @@ export type EventSessionError = {
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventVcsBranchUpdated = {
type: "vcs.branch.updated"
properties: {
@@ -446,6 +237,85 @@ export type EventVcsBranchUpdated = {
}
}
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
text: string
}
}
export type EventTuiCommandExecute = {
type: "tui.command.execute"
properties: {
command:
| "session.list"
| "session.new"
| "session.share"
| "session.interrupt"
| "session.compact"
| "session.page.up"
| "session.page.down"
| "session.line.up"
| "session.line.down"
| "session.half.page.up"
| "session.half.page.down"
| "session.first"
| "session.last"
| "prompt.clear"
| "prompt.submit"
| "agent.cycle"
| string
}
}
export type EventTuiToastShow = {
type: "tui.toast.show"
properties: {
title?: string
message: string
variant: "info" | "success" | "warning" | "error"
/**
* Duration in milliseconds
*/
duration?: number
}
}
export type EventTuiSessionSelect = {
type: "tui.session.select"
properties: {
/**
* Session ID to navigate to
*/
sessionID: string
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventMcpBrowserOpenFailed = {
type: "mcp.browser.open.failed"
properties: {
mcpName: string
url: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
name: string
sessionID: string
arguments: string
messageID: string
}
}
export type EventWorkspaceReady = {
type: "workspace.ready"
properties: {
@@ -460,6 +330,136 @@ export type EventWorkspaceFailed = {
}
}
export type QuestionOption = {
/**
* Display text (1-5 words, concise)
*/
label: string
/**
* Explanation of choice
*/
description: string
}
export type QuestionInfo = {
/**
* Complete question
*/
question: string
/**
* Very short label (max 30 chars)
*/
header: string
/**
* Available choices
*/
options: Array<QuestionOption>
/**
* Allow selecting multiple choices
*/
multiple?: boolean
/**
* Allow typing a custom answer (default: true)
*/
custom?: boolean
}
export type QuestionRequest = {
id: string
sessionID: string
/**
* Questions to ask
*/
questions: Array<QuestionInfo>
tool?: {
messageID: string
callID: string
}
}
export type EventQuestionAsked = {
type: "question.asked"
properties: QuestionRequest
}
export type QuestionAnswer = Array<string>
export type EventQuestionReplied = {
type: "question.replied"
properties: {
sessionID: string
requestID: string
answers: Array<QuestionAnswer>
}
}
export type EventQuestionRejected = {
type: "question.rejected"
properties: {
sessionID: string
requestID: string
}
}
export type SessionStatus =
| {
type: "idle"
}
| {
type: "retry"
attempt: number
message: string
next: number
}
| {
type: "busy"
}
export type EventSessionStatus = {
type: "session.status"
properties: {
sessionID: string
status: SessionStatus
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
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: {
sessionID: string
todos: Array<Todo>
}
}
export type Pty = {
id: string
title: string
@@ -972,17 +972,13 @@ export type Event =
| EventLspClientDiagnostics
| EventLspUpdated
| EventMessagePartDelta
| EventSessionStatus
| EventSessionIdle
| EventPermissionAsked
| EventPermissionReplied
| 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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.4.2",
"version": "1.4.3",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.4.2",
"version": "1.4.3",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.4.2",
"version": "1.4.3",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.4.2",
"version": "1.4.3",
"publisher": "sst-dev",
"repository": {
"type": "git",

17
specs/v2/session.md Normal file
View File

@@ -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