Compare commits

..
Author SHA1 Message Date
Aiden Cline 958e7f77f6 Merge remote-tracking branch 'origin/v2' into subagent-command
# Conflicts:
#	packages/core/src/config/plugin/command.ts
2026-07-03 23:04:15 -05:00
Aiden ClineandGitHub 3baaabede8 fix(core): resolve mcp header env placeholders (#35236) 2026-07-03 23:00:10 -05:00
Dax Raad afe3ebbc35 fix(core): bust external plugin import cache 2026-07-03 23:12:09 -04:00
Kit LangtonandGitHub e2faeb84e5 fix(core): tolerate minimal FSWatcher typings (#35264) 2026-07-03 22:37:42 -04:00
Kit LangtonandGitHub 35ed09ff37 fix(tui): improve MCP error details (#35263) 2026-07-03 22:26:54 -04:00
Dax Raad 9751615651 fix(core): reload all config plugins 2026-07-03 20:41:33 -04:00
Dax Raad c9b24ef027 feat(core): reload config on filesystem changes 2026-07-03 20:35:04 -04:00
Aiden ClineandGitHub b04d8d53e6 fix(tui): expand MCP server errors in dialog (#35243) 2026-07-03 18:19:16 -05:00
Dax Raad 7bd3f8ac83 chore(client): remove cyclic test dependencies 2026-07-03 18:12:22 -04:00
Dax Raad 6ae2fa5196 feat(plugin): align hooks with client APIs 2026-07-03 18:12:22 -04:00
Kit LangtonandGitHub 650d774372 refactor(schema): session shell payloads and event prefix restore (#35229) 2026-07-03 17:30:25 -04:00
Simon KleeandGitHub 64e4f6f91b cli: route run commands through v2 APIs (#35234) 2026-07-03 22:47:04 +02:00
Aiden Clineandopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 56eddcfa1f feat(core): run subagent commands in background
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-03 20:31:39 +00:00
168 changed files with 4771 additions and 2580 deletions
+2 -2
View File
@@ -122,15 +122,14 @@
},
"packages/client": {
"name": "@opencode-ai/client",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -695,6 +694,7 @@
"version": "1.17.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -177,7 +177,7 @@ export function DialogCustomProvider(props: Props) {
>
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
</div>
@@ -226,7 +226,7 @@ const SettingsProvidersContent: Component = () => {
>
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
<ProviderIcon id="session.synthetic" class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">{language.t("provider.custom.title")}</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
</div>
@@ -223,7 +223,7 @@ export const SettingsProvidersV2: Component = () => {
<div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead">
<ProviderIcon
id="synthetic"
id="session.synthetic"
width={PROVIDER_ICON_SIZE}
height={PROVIDER_ICON_SIZE}
class="settings-v2-provider-icon shrink-0"
@@ -7,7 +7,7 @@ describe("file watcher invalidation", () => {
const refresh: string[] = []
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/new.ts",
event: "add",
@@ -32,7 +32,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/open.ts",
event: "change",
@@ -63,7 +63,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src",
event: "change",
@@ -81,7 +81,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: "src/file.ts",
event: "change",
@@ -111,7 +111,7 @@ describe("file watcher invalidation", () => {
invalidateFromWatcher(
{
type: "file.watcher.updated",
type: "filesystem.changed",
properties: {
file: ".git/index.lock",
event: "change",
+1 -1
View File
@@ -16,7 +16,7 @@ type WatcherOps = {
}
export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
if (event.type !== "file.watcher.updated") return
if (event.type !== "filesystem.changed") return
const props =
typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
const rawPath = typeof props?.file === "string" ? props.file : undefined
+1 -1
View File
@@ -820,7 +820,7 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
if (evt.details.type !== "filesystem.changed") return
const props =
typeof evt.details.properties === "object" && evt.details.properties
? (evt.details.properties as Record<string, unknown>)
+17 -5
View File
@@ -1,16 +1,30 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/client",
"private": true,
"version": "1.17.13",
"type": "module",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/client"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"exports": {
"./promise": "./src/promise/index.ts",
"./effect": "./src/effect/index.ts"
"./promise/api": "./src/promise/api.ts",
"./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts"
},
"scripts": {
"build": "bun run script/build-package.ts",
"generate": "bun run script/build.ts",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated",
"check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api",
"test": "bun test --timeout 5000",
"typecheck": "tsgo --noEmit"
},
@@ -28,9 +42,7 @@
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await $`rm -rf dist`
await $`bun tsc -p tsconfig.build.json`
+2 -2
View File
@@ -32,8 +32,8 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
),
write(
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
),
],
{ concurrency: 3, discard: true },
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [
key,
{
import: value.replace("./src/", "./dist/").replace(/\.ts$/, ".js"),
types: value.replace("./src/", "./dist/").replace(/\.ts$/, ".d.ts"),
},
]
}),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+8
View File
@@ -0,0 +1,8 @@
import type { ModelApi, ProviderApi } from "./api/api.js"
export type * from "./api/api.js"
export interface CatalogApi<E = never> {
readonly provider: ProviderApi<E>
readonly model: ModelApi<E>
}
@@ -1,7 +1,7 @@
// Generated by @opencode-ai/httpapi-codegen. Do not edit.
import type { Effect, Stream } from "effect"
import type { HttpApiClient } from "effect/unstable/httpapi"
import type { ClientApi } from "@opencode-ai/protocol/client"
import type { ClientApi } from "../../contract"
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
type EffectValue<A> = A extends Effect.Effect<infer Success, any, any> ? Success : never
+17
View File
@@ -1,6 +1,22 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
export * from "./generated/index"
export type {
AgentApi,
AppApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export { Service } from "./service.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
@@ -27,3 +43,4 @@ export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
+41
View File
@@ -0,0 +1,41 @@
import type {
AgentApi as EffectAgentApi,
CommandApi as EffectCommandApi,
EventApi as EffectEventApi,
IntegrationApi as EffectIntegrationApi,
ModelApi as EffectModelApi,
PluginApi as EffectPluginApi,
ProviderApi as EffectProviderApi,
ReferenceApi as EffectReferenceApi,
SessionApi as EffectSessionApi,
SkillApi as EffectSkillApi,
} from "../effect/api/api.js"
import type { Effect, Stream } from "effect"
type PromisifyOperation<Operation> = Operation extends (
...args: infer Args
) => Effect.Effect<infer Success, unknown, unknown>
? (...args: Args) => Promise<Success>
: Operation extends (...args: infer Args) => Stream.Stream<infer Success, unknown, unknown>
? (...args: Args) => AsyncIterable<Success>
: Operation
type PromisifyApi<Api> = {
readonly [Name in keyof Api]: PromisifyOperation<Api[Name]>
}
export type AgentApi = PromisifyApi<EffectAgentApi<unknown>>
export type CommandApi = PromisifyApi<EffectCommandApi<unknown>>
export type EventApi = PromisifyApi<EffectEventApi<unknown>>
export type IntegrationApi = PromisifyApi<EffectIntegrationApi<unknown>>
export type ModelApi = PromisifyApi<EffectModelApi<unknown>>
export type PluginApi = PromisifyApi<EffectPluginApi<unknown>>
export type ProviderApi = PromisifyApi<EffectProviderApi<unknown>>
export type ReferenceApi = PromisifyApi<EffectReferenceApi<unknown>>
export type SessionApi = PromisifyApi<EffectSessionApi<unknown>>
export type SkillApi = PromisifyApi<EffectSkillApi<unknown>>
export interface CatalogApi {
readonly provider: ProviderApi
readonly model: ModelApi
}
+205 -83
View File
@@ -976,9 +976,27 @@ export type SessionContextOutput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
@@ -1109,7 +1127,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.selected"
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly agent: string }
@@ -1118,7 +1136,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "model.selected"
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1143,7 +1161,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "renamed"
readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string }
@@ -1152,7 +1170,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "forked"
readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
@@ -1161,7 +1179,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.promoted"
readonly type: "session.prompt.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
@@ -1170,7 +1188,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.admitted"
readonly type: "session.prompt.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1206,7 +1224,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "synthetic"
readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1220,7 +1238,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.activated"
readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
@@ -1229,25 +1247,59 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.started"
readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.ended"
readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
readonly output: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.started"
readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1262,7 +1314,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.ended"
readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1284,7 +1336,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.failed"
readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1297,7 +1349,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.started"
readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
@@ -1306,7 +1358,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.ended"
readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1320,7 +1372,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.started"
readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1334,7 +1386,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.ended"
readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1349,7 +1401,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.started"
readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1363,7 +1415,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.ended"
readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1377,7 +1429,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.called"
readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1396,7 +1448,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.progress"
readonly type: "session.tool.progress"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1414,7 +1466,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.success"
readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1438,7 +1490,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.failed"
readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1457,7 +1509,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "retried"
readonly type: "session.retried"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1477,7 +1529,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.started"
readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
@@ -1486,7 +1538,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.ended"
readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1500,7 +1552,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.staged"
readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -1524,7 +1576,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.cleared"
readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
@@ -1533,7 +1585,7 @@ export type SessionLogOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.committed"
readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
@@ -1617,9 +1669,27 @@ export type SessionMessageOutput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
@@ -1798,9 +1868,27 @@ export type MessageListOutput = {
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly type: "shell"
readonly callID: string
readonly command: string
readonly output: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
readonly output?: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
| {
readonly id: string
@@ -2339,7 +2427,7 @@ export type ServerMcpListOutput = {
readonly name: string
readonly status:
| { readonly status: "connected" }
| { readonly status: "disconnected" }
| { readonly status: "pending" }
| { readonly status: "disabled" }
| { readonly status: "failed"; readonly error: string }
| { readonly status: "needs_auth" }
@@ -3690,7 +3778,7 @@ export type CommandListOutput = {
readonly description?: string
readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly subtask?: boolean
readonly subagent?: boolean
}>
}
@@ -4309,7 +4397,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "agent.selected"
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly agent: string }
@@ -4318,7 +4406,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "model.selected"
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4343,7 +4431,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "renamed"
readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string }
@@ -4352,7 +4440,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "forked"
readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly parentID: string; readonly from?: string }
@@ -4361,7 +4449,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.promoted"
readonly type: "session.prompt.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
@@ -4370,7 +4458,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "prompt.admitted"
readonly type: "session.prompt.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4397,7 +4485,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "execution.settled"
readonly type: "session.execution.settled"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4418,7 +4506,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "synthetic"
readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4432,7 +4520,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.activated"
readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly name: string; readonly text: string }
@@ -4441,25 +4529,59 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.started"
readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly command: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "shell.ended"
readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly callID: string; readonly output: string }
readonly data: {
readonly sessionID: string
readonly shell: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number
readonly metadata: { readonly [x: string]: unknown }
readonly time: { readonly started: number; readonly completed?: number }
}
readonly output: {
readonly output: string
readonly cursor: number
readonly size: number
readonly truncated: boolean
}
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.started"
readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4474,7 +4596,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.ended"
readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4496,7 +4618,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "step.failed"
readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4509,7 +4631,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.started"
readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string }
@@ -4518,7 +4640,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.delta"
readonly type: "session.text.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4531,7 +4653,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "text.ended"
readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4545,7 +4667,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.started"
readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4559,7 +4681,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.delta"
readonly type: "session.reasoning.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4572,7 +4694,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "reasoning.ended"
readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4587,7 +4709,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.started"
readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4601,7 +4723,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.delta"
readonly type: "session.tool.input.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
@@ -4614,7 +4736,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.input.ended"
readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4628,7 +4750,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.called"
readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4647,7 +4769,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.progress"
readonly type: "session.tool.progress"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4665,7 +4787,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.success"
readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4689,7 +4811,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "tool.failed"
readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4708,7 +4830,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "retried"
readonly type: "session.retried"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4728,7 +4850,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.started"
readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "auto" | "manual" }
@@ -4737,7 +4859,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.delta"
readonly type: "session.compaction.delta"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly text: string }
}
@@ -4745,7 +4867,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "compaction.ended"
readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4759,7 +4881,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.staged"
readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
@@ -4783,7 +4905,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.cleared"
readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
@@ -4792,7 +4914,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "revert.committed"
readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string }
@@ -4801,9 +4923,9 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.edited"
readonly type: "filesystem.changed"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
}
| {
readonly id: string
@@ -4869,7 +4991,7 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "skill.updated"
readonly type: "config.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {}
}
@@ -4877,9 +4999,9 @@ export type EventSubscribeOutput =
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "file.watcher.updated"
readonly type: "skill.updated"
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly file: string; readonly event: "add" | "change" | "unlink" }
readonly data: {}
}
| {
readonly id: string
+13
View File
@@ -1,3 +1,16 @@
export * from "./generated/index"
export type {
AgentApi,
CatalogApi,
CommandApi,
EventApi,
IntegrationApi,
ModelApi,
PluginApi,
ProviderApi,
ReferenceApi,
SessionApi,
SkillApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
+10
View File
@@ -0,0 +1,10 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
declare const effectClient: EffectClient
const effectApi: EffectApi<unknown> = effectClient
void effectApi
+5 -5
View File
@@ -45,9 +45,9 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
return yield* client.event.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "model.selected"])
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.model.selected"])
const durable = events[1]
if (durable?.type !== "model.selected") throw new Error("Expected model event")
if (durable?.type !== "session.model.selected") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.created)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
@@ -159,8 +159,8 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["model.selected", "log.synced"])
expect(logged[0]?.type === "model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
1_717_171_717_000,
)
expect(logged.at(-1)).toEqual(synced)
@@ -228,7 +228,7 @@ const modelSwitchedMessage = {
const modelSwitchedEvent = {
id: "evt_model",
created: 1_717_171_717_000,
type: "model.selected",
type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: {
sessionID: "ses_test",
+2 -2
View File
@@ -160,7 +160,7 @@ test("event.subscribe exposes the Promise event stream wire projection", async (
for await (const event of client.event.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", created: 0, type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "model.selected" && events[1].created).toBe(1_717_171_717_000)
expect(events[1]?.type === "session.model.selected" && events[1].created).toBe(1_717_171_717_000)
})
test("event.subscribe terminates on malformed Promise SSE data", async () => {
@@ -329,7 +329,7 @@ const synced = { type: "log.synced", aggregateID: "ses_test", seq: 1 }
const modelSwitchedEvent = {
id: "evt_model",
created: 1_717_171_717_000,
type: "model.selected",
type: "session.model.selected",
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
data: {
sessionID: "ses_test",
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false,
"declaration": true
},
"include": ["src"]
}
+2
View File
@@ -3,6 +3,8 @@
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"allowImportingTsExtensions": false,
"allowJs": false,
"noUncheckedIndexedAccess": false
},
"include": ["src"]
+2 -13
View File
@@ -1,12 +1,11 @@
export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/app-node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Array, Context, Effect, Layer, Option, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Integration } from "./integration"
@@ -17,8 +16,6 @@ export type ProviderRecord = {
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export const PolicyActions = Schema.Literals(["provider.use"])
export const Event = Catalog.Event
type Data = {
@@ -65,7 +62,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const integrations = yield* Integration.Service
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
@@ -159,13 +155,6 @@ const layer = Layer.effect(
return result
},
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
if (policy.hasStatements()) {
for (const record of [...catalog.provider.list()]) {
if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") {
catalog.provider.remove(record.provider.id)
}
}
}
yield* events.publish(Event.Updated, {})
}),
})
@@ -294,4 +283,4 @@ const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
+99 -45
View File
@@ -3,18 +3,19 @@ export * as Config from "./config"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { EventV2 } from "./event"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { Policy } from "./policy"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
import { ConfigFormatter } from "./config/formatter"
import { ConfigLSP } from "./config/lsp"
import { ConfigMCP } from "./config/mcp"
@@ -22,6 +23,7 @@ import { ConfigPlugin } from "./config/plugin"
import { ConfigProvider } from "./config/provider"
import { ConfigReference } from "./config/reference"
import { ConfigToolOutput } from "./config/tool-output"
import { ConfigVariable } from "./config/variable"
import { ConfigWatcher } from "./config/watcher"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
@@ -102,7 +104,6 @@ export class Info extends Schema.Class<Info>("Config.Info")({
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
description: "Ordered external plugin packages to load",
}),
experimental: ConfigExperimental.Experimental.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
}) {}
@@ -138,7 +139,8 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const policy = yield* Policy.Service
const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const names = ["opencode.json", "opencode.jsonc"]
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
@@ -147,15 +149,16 @@ const layer = Layer.effect(
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
const input: unknown = parse(substituted, errors, { allowTrailingComma: true })
if (errors.length) return
const info = Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
: decodeInfo(normalizeCommandAliases(input)),
)
if (!info) return
return new Document({ type: "document", path: filepath, info })
@@ -170,45 +173,78 @@ const layer = Layer.effect(
]
})
const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Read configuration once when this location opens. Later calls reuse these
// values until the location is reopened.
const discovered = locationIsGlobal
? []
: yield* fs
.up({
targets: [".opencode", ...names.toReversed()],
start: location.directory,
stop: location.project.directory,
})
.pipe(Effect.orDie)
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
const discover = Effect.fn("Config.discover")(function* () {
const globalDirectory = AbsolutePath.make(global.config)
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
const discovered = locationIsGlobal
? []
: yield* fs
.up({
targets: [".opencode", ...names.toReversed()],
start: location.directory,
stop: location.project.directory,
})
.pipe(Effect.orDie)
const directories = [
globalDirectory,
...discovered
.filter((item) => path.basename(item) === ".opencode")
.toReversed()
.map((directory) => AbsolutePath.make(directory)),
]
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
return {
entries: [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
directories,
files: directPaths,
}
})
const initial = yield* discover()
let configs = initial.entries
const updates = yield* PubSub.unbounded<Watcher.Update>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const targets = (snapshot: typeof initial) => [
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
...snapshot.files
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
// A config closer to the opened directory should win over one higher up.
// Search starts nearby, so reverse the results before applying them.
const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed()
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
Effect.orDie,
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
)
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
// Apply general settings first and more specific settings last:
// global config, project files, then `.opencode` files.
const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
// Rules use the opposite order so a user-global rule can override a
// repository rule. Statement order inside each file stays unchanged.
yield* policy.load(
configs
.filter((config): config is Document => config.type === "document")
.toReversed()
.flatMap((config) => config.info.experimental?.policies ?? []),
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
for (const [key, stop] of subscriptions) {
if (next.has(key)) continue
yield* stop
subscriptions.delete(key)
}
for (const [key, target] of next) {
if (subscriptions.has(key)) continue
const fiber = yield* watcher.subscribe(target).pipe(
Stream.runForEach((update) => PubSub.publish(updates, update)),
Effect.forkScoped({ startImmediately: true }),
)
subscriptions.set(key, Fiber.interrupt(fiber))
}
})
yield* Stream.fromPubSub(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((update) =>
Effect.gen(function* () {
const next = yield* discover()
configs = next.entries
yield* reconcile(next)
yield* events.publish(ConfigSchema.Event.Updated, {})
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconcile(initial)
return Service.of({
entries: Effect.fn("Config.entries")(function* () {
@@ -221,5 +257,23 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node],
})
function normalizeCommandAliases(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return input
const commands = (input as Record<string, unknown>).commands
if (typeof commands !== "object" || commands === null || Array.isArray(commands)) return input
return {
...input,
commands: Object.fromEntries(
Object.entries(commands).map(([name, command]) => {
if (typeof command !== "object" || command === null || Array.isArray(command)) return [name, command]
const data = command as Record<string, unknown>
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return [name, command]
const { subtask, ...rest } = data
return [name, { ...rest, subagent: subtask }]
}),
),
}
}
+1 -1
View File
@@ -8,5 +8,5 @@ export class Info extends Schema.Class<Info>("ConfigV2.Command")({
agent: Schema.String.pipe(Schema.optional),
model: Schema.String.pipe(Schema.optional),
variant: Schema.String.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
subagent: Schema.Boolean.pipe(Schema.optional),
}) {}
-20
View File
@@ -1,20 +0,0 @@
export * as ConfigExperimental from "./experimental"
import { Schema } from "effect"
import { Catalog } from "../catalog"
import { Policy } from "../policy"
// Each core domain exports the policy actions it supports. Adding an action to
// this union makes it valid in authored config while keeping Policy generic.
export const PolicyAction = Schema.Union([Catalog.PolicyActions])
class PolicyConfig extends Schema.Class<PolicyConfig>("ConfigV2.Experimental.Policy")({
...Policy.Info.fields,
action: PolicyAction,
}) {}
export { PolicyConfig as Policy }
export class Experimental extends Schema.Class<Experimental>("ConfigV2.Experimental")({
policies: PolicyConfig.pipe(Schema.Array, Schema.optional),
}) {}
+67 -56
View File
@@ -2,7 +2,7 @@ export * as ConfigAgentPlugin from "./agent"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
@@ -38,64 +38,75 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
yield* ctx.agent.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([entry])
return Effect.gen(function* () {
const files = yield* discover(fs, entry.path)
return yield* Effect.forEach(files, (file) =>
fs.readFileStringSafe(file.filepath).pipe(
Effect.map((content) => content && decode(file, content)),
Effect.catch(() => Effect.succeed(undefined)),
),
).pipe(
Effect.map((documents) =>
documents.filter((document): document is Config.Document => document !== undefined),
),
)
})
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: yield* load() }
yield* ctx.agent.transform((draft) => {
const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...global))
}
for (const document of documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
for (const document of loaded.documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...global)
if (item.model !== undefined) {
const model = ModelV2.parse(item.model)
agent.model = { id: model.modelID, providerID: model.providerID, variant: agent.model?.variant }
}
if (item.variant !== undefined && agent.model !== undefined) {
agent.model.variant = ModelV2.VariantID.make(item.variant)
}
if (item.request !== undefined) {
Object.assign(agent.request.headers, item.request.headers ?? {})
Object.assign(agent.request.body, item.request.body ?? {})
}
if (item.system !== undefined) agent.system = item.system
if (item.description !== undefined) agent.description = item.description
if (item.mode !== undefined) agent.mode = item.mode
if (item.hidden !== undefined) agent.hidden = item.hidden
if (item.color !== undefined) agent.color = item.color
if (item.steps !== undefined) agent.steps = item.steps
if (item.permissions !== undefined) agent.permissions.push(...item.permissions)
})
}
}),
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+48 -29
View File
@@ -2,7 +2,7 @@ export * as ConfigCommandPlugin from "./command"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "../../fs-util"
@@ -17,34 +17,45 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
yield* ctx.command.transform(
Effect.fn(function* (draft) {
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
for (const document of documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subtask !== undefined) item.subtask = command.subtask
})
}
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
return yield* Effect.forEach(yield* config.entries(), (entry) => {
if (entry.type === "document") return Effect.succeed([{ commands: entry.info.commands }])
return loadDirectory(fs, entry.path).pipe(
Effect.map((commands) => [
{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) },
]),
)
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: yield* load() }
yield* ctx.command.transform((draft) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.update(name, (item) => {
item.template = command.template
if (command.description !== undefined) item.description = command.description
if (command.agent !== undefined) item.agent = command.agent
if (command.model !== undefined) {
const model = ModelV2.parse(command.model)
item.model = { id: model.modelID, providerID: model.providerID, variant: item.model?.variant }
}
if (command.variant !== undefined && item.model !== undefined) {
item.model.variant = ModelV2.VariantID.make(command.variant)
}
if (command.subagent !== undefined) item.subagent = command.subagent
})
}
}),
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
@@ -70,7 +81,9 @@ function loadDirectory(fs: FSUtil.Interface, directory: string) {
function decode(directory: string, filepath: string, content: string) {
const markdown = ConfigMarkdown.parseOption(content)
if (!markdown) return
const info = Option.getOrUndefined(decodeCommand({ ...markdown.data, template: markdown.content.trim() }))
const info = Option.getOrUndefined(
decodeCommand({ ...normalizeFrontmatter(markdown.data), template: markdown.content.trim() }),
)
if (!info) return
return {
name: path
@@ -81,3 +94,9 @@ function decode(directory: string, filepath: string, content: string) {
info,
}
}
function normalizeFrontmatter(data: Record<string, unknown>) {
if (data.subagent !== undefined || typeof data.subtask !== "boolean") return data
const { subtask, ...rest } = data
return { ...rest, subagent: subtask }
}
+45 -11
View File
@@ -2,7 +2,8 @@ export * as ConfigExternalPlugin from "./external"
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { createRequire } from "node:module"
import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config"
@@ -35,6 +36,9 @@ const PluginPackage = Schema.Struct({
module: Schema.optional(Schema.String),
})
let importGeneration = 0
const moduleCache = createRequire(import.meta.url).cache
export const Plugin = define({
id: "config-plugin",
effect: Effect.fn(function* (ctx) {
@@ -42,8 +46,9 @@ export const Plugin = define({
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
yield* Effect.gen(function* () {
const configured: { package: string; options?: Record<string, any> }[] = []
const active = new Set<string>()
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
const configured: { package: string; options?: Record<string, unknown> }[] = []
for (const entry of yield* config.entries()) {
if (entry.type === "document") {
@@ -98,26 +103,55 @@ export const Plugin = define({
}
}
for (const ref of configured) {
yield* Effect.gen(function* () {
return yield* Effect.forEach(configured, (ref) =>
Effect.gen(function* () {
const entrypoint = path.isAbsolute(ref.package)
? pathToFileURL(ref.package).href
: (yield* npm.add(ref.package)).entrypoint
if (!entrypoint) return
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
const mod = yield* Effect.promise(() => import(entrypoint))
const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
yield* ctx.plugin.add({
return {
id: plugin.id,
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
})
}).pipe(Effect.ignoreCause)
}
effect: (host: Parameters<typeof plugin.effect>[0]) =>
plugin.effect({ ...host, options: ref.options ?? {} }),
}
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
),
),
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
})
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
const plugins = yield* load()
const next = new Set(plugins.map((plugin) => plugin.id))
for (const id of active) {
if (!next.has(id)) yield* ctx.plugin.remove(id)
}
for (const plugin of plugins) yield* ctx.plugin.add(plugin)
active.clear()
for (const id of next) active.add(id)
})
yield* reconcile()
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconcile()),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
function cacheBust(entrypoint: string) {
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
url.searchParams.set("opencode-reload", String(++importGeneration))
return url.href
}
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
+102 -95
View File
@@ -1,7 +1,7 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "../../plugin/internal"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
@@ -9,108 +9,115 @@ export const Plugin = define({
id: "config-provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
const loaded = { entries: yield* config.entries() }
yield* ctx.integration.transform((integrations) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredIntegrations = new Set(
files.flatMap((file) =>
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
provider.env === undefined ? [] : [id],
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
),
)
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const integrationID = id
if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue
integrations.update(integrationID, (integration) => {
integration.name = item.name ?? integration.name
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
if (item.env !== undefined) {
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}
}
}),
)
}
})
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.settings, item.request.settings)
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
yield* ctx.catalog.transform((catalog) => {
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
const configuredDefault = Config.latest(loaded.entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
if (item.name !== undefined) provider.name = item.name
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.settings, item.request.settings)
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
}
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
}
if (config.request !== undefined) {
Object.assign(model.request.settings, config.request.settings)
Object.assign(model.request.headers, config.request.headers)
Object.assign(model.request.body, config.request.body)
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
for (const variant of config.variants) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = {
id: variant.id,
settings: {},
headers: {},
body: {},
}
model.variants.push(existing)
}
Object.assign(existing.settings, variant.settings)
Object.assign(existing.headers, variant.headers)
Object.assign(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? 0,
write: cost.cache?.write ?? 0,
},
}))
}
if (config.disabled !== undefined) model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
}),
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+41 -34
View File
@@ -2,7 +2,7 @@ export * as ConfigReferencePlugin from "./reference"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Reference } from "../../reference"
@@ -16,40 +16,47 @@ export const Plugin = define({
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
yield* ctx.reference.transform(
Effect.fn(function* (draft) {
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
const loaded = { entries: yield* config.entries() }
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
name,
local(entry)
? Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
})
: Reference.GitSource.make({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
...(entry.branch === undefined ? {} : { branch: entry.branch }),
...(description === undefined ? {} : { description }),
...(hidden === undefined ? {} : { hidden }),
}),
)
}
for (const [name, source] of entries) draft.add(name, source)
}),
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+38 -30
View File
@@ -2,7 +2,7 @@ export * as ConfigSkillPlugin from "./skill"
import { define } from "../../plugin/internal"
import path from "path"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { AbsolutePath } from "../../schema"
import { SkillV2 } from "../../skill"
@@ -15,36 +15,44 @@ export const Plugin = define({
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
yield* ctx.skill.transform(
Effect.fn(function* (draft) {
const entries = yield* config.entries()
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
const loaded = { entries: yield* config.entries() }
yield* ctx.skill.transform((draft) => {
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of directories) {
draft.source(
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
)
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
}),
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.skill.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+85
View File
@@ -0,0 +1,85 @@
export * as ConfigVariable from "./variable"
import os from "os"
import path from "path"
import { Effect } from "effect"
import { FSUtil } from "../fs-util"
import { InvalidError } from "../v1/config/error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
const text = input.text.replace(
/\{env:([^}]+)\}/g,
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
)
if (!text.includes("{file:")) return text
return yield* substituteFiles(input, text)
})
const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, text: string) {
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const matches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
let out = ""
let cursor = 0
for (const match of matches) {
const token = match[0]
const index = match.index
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
const filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
cursor = index + token.length
}
return out + text.slice(cursor)
})
+1
View File
@@ -43,5 +43,6 @@ export const migrations = (
import("./migration/20260702134641_add_session_context_entry"),
import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703190000_reset_v2_shell_event_payloads",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,83 @@
export * as LocationWatcher from "./location-watcher"
import { makeLocationNode } from "../effect/app-node"
import { Context, Effect, Layer, Stream } from "effect"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { Watcher } from "./watcher"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
}
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationWatcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
if (path.resolve(location.directory) !== path.resolve(os.homedir())) {
yield* watcher
.subscribe({
path: location.directory,
type: "directory",
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
})
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
} else {
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
}
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* watcher
.subscribe({ path: vcs, type: "directory", ignore })
.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) =>
Effect.logError("failed to init location watcher service", { cause }).pipe(Effect.as(Service.of({}))),
),
),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
+121 -101
View File
@@ -3,26 +3,20 @@ export * as Watcher from "./watcher"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { makeLocationNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import os from "os"
import path from "path"
import { Config } from "../config"
import { EventV2 } from "../event"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "../effect/app-node"
import { Cause, Context, Effect, Layer, PubSub, Scope, Stream } from "effect"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Flag } from "../flag/flag"
import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
declare const OPENCODE_LIBC: string | undefined
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = FileSystemWatcher.Event
export const Event = { Updated: FileSystem.Event.Changed }
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
@@ -42,108 +36,134 @@ function getBackend() {
if (process.platform === "linux") return "inotify"
}
function protecteds(dir: string) {
return Protected.paths().filter((item) => {
const relative = path.relative(dir, item)
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative)
})
export const hasNativeBinding = () => !!watcher()
export type Update = ParcelWatcher.Event
export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
}
export const hasNativeBinding = () => !!watcher()
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (Flag.OPENCODE_DISABLE_FILEWATCHER) return Service.of({})
const backend = getBackend()
const location = yield* Location.Service
if (path.resolve(location.directory) === path.resolve(os.homedir())) {
yield* Effect.logInfo("watcher skipped home directory", { directory: location.directory })
return Service.of({})
}
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
return Service.of({})
const native = watcher()
if (Flag.OPENCODE_DISABLE_FILEWATCHER) {
return Service.of({ subscribe: () => Stream.empty })
}
const w = watcher()
if (!w) return Service.of({})
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const subscriptions: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
Effect.promise(() => Promise.allSettled(subscriptions.map((subscription) => subscription.unsubscribe()))),
)
const callback: ParcelWatcher.SubscribeCallback = (_error, updates) => {
if (_error) runFork(Effect.logError("watcher callback failed", { error: _error }))
for (const update of updates) {
if (update.type === "create") runFork(events.publish(Event.Updated, { file: update.path, event: "add" }))
if (update.type === "update") runFork(events.publish(Event.Updated, { file: update.path, event: "change" }))
if (update.type === "delete") runFork(events.publish(Event.Updated, { file: update.path, event: "unlink" }))
}
type Entry = {
readonly pubsub: PubSub.PubSub<Update>
readonly subscription: { readonly unsubscribe: () => Promise<void> }
refs: number
}
const entries = new Map<string, Entry>()
const locks = KeyedMutex.makeUnsafe<string>()
const subscribe = (directory: string, ignore: string[]) => {
const pending = w.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.tap((subscription) =>
Effect.sync(() => subscriptions.push(subscription)).pipe(
Effect.andThen(Effect.logInfo("watcher subscribed", { directory, backend, ignores: ignore.length })),
),
),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
const acquire = Effect.fn("Watcher.acquire")(function* (input: WatchInput) {
const scope = yield* Scope.Scope
const target = path.resolve(input.path)
const directory = input.type === "file" ? path.dirname(target) : target
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
const id = JSON.stringify([input.type, target, ignore])
const pubsub = yield* locks.withLock(id)(
Effect.gen(function* () {
const existing = entries.get(id)
if (existing) {
existing.refs++
return existing.pubsub
}
const pubsub = yield* PubSub.unbounded<Update>()
const subscription = yield* input.type === "file"
? Effect.sync(() => {
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== target) return
PubSub.publishUnsafe(pubsub, {
path: target,
type: "update",
} satisfies Update)
})
if ("on" in subscription && typeof subscription.on === "function") {
subscription.on("error", (error: unknown) =>
Effect.runFork(Effect.logError("watcher callback failed", { path: target, error })),
)
}
return { unsubscribe: () => Promise.resolve(subscription.close()) }
})
: subscribeDirectory(native, backend, directory, ignore, pubsub)
if (subscription) {
entries.set(id, { pubsub, subscription, refs: 1 })
yield* Effect.logInfo("watcher started", {
path: target,
type: input.type,
backend: input.type === "file" ? "node" : backend,
ignores: ignore.length,
})
return pubsub
}
yield* PubSub.shutdown(pubsub)
return pubsub
}),
)
}
const configService = yield* Config.Service
const config = (yield* configService.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((item) => item.info.watcher?.ignore ?? [])
yield* Effect.forkScoped(
subscribe(location.directory, [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)]),
)
if (location.vcs?.type === "git") {
const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory
const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined
if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) {
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
)
yield* Effect.forkScoped(subscribe(vcs, ignore))
}
}
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
yield* Scope.addFinalizer(
scope,
locks.withLock(id)(
Effect.gen(function* () {
const entry = entries.get(id)
if (!entry) return
entry.refs--
if (entry.refs > 0) return
entries.delete(id)
yield* Effect.promise(() => entry.subscription.unsubscribe()).pipe(Effect.ignore)
yield* PubSub.shutdown(entry.pubsub)
yield* Effect.logInfo("watcher stopped", { path: target, type: input.type })
}),
),
)
}),
),
return pubsub
})
const subscribe = (input: WatchInput) =>
Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
return Service.of({ subscribe })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,
backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: string[],
pubsub: PubSub.PubSub<Update>,
) {
if (!native || !backend) {
return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe(
Effect.as(undefined),
)
}
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) PubSub.publishUnsafe(pubsub, update)
}
const pending = native.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", {
directory,
cause: Cause.pretty(cause),
}).pipe(Effect.as(undefined))
}),
)
}
+2 -4
View File
@@ -11,7 +11,7 @@ import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Generate } from "./generate"
import { Form } from "./form"
import { Watcher } from "./filesystem/watcher"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Image } from "./image"
import { Integration } from "./integration"
import { Location } from "./location"
@@ -21,7 +21,6 @@ import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
@@ -50,7 +49,6 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [
Location.node,
Policy.node,
Config.node,
AgentV2.node,
CommandV2.node,
@@ -64,7 +62,7 @@ const locationServiceNodes = [
ProjectCopy.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Watcher.node,
LocationWatcher.node,
Pty.node,
Shell.node,
SkillV2.node,
+1 -1
View File
@@ -205,7 +205,7 @@ export const layer = Layer.effect(
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), {
config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "disconnected" },
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}
+1
View File
@@ -18,6 +18,7 @@ export const Plugin = define({
draft.update("review", (command) => {
command.template = PROMPT_REVIEW.replace("${path}", location.project.directory)
command.description = "review changes [commit|branch|pr], defaults to uncommitted"
command.subagent = true
})
})
}),
+71 -17
View File
@@ -1,12 +1,14 @@
export * as PluginHost from "./host"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Schema } from "effect"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import { Effect, Schema, Stream } from "effect"
import { AgentV2 } from "../agent"
import { AISDK } from "../aisdk"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
import { Credential } from "../credential"
import { EventV2 } from "../event"
import { Integration } from "../integration"
import { Location } from "../location"
import { ModelV2 } from "../model"
@@ -21,12 +23,14 @@ import { ToolHooks } from "../tool/hooks"
import { WorkspaceV2 } from "../workspace"
const mutable = <T>(value: T) => value as DeepMutable<T>
const isEvent = Schema.is(Schema.Union(EventManifest.ServerDefinitions))
export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Interface) {
const agents = yield* AgentV2.Service
const aisdk = yield* AISDK.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const integration = yield* Integration.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
@@ -52,6 +56,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
})
const isCurrentLocation = (ref: Location.Ref) =>
ref.directory === location.directory && ref.workspaceID === location.workspaceID
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
return {
options: {},
@@ -63,15 +69,15 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
},
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) =>
agents.transform((draft) => {
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(AgentV2.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : AgentV2.ID.make(id)),
update: (id, update) => draft.update(AgentV2.ID.make(id), update),
remove: (id) => draft.remove(AgentV2.ID.make(id)),
}),
),
})
}),
},
aisdk: {
sdk: (callback) =>
@@ -102,9 +108,26 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
}),
},
catalog: {
provider: {
list: () => response(catalog.provider.available()),
get: (input) =>
catalog.provider
.get(ProviderV2.ID.make(input.providerID))
.pipe(
Effect.flatMap((provider) =>
provider === undefined
? Effect.fail(new Error(`Provider not found: ${input.providerID}`))
: response(Effect.succeed(provider)),
),
),
},
model: {
list: () => response(catalog.model.available()),
default: () => response(catalog.model.default()),
},
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) =>
catalog.transform((draft) => {
callback({
provider: {
list: () => mutable(draft.provider.list()),
@@ -125,14 +148,42 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
draft.model.default.set(ProviderV2.ID.make(providerID), ModelV2.ID.make(modelID)),
},
},
}),
),
})
}),
},
command: {
list: () => response(commands.list()),
reload: commands.reload,
transform: commands.transform,
transform: (callback) =>
commands.transform((draft) => {
callback(draft)
}),
},
event: {
subscribe: () => events.live().pipe(Stream.filter(isEvent)),
},
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
key: input.key,
label: input.label,
}),
connectOauth: (input) =>
response(
integration.connection.oauth({
integrationID: Integration.ID.make(input.integrationID),
methodID: Integration.MethodID.make(input.methodID),
inputs: input.inputs,
label: input.label,
}),
),
attemptStatus: (input) => response(integration.attempt.status(Integration.AttemptID.make(input.attemptID))),
attemptComplete: (input) =>
integration.attempt.complete({ attemptID: Integration.AttemptID.make(input.attemptID), code: input.code }),
attemptCancel: (input) => integration.attempt.cancel(Integration.AttemptID.make(input.attemptID)),
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
@@ -142,7 +193,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
),
},
transform: (callback) =>
integration.transform((draft) =>
integration.transform((draft) => {
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Integration.ID.make(id))),
@@ -219,33 +270,36 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
}),
),
})
}),
},
plugin: {
list: () => response(plugin.list()),
add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
remove: (id) => plugin.remove(PluginV2.ID.make(id)),
},
reference: {
list: () => response(reference.list()),
reload: reference.reload,
transform: (callback) =>
reference.transform((draft) =>
reference.transform((draft) => {
callback({
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove,
list: draft.list,
}),
),
})
}),
},
skill: {
list: () => response(skill.list()),
reload: skill.reload,
transform: (callback) =>
skill.transform((draft) =>
skill.transform((draft) => {
callback({
source: (source) => draft.source(Schema.decodeUnknownSync(SkillV2.Source)(source)),
list: draft.list,
}),
),
})
}),
},
tool: {
register: (input) => tools.register(input),
+55 -54
View File
@@ -201,64 +201,65 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
yield* ctx.integration.transform(
Effect.fn(function* (integrations) {
const data = yield* modelsDev.get()
for (const item of Object.values(data)) {
if (item.env.length === 0) continue
const integrationID = item.id
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
}),
)
yield* ctx.catalog.transform(
Effect.fn(function* (catalog) {
const data = yield* modelsDev.get()
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.api = item.npm
? {
type: "aisdk",
package: item.npm,
url: item.api,
}
: {
type: "native",
url: item.api,
settings: {},
}
})
const loaded = { data: yield* modelsDev.get() }
yield* ctx.integration.transform((integrations) => {
for (const item of Object.values(loaded.data)) {
if (item.env.length === 0) continue
const integrationID = item.id
integrations.update(integrationID, (integration) => (integration.name = item.name))
integrations.method.update({
integrationID,
method: { type: "key" },
})
integrations.method.update({
integrationID,
method: { type: "env", names: [...item.env] },
})
}
})
yield* ctx.catalog.transform((catalog) => {
for (const item of Object.values(loaded.data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.api = item.npm
? {
type: "aisdk",
package: item.npm,
url: item.api,
}
: {
type: "native",
url: item.api,
settings: {},
}
})
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, {
name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost),
request: options.provider,
variants,
}),
)
}
for (const model of Object.values(item.models)) {
const baseCost = cost(model.cost)
const variants = reasoningVariants(item, model)
catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants }))
for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) {
catalog.model.update(providerID, `${model.id}-${mode}`, (draft) =>
applyModel(draft, model, {
name: modeName(model, mode),
cost: mergeCost(baseCost, options.cost),
request: options.provider,
variants,
}),
)
}
}
}),
)
}
})
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload()))),
Stream.runForEach(() =>
modelsDev.get().pipe(
Effect.tap((data) => Effect.sync(() => (loaded.data = data))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
+41 -10
View File
@@ -1,12 +1,11 @@
export * as PluginPromise from "./promise"
import { define } from "@opencode-ai/plugin/v2/effect"
import type { Plugin, PluginContext, Registration } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope } from "effect"
import type { Plugin, PluginContext } from "@opencode-ai/plugin/v2/promise"
import { Effect, Scope, Stream } from "effect"
// The Effect host hands back this registration shape; mirror it structurally so
// we do not have to alias the Effect package's `Registration` against the Promise one.
type HostRegistration = { readonly dispose: Effect.Effect<void> }
type Registration = { readonly dispose: () => Promise<void> }
/**
* Adapts a Promise plugin into an Effect plugin so the existing Effect-only
@@ -31,20 +30,23 @@ export function fromPromise(plugin: Plugin) {
dispose: () => Effect.runPromiseWith(context)(registration.dispose),
}))
const run = (effect: Effect.Effect<void>) => Effect.runPromiseWith(context)(effect)
const run = <A, E>(effect: Effect.Effect<A, E>) => Effect.runPromiseWith(context)(effect)
const transform =
<Draft>(domain: {
transform: (
callback: (draft: Draft) => Effect.Effect<void> | void,
) => Effect.Effect<HostRegistration, never, Scope.Scope>
transform: (callback: (draft: Draft) => void) => Effect.Effect<HostRegistration, never, Scope.Scope>
}) =>
(callback: (draft: Draft) => Promise<void> | void) =>
register(domain.transform((draft) => Effect.promise(() => Promise.resolve(callback(draft)))))
(callback: (draft: Draft) => void) =>
register(
domain.transform((draft) => {
callback(draft)
}),
)
const context2: PluginContext = {
options: host.options,
agent: {
list: (input) => run(host.agent.list(input)),
transform: transform(host.agent),
reload: () => run(host.agent.reload()),
},
@@ -55,14 +57,33 @@ export function fromPromise(plugin: Plugin) {
register(host.aisdk.language((event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
catalog: {
provider: {
list: (input) => run(host.catalog.provider.list(input)),
get: (input) => run(host.catalog.provider.get(input)),
},
model: {
list: (input) => run(host.catalog.model.list(input)),
default: (input) => run(host.catalog.model.default(input)),
},
transform: transform(host.catalog),
reload: () => run(host.catalog.reload()),
},
command: {
list: (input) => run(host.command.list(input)),
transform: transform(host.command),
reload: () => run(host.command.reload()),
},
event: {
subscribe: () => Stream.toAsyncIterable(host.event.subscribe()),
},
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),
attemptComplete: (input) => run(host.integration.attemptComplete(input)),
attemptCancel: (input) => run(host.integration.attemptCancel(input)),
transform: transform(host.integration),
reload: () => run(host.integration.reload()),
connection: {
@@ -71,6 +92,7 @@ export function fromPromise(plugin: Plugin) {
},
},
plugin: {
list: (input) => run(host.plugin.list(input)),
add: (input) => {
const child = fromPromise(input)
return run(host.plugin.add(child))
@@ -78,13 +100,22 @@ export function fromPromise(plugin: Plugin) {
remove: (id) => run(host.plugin.remove(id)),
},
reference: {
list: (input) => run(host.reference.list(input)),
transform: transform(host.reference),
reload: () => run(host.reference.reload()),
},
skill: {
list: (input) => run(host.skill.list(input)),
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
session: {
create: (input) => run(host.session.create(input)),
get: (input) => run(host.session.get(input)),
prompt: (input) => run(host.session.prompt(input)),
command: (input) => run(host.session.command(input)),
interrupt: (input) => run(host.session.interrupt(input)),
},
}
yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
@@ -62,22 +62,20 @@ function selectMantleModel(sdk: MantleSDK, modelID: string) {
export const AmazonBedrockPlugin = define({
id: "amazon-bedrock",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (typeof provider.request.body.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.api.url = provider.request.body.endpoint
delete provider.request.body.endpoint
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (typeof provider.request.body.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.api.url = provider.request.body.endpoint
delete provider.request.body.endpoint
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (!["@ai-sdk/amazon-bedrock", "@ai-sdk/amazon-bedrock/mantle"].includes(evt.package)) return
+10 -12
View File
@@ -4,18 +4,16 @@ import { define } from "../internal"
export const AnthropicPlugin = define({
id: "anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return
+25 -29
View File
@@ -13,21 +13,19 @@ function selectLanguage(sdk: any, modelID: string, useChat: boolean) {
export const AzurePlugin = define({
id: "azure",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue
const configured = item.provider.request.body.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.resourceName = resourceName
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/azure") continue
const configured = item.provider.request.body.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.resourceName = resourceName
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
@@ -58,20 +56,18 @@ export const AzurePlugin = define({
export const AzureCognitiveServicesPlugin = define({
id: "azure-cognitive-services",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.body.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
})
yield* ctx.aisdk.language(
Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
+9 -11
View File
@@ -4,17 +4,15 @@ import { define } from "../internal"
export const CerebrasPlugin = define({
id: "cerebras",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/cerebras") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return
@@ -9,18 +9,16 @@ const providerID = ProviderV2.ID.make("cloudflare-workers-ai")
export const CloudflareWorkersAIPlugin = define({
id: "cloudflare-workers-ai",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (provider.api.url) return
const accountId = resolveAccountId(provider.request.body)
if (accountId) provider.api.url = workersEndpoint(accountId)
})
}),
)
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (provider.api.type !== "aisdk") return
if (provider.api.url) return
const accountId = resolveAccountId(provider.request.body)
if (accountId) provider.api.url = workersEndpoint(accountId)
})
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
@@ -14,17 +14,15 @@ function shouldUseResponses(modelID: string) {
export const GithubCopilotPlugin = define({
id: "github-copilot",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
}),
)
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/github-copilot") return
@@ -57,33 +57,31 @@ function authFetch(fetchWithRuntimeOptions?: unknown) {
export const GoogleVertexPlugin = define({
id: "google-vertex",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (
item.provider.api.package !== "@ai-sdk/google-vertex" &&
!(
item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (
item.provider.api.package !== "@ai-sdk/google-vertex" &&
!(
item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
continue
const project = resolveProject(item.provider.request.body)
const location = String(resolveLocation(item.provider.request.body))
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
if (provider.api.type === "aisdk" && provider.api.url) {
provider.api.url = replaceVertexVars(provider.api.url, project, location)
}
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
provider.request.body.fetch = authFetch(provider.request.body.fetch)
}
})
}
}),
)
)
continue
const project = resolveProject(item.provider.request.body)
const location = String(resolveLocation(item.provider.request.body))
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
if (provider.api.type === "aisdk" && provider.api.url) {
provider.api.url = replaceVertexVars(provider.api.url, project, location)
}
if (provider.api.type === "aisdk" && provider.api.package.includes("@ai-sdk/openai-compatible")) {
provider.request.body.fetch = authFetch(provider.request.body.fetch)
}
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.model.providerID === ProviderV2.ID.googleVertex && evt.package.includes("@ai-sdk/openai-compatible")) {
@@ -115,28 +113,26 @@ export const GoogleVertexPlugin = define({
export const GoogleVertexAnthropicPlugin = define({
id: "google-vertex-anthropic",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
const project =
item.provider.request.body.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
item.provider.request.body.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/google-vertex/anthropic") continue
const project =
item.provider.request.body.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
item.provider.request.body.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.request.body.project = project
provider.request.body.location = location
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
+11 -13
View File
@@ -4,18 +4,16 @@ import { define } from "../internal"
export const KiloPlugin = define({
id: "kilo",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
}
})
}),
})
+15 -16
View File
@@ -6,21 +6,20 @@ export const LLMGatewayPlugin = define({
id: "llmgateway",
effect: Effect.fn(function* (ctx) {
const integrations = yield* Integration.Service
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Source"] = "opencode"
})
}
}),
)
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (!configured.has(Integration.ID.make(item.provider.id))) continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-Source"] = "opencode"
})
}
})
}),
})
+12 -14
View File
@@ -4,19 +4,17 @@ import { define } from "../internal"
export const NvidiaPlugin = define({
id: "nvidia",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
provider.request.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
})
}),
})
+26 -28
View File
@@ -177,34 +177,32 @@ export const OpenAIPlugin = define({
draft.method.update(browser)
draft.method.update(headless)
})
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(ProviderV2.ID.openai)
if (!item) return
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
evt.model.update(item.provider.id, model.id, (draft) => {
if (!OpenAICodex.eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = []
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
if (!chatgpt) return
const item = evt.provider.get(ProviderV2.ID.openai)
if (!item) return
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
evt.model.update(item.provider.id, model.id, (draft) => {
if (!OpenAICodex.eligible(draft.api.id)) {
draft.enabled = false
return
}
draft.cost = []
})
}
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
+16 -18
View File
@@ -5,26 +5,24 @@ import { define } from "../internal"
export const OpenRouterPlugin = define({
id: "openrouter",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.request.headers["X-Title"] = "opencode"
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
}
}
}),
)
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
+10 -12
View File
@@ -4,18 +4,16 @@ import { define } from "../internal"
export const VercelPlugin = define({
id: "vercel",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["http-referer"] = "https://opencode.ai/"
provider.request.headers["x-title"] = "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["http-referer"] = "https://opencode.ai/"
provider.request.headers["x-title"] = "opencode"
})
}
})
yield* ctx.aisdk.sdk(
Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return
+11 -13
View File
@@ -4,18 +4,16 @@ import { define } from "../internal"
export const ZenmuxPlugin = define({
id: "zenmux",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform(
Effect.fn(function* (evt) {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.request.headers["X-Title"] ??= "opencode"
})
}
}),
)
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.api.type !== "aisdk") continue
if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.api.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.request.headers["X-Title"] ??= "opencode"
})
}
})
}),
})
@@ -303,7 +303,8 @@ model: anthropic/claude-sonnet-4-6
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
- Optional: `description`, `agent`, `model`, `variant`, `subagent`.
- `subtask` is still accepted for older commands and is treated like `subagent`.
## Plugins
-48
View File
@@ -1,48 +0,0 @@
export * as Policy from "./policy"
import { makeLocationNode } from "./effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Location } from "./location"
const PolicyEffect = Schema.Literals(["allow", "deny"]).annotate({ identifier: "Policy.Effect" })
export { PolicyEffect as Effect }
export type Effect = typeof PolicyEffect.Type
export class Info extends Schema.Class<Info>("Policy.Info")({
action: Schema.String,
effect: PolicyEffect,
resource: Schema.String,
}) {}
export interface Interface {
readonly load: (statements: Info[]) => Effect.Effect<void>
readonly evaluate: (action: string, resource: string, fallback: Effect) => Effect.Effect<Effect>
readonly hasStatements: () => boolean
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
let statements: Info[] = []
yield* Location.Service
return Service.of({
load: Effect.fn("Policy.load")(function* (input) {
statements = input
}),
hasStatements: () => statements.length > 0,
evaluate: Effect.fn("Policy.evaluate")(function* (action, resource, fallback) {
return (
statements.findLast(
(statement) => Wildcard.match(action, statement.action) && Wildcard.match(resource, statement.resource),
)?.effect ?? fallback
)
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
+118 -29
View File
@@ -41,10 +41,14 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
import { SkillV2 } from "./skill"
import { Job } from "./job"
import { CommandV2 } from "./command"
import { Identifier } from "./util/identifier"
import { Shell } from "./shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
const SUBAGENT_COMMAND_STARTED =
"The command is running in a background subagent session. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
const SUBAGENT_COMMAND_NO_TEXT = "Subagent command completed without a text response."
export const RevertState = Revert.State
export type RevertState = Revert.State
@@ -272,19 +276,6 @@ const layer = Layer.effect(
),
)
// Session shell is user-initiated and synchronous at the API boundary, while
// the Location shell service owns process lifecycle and file-backed output.
const runShellCommand = (command: string, cwd: string) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const info = yield* shell.create({ command, cwd })
yield* shell.wait(info.id)
const output = yield* shell.output(info.id, { limit: SHELL_MAX_CAPTURE_BYTES })
return output.output || "(no output)"
}).pipe(
Effect.catchTag("Shell.NotFoundError", () => Effect.succeed("Shell command output is no longer available.")),
)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
@@ -524,14 +515,77 @@ const layer = Layer.effect(
})
const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments })
// TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands.
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
const commandAgent = command.agent ? yield* agents.get(AgentV2.ID.make(command.agent)) : undefined
const model = command.model ?? commandAgent?.model ?? input.model ?? session.model
const subagent = command.subagent ?? false
if (subagent) {
const childAgent = AgentV2.ID.make(command.agent ?? "general")
const childAgentInfo = yield* agents.get(childAgent)
const title = command.description ?? input.command
const child = yield* result.create({
parentID: input.sessionID,
title,
agent: childAgent,
model: command.model ?? childAgentInfo?.model ?? input.model ?? session.model,
})
const completion = (state: "completed" | "error" | "cancelled", text: string) =>
result.synthetic({
sessionID: input.sessionID,
text: `<subagent id="${child.id}" state="${state}" description="${title}">\n${text}\n</subagent>`,
description: command.description,
metadata: { command: input.command, subagent: true, sessionID: child.id },
})
const admitted = yield* result.prompt({
id: input.id,
sessionID: child.id,
prompt: { text: evaluated.text, files: input.files, agents: input.agents },
delivery: input.delivery,
resume: false,
})
yield* jobs.start({
id: child.id,
type: "subagent",
title,
metadata: { command: input.command, parentID: input.sessionID, agent: childAgent.toString() },
run: Effect.gen(function* () {
yield* result.resume(child.id)
const messages = yield* result.messages({ sessionID: child.id, order: "desc", limit: 20 })
const assistant = messages.find(
(message) =>
message.type === "assistant" && message.time.completed !== undefined && message.error === undefined,
)
if (assistant === undefined || assistant.type !== "assistant") return SUBAGENT_COMMAND_NO_TEXT
const text = assistant.content
.filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
return text.length > 0 ? text : SUBAGENT_COMMAND_NO_TEXT
}).pipe(Effect.onInterrupt(() => result.interrupt(child.id))),
})
yield* jobs.background(child.id)
yield* jobs.wait({ id: child.id }).pipe(
Effect.flatMap((result) => {
if (result.info?.status === "completed")
return completion("completed", result.info.output ?? SUBAGENT_COMMAND_NO_TEXT)
if (result.info?.status === "error")
return completion("error", result.info.error ?? "Subagent command failed")
if (result.info?.status === "cancelled") return completion("cancelled", "Subagent command cancelled")
return Effect.void
}),
Effect.forkIn(scope, { startImmediately: true }),
)
yield* result.synthetic({
sessionID: input.sessionID,
text: `<subagent id="${child.id}" state="running" description="${title}">\n${SUBAGENT_COMMAND_STARTED}\n</subagent>`,
description: command.description,
metadata: { command: input.command, subagent: true, sessionID: child.id },
})
return admitted
}
const agent = command.agent ?? input.agent
const commandAgent = yield* Effect.gen(function* () {
if (!command.agent) return undefined
const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* agents.get(AgentV2.ID.make(command.agent))
})
const model = command.model ?? commandAgent?.model ?? input.model
if (agent !== undefined && session.agent !== AgentV2.ID.make(agent))
yield* result.switchAgent({ sessionID: input.sessionID, agent })
if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model })
@@ -550,23 +604,38 @@ const layer = Layer.effect(
Effect.gen(function* () {
activeShells.add(input.sessionID)
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const callID = Identifier.ascending()
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory })
}).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(
SessionEvent.Shell.Started,
{
sessionID: input.sessionID,
callID,
command: input.command,
shell: started,
},
{ id: input.id },
)
const output = yield* runShellCommand(input.command, session.location.directory).pipe(
Effect.provide(locations.get(session.location)),
)
const completed = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
const terminal = yield* shell.wait(started.id).pipe(
Effect.map((info) => ({ info, retained: true as const })),
Effect.catchTag("Shell.NotFoundError", () =>
Effect.succeed({ info: synthesizeTerminalShellInfo(started), retained: false as const }),
),
)
const output = terminal.retained
? yield* shell
.output(started.id, { limit: SHELL_MAX_CAPTURE_BYTES })
.pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(missingShellOutput())))
: missingShellOutput()
return { shell: terminal.info, output }
})
.pipe(Effect.provide(locations.get(session.location)))
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID: input.sessionID,
callID,
output,
shell: completed.shell,
output: completed.output,
})
}).pipe(
Effect.ensuring(
@@ -706,6 +775,26 @@ const layer = Layer.effect(
}),
)
function missingShellOutput() {
const output = "Shell command output is no longer available."
return {
output,
cursor: Buffer.byteLength(output),
size: Buffer.byteLength(output),
truncated: false,
}
}
function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Info {
return {
...started,
// The Shell record was removed before waiters could observe it; publish a terminal
// boundary instead of leaving the Session shell message permanently running.
status: "killed",
time: { ...started.time, completed: Date.now() },
}
}
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
+1 -1
View File
@@ -129,7 +129,7 @@ const serialize = (message: SessionMessage.Message) => {
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "skill") return `[Skill activated: ${message.name}]\n${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
if (message.type === "shell") return `[Shell]: ${message.shell.command}\n${truncate(message.output?.output ?? "")}`
return ""
}
+54 -52
View File
@@ -8,21 +8,25 @@ export type MemoryState = {
}
export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getAssistant: (
messageID: SessionMessage.ID,
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
}
export function memory(state: MemoryState): Adapter {
const assistantIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
const activeShellIndex = (callID: string) =>
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
return {
getCurrentAssistant() {
@@ -41,12 +45,11 @@ export function memory(state: MemoryState): Adapter {
return assistant?.type === "assistant" ? assistant : undefined
})
},
getCurrentShell(callID) {
getShell(shellID) {
return Effect.sync(() => {
const index = activeShellIndex(callID)
if (index < 0) return
const shell = state.messages[index]
return shell?.type === "shell" ? shell : undefined
return state.messages.find((message): message is SessionMessage.Shell => {
return message.type === "shell" && message.shell.id === shellID
})
})
},
updateAssistant(assistant) {
@@ -60,7 +63,7 @@ export function memory(state: MemoryState): Adapter {
},
updateShell(shell) {
return Effect.sync(() => {
const index = activeShellIndex(shell.callID)
const index = shellIndex(shell.id)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "shell") return
@@ -100,7 +103,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"agent.selected": (event) => {
"session.agent.selected": (event) => {
return adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -111,7 +114,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"model.selected": (event) => {
"session.model.selected": (event) => {
return adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -123,11 +126,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
},
"session.moved": () => Effect.void,
renamed: () => Effect.void,
forked: () => Effect.void,
"prompt.promoted": () => Effect.void,
"prompt.admitted": () => Effect.void,
"execution.settled": () => Effect.void,
"session.renamed": () => Effect.void,
"session.forked": () => Effect.void,
"session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void,
"session.execution.settled": () => Effect.void,
"session.context.updated": (event) =>
adapter.appendMessage(
SessionMessage.System.make({
@@ -137,7 +140,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created },
}),
),
synthetic: (event) => {
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
sessionID: event.data.sessionID,
@@ -150,7 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"skill.activated": (event) => {
"session.skill.activated": (event) => {
return adapter.appendMessage(
SessionMessage.Skill.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -161,25 +164,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"shell.started": (event) => {
"session.shell.started": (event) => {
return adapter.appendMessage(
SessionMessage.Shell.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "shell",
metadata: event.metadata,
callID: event.data.callID,
command: event.data.command,
output: "",
shell: event.data.shell,
time: { created: event.created },
}),
)
},
"shell.ended": (event) => {
"session.shell.ended": (event) => {
return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID)
const currentShell = yield* adapter.getShell(event.data.shell.id)
if (currentShell) {
yield* adapter.updateShell(
produce(currentShell, (draft) => {
draft.shell = castDraft(event.data.shell)
draft.output = event.data.output
draft.time.completed = event.created
}),
@@ -187,7 +189,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"step.started": (event) => {
"session.step.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
@@ -210,7 +212,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"step.ended": (event) => {
"session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.finish = event.data.finish
@@ -224,33 +226,33 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"step.failed": (event) => {
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.finish = "error"
draft.error = event.data.error
})
},
"text.started": (event) => {
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
})
},
"text.delta": (event) => {
"session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text += event.data.delta
})
},
"text.ended": (event) => {
"session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID)
if (match) match.text = event.data.text
})
},
"tool.input.started": (event) => {
"session.tool.input.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@@ -265,14 +267,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"tool.input.delta": () => Effect.void,
"tool.input.ended": (event) => {
"session.tool.input.delta": () => Effect.void,
"session.tool.input.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "pending") match.state.input = event.data.text
})
},
"tool.called": (event) => {
"session.tool.called": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
@@ -289,7 +291,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"tool.progress": (event) => {
"session.tool.progress": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@@ -298,7 +300,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"tool.success": (event) => {
"session.tool.success": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@@ -321,7 +323,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"tool.failed": (event) => {
"session.tool.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) {
@@ -344,7 +346,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
"reasoning.started": (event) => {
"session.reasoning.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push(
castDraft(
@@ -359,13 +361,13 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
)
})
},
"reasoning.delta": (event) => {
"session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
})
},
"reasoning.ended": (event) => {
"session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) {
@@ -375,10 +377,10 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}
})
},
retried: () => Effect.void,
"compaction.started": () => Effect.void,
"compaction.delta": () => Effect.void,
"compaction.ended": (event) => {
"session.retried": () => Effect.void,
"session.compaction.started": () => Effect.void,
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -391,9 +393,9 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}),
)
},
"revert.staged": () => Effect.void,
"revert.cleared": () => Effect.void,
"revert.committed": () => Effect.void,
"session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void,
})
})
}
+14 -7
View File
@@ -393,18 +393,25 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "assistant" ? message : undefined
})
},
getCurrentShell(callID) {
getShell(shellID) {
return Effect.gen(function* () {
const rows = yield* db
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "shell"),
sql`json_extract(${SessionMessageTable.data}, '$.shell.id') = ${shellID}`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.all()
.limit(1)
.get()
.pipe(Effect.orDie)
return rows
.map(decodeRow)
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
if (!row) return
const message = decodeRow(row)
return message.type === "shell" ? message : undefined
})
},
updateAssistant: updateMessage,
+55 -68
View File
@@ -1,6 +1,15 @@
export * as SessionRunnerLLM from "./llm"
import { LLM, LLMClient, LLMError, LLMEvent, Message, SystemPart, isContextOverflowFailure } from "@opencode-ai/llm"
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
Message,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
@@ -27,7 +36,7 @@ import { SessionStore } from "../store"
import { SessionTitle } from "../title"
import { Service } from "./index"
import { SessionRunnerModel } from "./model"
import { createStepLedger } from "./step-ledger"
import { createLLMEventPublisher } from "./publish-llm-event"
import { toLLMMessages } from "./to-llm-message"
import { MAX_STEPS_PROMPT } from "./max-steps"
import { SessionRunnerSystemPrompt } from "./system-prompt"
@@ -176,6 +185,7 @@ const layer = Layer.effect(
session.id,
)
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
let needsContinuation = false
let currentStep = step
if (promotion) {
let promoted = 0
@@ -212,7 +222,7 @@ const layer = Layer.effect(
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture()
const ledger = createStepLedger(events, {
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
// The selected catalog identity, not model.id: route-level ids are provider API
@@ -225,25 +235,26 @@ const layer = Layer.effect(
// mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
serialized(ledger.publish(event, outputPaths))
serialized(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (ledger.heldOverflow() || ledger.hasProviderError()) return
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !ledger.hasAssistantStarted()) {
ledger.holdOverflow(event)
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) {
yield* serialized(ledger.failUnsettledTools("Tools are disabled after the maximum agent steps"))
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps"))
return
}
ledger.admitLocalToolCall(event.id)
const assistantMessageID = yield* ledger.assistantMessageID(event.id)
needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) =>
restore(
toolMaterialization.settle({
@@ -268,12 +279,12 @@ const layer = Layer.effect(
).pipe(FiberSet.run(toolFibers))
}),
),
Effect.ensuring(serialized(ledger.flush())),
Effect.ensuring(serialized(publisher.flush())),
)
// Captures the end snapshot, diffs it against the step's start, and durably ends the
// assistant step.
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof ledger.stepSettlement>>) =>
const publishStepEnd = (settlement: NonNullable<ReturnType<typeof publisher.stepSettlement>>) =>
Effect.gen(function* () {
const endSnapshot = yield* snapshots.capture()
const files =
@@ -285,7 +296,7 @@ const layer = Layer.effect(
yield* serialized(
events.publish(SessionEvent.Step.Ended, {
sessionID: session.id,
assistantMessageID: yield* ledger.startAssistant(),
assistantMessageID: yield* publisher.startAssistant(),
finish: settlement.finish,
cost: 0,
tokens: settlement.tokens,
@@ -308,8 +319,8 @@ const layer = Layer.effect(
// restart the step instead of surfacing the provider error.
if (
recoverOverflow &&
!ledger.hasAssistantStarted() &&
isContextOverflowFailure(ledger.heldOverflow() ?? streamFailure) &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
)
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
@@ -317,21 +328,28 @@ const layer = Layer.effect(
// An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure fails hosted tool calls and the assistant unless a provider
// error was already recorded from the stream.
const heldOverflow = ledger.heldOverflow()
if (heldOverflow) yield* publish(heldOverflow)
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !ledger.hasProviderError()) {
yield* serialized(ledger.failUnsettledTools("Provider did not return a tool result", true))
yield* serialized(ledger.failAssistant(llmFailure.reason.message))
if (llmFailure && !publisher.hasProviderError()) {
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* serialized(publisher.failAssistant(llmFailure.reason.message))
}
// Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = ledger.hasProviderError()
const providerFailed = publisher.hasProviderError()
// Settle tool fibers: an interrupted stream abandons unstarted tool work first.
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
if (questionDismissed || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) return yield* Effect.interrupt
}
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
// settles so the model may recover. A typed infrastructure failure (tool output
@@ -339,61 +357,30 @@ const layer = Layer.effect(
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined
const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
const settlement =
questionDismissed || streamInterrupted || toolsInterrupted
? { _tag: "Interrupted" as const, questionDismissed }
: providerFailed
? { _tag: "ProviderFailed" as const }
: infraError !== undefined
? { _tag: "ToolInfraFailed" as const, cause: infraError }
: { _tag: "Clean" as const }
if (settlement._tag === "Interrupted") {
yield* FiberSet.clear(toolFibers)
yield* serialized(ledger.failUnsettledTools("Tool execution interrupted"))
yield* serialized(ledger.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (settlement.questionDismissed) return yield* Effect.interrupt
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure)
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
if (infraError !== undefined)
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
}
if (settlement._tag === "ToolInfraFailed") {
const message = settlement.cause instanceof Error ? settlement.cause.message : String(settlement.cause)
yield* serialized(ledger.failUnsettledTools(`Tool execution failed: ${message}`))
yield* serialized(ledger.failAssistant(`Tool execution failed: ${message}`))
}
if (settlement._tag === "Clean") {
if (settledFailure !== undefined) {
const failure = Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure)
yield* serialized(ledger.failUnsettledTools(`Tool execution failed: ${message}`))
}
const stepSettlement = ledger.stepSettlement()
if (stepSettlement) yield* publishStepEnd(stepSettlement)
}
// A provider error orphans recorded local calls. A tool fiber that failed
// alongside the provider error still settles with its own failure message first.
if (settlement._tag === "ProviderFailed") {
if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure)
yield* serialized(ledger.failUnsettledTools(`Tool execution failed: ${message}`))
}
yield* serialized(ledger.failUnsettledTools("Tool execution interrupted"))
}
// A clean stream can still leave hosted calls without results.
if (stream._tag === "Success" && settlement._tag !== "ProviderFailed")
yield* serialized(ledger.failUnsettledTools("Provider did not return a tool result", true))
const stepSettlement = publisher.stepSettlement()
const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
// A provider error orphans recorded local calls; a clean stream can still leave
// hosted calls without results.
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !providerFailed)
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined))
return yield* Effect.failCause(settled.cause)
return {
_tag: "Completed",
needsContinuation: !providerFailed && ledger.hasLocalToolCalls(),
needsContinuation: !providerFailed && needsContinuation,
step: currentStep,
} as const
}),
@@ -1,11 +1,4 @@
import {
ToolOutput,
type LLMEvent,
type ProviderErrorEvent,
type ProviderMetadata,
type ToolResultValue,
type Usage,
} from "@opencode-ai/llm"
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect"
import { EventV2 } from "../../event"
import { ModelV2 } from "../../model"
@@ -58,7 +51,7 @@ const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue):
}
/** Persist one step without executing tools or starting a continuation step. */
export const createStepLedger = (events: EventV2.Interface, input: Input) => {
export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) => {
const tools = new Map<
string,
{
@@ -76,9 +69,7 @@ export const createStepLedger = (events: EventV2.Interface, input: Input) => {
let assistantActive = false
let assistantFailed = false
let providerFailed = false
let heldOverflowEvent: ProviderErrorEvent | undefined
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined
const localToolCalls = new Set<string>()
const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID
@@ -238,11 +229,6 @@ export const createStepLedger = (events: EventV2.Interface, input: Input) => {
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(new Error(`Unknown tool call: ${callID}`))
}
const admitLocalToolCall = (callID: string) => {
const tool = tools.get(callID)
if (tool?.called && !tool.providerExecuted) localToolCalls.add(callID)
}
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
@@ -411,14 +397,8 @@ export const createStepLedger = (events: EventV2.Interface, input: Input) => {
flush,
failAssistant,
failUnsettledTools,
admitLocalToolCall,
holdOverflow: (event: ProviderErrorEvent) => {
heldOverflowEvent = event
},
heldOverflow: () => heldOverflowEvent,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasLocalToolCalls: () => localToolCalls.size > 0,
hasProviderError: () => providerFailed,
stepSettlement: () => stepSettlement,
startAssistant,
@@ -139,7 +139,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: `Shell command: ${message.command}\n\n${message.output}`,
content: `Shell command: ${message.shell.command}\n\n${message.output?.output ?? ""}`,
metadata: message.metadata,
}),
]
+2 -2
View File
@@ -3,7 +3,7 @@ export * as SkillV2 from "./skill"
import { makeLocationNode } from "./effect/app-node"
import path from "path"
import { Context, Effect, Layer, Schema, Stream, Types } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Skill } from "@opencode-ai/schema/skill"
import { AgentV2 } from "./agent"
import { ConfigMarkdown } from "./config/markdown"
@@ -153,7 +153,7 @@ const layer = Layer.effect(
yield* events.publish(Event.Updated, {}).pipe(Effect.asVoid)
})
yield* events.subscribe(FileSystemWatcher.Event.Updated).pipe(
yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => invalidate(event.data.file)),
Effect.forkScoped({ startImmediately: true }),
)
-4
View File
@@ -2,7 +2,6 @@ export * as ConfigV1 from "./config"
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
import { ConfigExperimental } from "../../config/experimental"
import { ConfigReference } from "../../config/reference"
import { ConfigAgentV1 } from "./agent"
import { ConfigAttachmentV1 } from "./attachment"
@@ -179,9 +178,6 @@ export const Info = Schema.Struct({
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
policies: Schema.optional(Schema.mutable(Schema.Array(ConfigExperimental.Policy))).annotate({
description: "Policy statements applied to supported resources, such as provider access",
}),
}),
),
}).annotate({ identifier: "Config" })
+12 -2
View File
@@ -72,17 +72,27 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: info.command,
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
experimental: info.experimental?.policies && { policies: info.experimental.policies },
providers: providers(info.provider),
}
}
function commands(info?: typeof ConfigV1.Info.Type.command) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).map(([name, command]) => {
if (command?.subtask === undefined) return [name, command]
const { subtask, ...rest } = command
return [name, { ...rest, subagent: subtask }]
}),
)
}
function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<string, boolean>>) {
const rules: Array<{ action: string; resource: string; effect: ConfigPermissionV1.Action }> = Object.entries(
tools ?? {},
+1 -19
View File
@@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { Policy } from "@opencode-ai/core/policy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
@@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
[[Location.node, locationLayer]],
)
const it = testEffect(catalogLayer)
@@ -333,21 +332,4 @@ describe("CatalogV2", () => {
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
}),
)
it.effect("removes providers denied by policy after loading", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policy = yield* Policy.Service
const providerID = ProviderV2.ID.make("blocked")
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
yield* catalog.transform((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
})
expect(yield* catalog.provider.all()).toEqual([])
expect(yield* catalog.model.all()).toEqual([])
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
}),
)
})
+36 -5
View File
@@ -1,13 +1,15 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, PubSub, Schema, Stream } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
import { host } from "../plugin/host"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
[MCP.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
@@ -44,16 +46,35 @@ description: File review
agent: reviewer
model: anthropic/claude
variant: high
subtask: true
subagent: true
---
Review files`,
)
await fs.writeFile(
path.join(tmp.path, "commands", "legacy.md"),
`---
subtask: true
---
Legacy review`,
)
await fs.writeFile(path.join(tmp.path, "commands", "nested", "docs.md"), "Write docs")
await fs.writeFile(path.join(tmp.path, "commands", "empty.md"), "")
})
const command = yield* CommandV2.Service
yield* ConfigCommandPlugin.Plugin.effect(host({ command: { ...command, reload: command.reload } })).pipe(
const events = yield* EventV2.Service
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
const updates = yield* PubSub.unbounded<typeof update>()
yield* ConfigCommandPlugin.Plugin.effect(
host({
command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: command.reload,
},
event: { subscribe: () => Stream.fromPubSub(updates) },
}),
).pipe(
Effect.provideService(
Config.Service,
Config.Service.of({
@@ -80,11 +101,21 @@ Review files`,
id: ModelV2.ID.make("claude"),
variant: ModelV2.VariantID.make("high"),
},
subtask: true,
subagent: true,
}),
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "legacy", template: "Legacy review", subagent: true }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, update)
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* command.get("review"))?.template === "Review again") break
yield* Effect.sleep("10 millis")
}
expect((yield* command.get("review"))?.template).toBe("Review again")
}),
),
),
+120 -44
View File
@@ -1,18 +1,20 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@@ -26,6 +28,7 @@ function testLayer(
globalDirectory = path.join(directory, "global"),
projectDirectory = directory,
vcs?: Project.Vcs,
watcher?: Layer.Layer<Watcher.Service>,
) {
const locationLayer = Layer.succeed(
Location.Service,
@@ -36,9 +39,10 @@ function testLayer(
),
),
)
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
])
}
@@ -52,6 +56,52 @@ const provider = {
}
describe("Config", () => {
it.live("reloads external config and publishes directory updates", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
})
const updates = yield* PubSub.unbounded<Watcher.Update>()
const watcher = Layer.succeed(
Watcher.Service,
Watcher.Service.of({
subscribe: () => Stream.fromPubSub(updates),
}),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const events = yield* EventV2.Service
const changed = yield* events
.subscribe(ConfigSchema.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* PubSub.publish(updates, {
type: "update",
path: path.join(global, "commands", "review.md"),
} satisfies Watcher.Update)
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
}),
),
),
)
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
@@ -149,7 +199,7 @@ describe("Config", () => {
agent: "reviewer",
model: "anthropic/claude",
variant: "high",
subtask: true,
subagent: true,
},
})
}),
@@ -242,6 +292,72 @@ describe("Config", () => {
),
)
it.live("substitutes environment variables and relative file contents", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = {
token: process.env.OPENCODE_TEST_MCP_TOKEN,
missing: process.env.OPENCODE_TEST_MISSING,
}
process.env.OPENCODE_TEST_MCP_TOKEN = "secret"
delete process.env.OPENCODE_TEST_MISSING
return previous
}),
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(path.join(tmp.path, "token.txt"), 'file\n"token"\n'),
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Ignored reference: {file:missing.txt}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"mcp": {
"servers": {
"remote": {
"type": "remote",
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}"
}
}
}
}
}`,
),
]),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const document = (yield* config.entries()).find((entry) => entry.type === "document")
expect(document?.info.username).toBe("user-")
const remote = document?.info.mcp?.servers?.remote
expect(remote?.type).toBe("remote")
if (remote?.type !== "remote") return
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
"X-Token": 'file\n"token"',
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
(previous) =>
Effect.sync(() => {
if (previous.token === undefined) delete process.env.OPENCODE_TEST_MCP_TOKEN
else process.env.OPENCODE_TEST_MCP_TOKEN = previous.token
if (previous.missing === undefined) delete process.env.OPENCODE_TEST_MISSING
else process.env.OPENCODE_TEST_MISSING = previous.missing
}),
),
)
it.live("does not load legacy config.json files", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -274,7 +390,6 @@ describe("Config", () => {
const file = path.join(tmp.path, "opencode.json")
const contents = JSON.stringify({
shell: "/bin/zsh",
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
providers: { local: provider },
})
yield* Effect.promise(() => fs.writeFile(file, contents))
@@ -285,11 +400,6 @@ describe("Config", () => {
expect(documents[0]?.info.$schema).toBeUndefined()
expect(documents[0]?.info.shell).toBe("/bin/zsh")
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
effect: "deny",
action: "provider.use",
resource: "openai",
})
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
@@ -723,40 +833,6 @@ describe("Config", () => {
),
)
it.live("loads policy statements in reverse config order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.writeFile(
path.join(global, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
}),
)
await fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
}),
)
})
return yield* Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}).pipe(Effect.provide(testLayer(tmp.path, global)))
})
}),
),
)
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+73
View File
@@ -1,15 +1,19 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Effect, Schema } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -240,6 +244,49 @@ describe("ConfigExternalPlugin", () => {
})
}),
)
it.live("reloads changed plugin source from the same entrypoint", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const agents = yield* AgentV2.Service
const events = yield* EventV2.Service
const fsUtil = yield* FSUtil.Service
const location = yield* Location.Service
const npm = yield* Npm.Service
const host = yield* PluginHost.make(plugins)
const plugin = path.join(tmp.path, "plugin.ts")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({ plugins: [plugin] }),
}),
]),
})
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source")))
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
Effect.provideService(PluginV2.Service, plugins),
Effect.provideService(FSUtil.Service, fsUtil),
Effect.provideService(Location.Service, location),
Effect.provideService(Npm.Service, npm),
Effect.provideService(Config.Service, config),
)
expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source")
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source")))
yield* events.publish(ConfigSchema.Event.Updated, {})
expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true)
}),
),
),
)
})
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
@@ -250,3 +297,29 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id:
}
return yield* Effect.die(`Timed out waiting for agent ${id}`)
})
const waitForAgentDescription = Effect.fnUntraced(function* (
agents: AgentV2.Interface,
id: string,
description: string,
) {
for (let attempt = 0; attempt < 100; attempt++) {
if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true
yield* Effect.sleep("10 millis")
}
return false
})
function pluginSource(description: string) {
return `export default {
id: "source-hot-reload",
setup: async (ctx) => {
await ctx.agent.transform((agents) => {
agents.update("hot-reload", (agent) => {
agent.description = ${JSON.stringify(description)}
agent.mode = "subagent"
})
})
},
}`
}
+125
View File
@@ -0,0 +1,125 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { CommandV2 } from "@opencode-ai/core/command"
import { Config } from "@opencode-ai/core/config"
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { EventV2 } from "@opencode-ai/core/event"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { SkillV2 } from "@opencode-ai/core/skill"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
const decode = Schema.decodeUnknownSync(Config.Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.live("reloads every config-backed domain", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const events = yield* EventV2.Service
const plugins = yield* PluginV2.Service
const references = yield* Reference.Service
const skills = yield* SkillV2.Service
const host = yield* PluginHost.make(plugins)
let entries: Config.Entry[] = [config("first", "First plugin")]
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
effect.pipe(Effect.provideService(Config.Service, service))
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
yield* setup(ConfigExternalPlugin.Plugin.effect(host))
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
entries = [config("second", "Second plugin")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(
Effect.gen(function* () {
return (
(yield* agents.get(AgentV2.ID.make("first"))) === undefined &&
(yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" &&
(yield* commands.get("first")) === undefined &&
(yield* commands.get("second"))?.description === "Second command" &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined &&
(yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin"
)
}),
)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
).toBe(false)
expect(
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
).toBe(true)
entries = [config("second")]
yield* events.publish(ConfigSchema.Event.Updated, {})
yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined)))
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
)
})
function config(name: string, pluginDescription?: string) {
return new Config.Document({
type: "document",
path: document,
info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
skills: [`/skills/${name}`],
references: { [name]: `/references/${name}` },
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
plugins:
pluginDescription === undefined
? []
: [
{
package: "../plugin/fixtures/config-promise-plugin.ts",
options: { description: pluginDescription },
},
],
}),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
return yield* Effect.die("Timed out waiting for config plugin reloads")
})
+1 -1
View File
@@ -36,7 +36,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
yield* ConfigSkillPlugin.Plugin.effect(
host({
skill: { transform, reload: () => Effect.void },
skill: { list: () => Effect.die("unused skill.list"), transform, reload: () => Effect.void },
}),
).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home: "/home/test" })),
+25 -3
View File
@@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location"
@@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
)
return Effect.provide(
AppNodeBuilder.build(Watcher.node, [
AppNodeBuilder.build(LocationWatcher.node, [
[Config.node, configLayer],
[Location.node, locationLayer],
]),
@@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
return Effect.gen(function* () {
const events = yield* EventV2.Service
const deferred = yield* Deferred.make<WatcherEvent>()
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
Stream.runForEach((event) => {
if (!check(event.data)) return Effect.void
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
@@ -136,7 +138,27 @@ function ready(directory: string) {
})
}
describeWatcher("Watcher", () => {
describeWatcher("LocationWatcher", () => {
it.live("limits file watches to the exact target", () =>
withTmp((directory) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const watcher = yield* Watcher.Service
const target = path.join(directory, "opencode.json")
const sibling = path.join(directory, "other.json")
const update = yield* watcher
.subscribe({ path: target, type: "file" })
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.yieldNow
yield* fs.writeFileString(sibling, "sibling")
yield* fs.writeFileString(target, "target")
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
),
)
it.live("publishes root create, update, and delete events", () =>
withTmp(
(directory) =>
+11 -16
View File
@@ -51,27 +51,18 @@ describe("LocationServiceMap", () => {
),
)
it.live("isolates location state while sharing location policy with catalog", () =>
it.live("isolates catalog state by location", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([blocked, allowed]) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(blocked.path, "opencode.json"),
JSON.stringify({
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
}),
),
)
const update = (directory: string) =>
const update = (directory: string, providerID: ProviderV2.ID) =>
Effect.gen(function* () {
yield* Reference.Service
const catalog = yield* Catalog.Service
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
const registry = yield* ToolRegistry.Service
// Tool plugins register during the forked PluginInternal boot; wait for
// every expected tool rather than relying on batch ordering.
@@ -103,8 +94,11 @@ describe("LocationServiceMap", () => {
),
)
const blockedState = yield* update(blocked.path)
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
const blockedID = ProviderV2.ID.make("blocked-location")
const allowedID = ProviderV2.ID.make("allowed-location")
const blockedState = yield* update(blocked.path, blockedID)
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit",
"glob",
@@ -119,8 +113,9 @@ describe("LocationServiceMap", () => {
"websearch",
"write",
])
const allowedState = yield* update(allowed.path)
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
const allowedState = yield* update(allowed.path, allowedID)
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
"edit",
"glob",
+22 -1
View File
@@ -1,8 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Schema } from "effect"
import { Effect, Exit, Fiber, Schema, Stream } from "effect"
import { define } from "@opencode-ai/plugin/v2/effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { AgentV2 } from "@opencode-ai/core/agent"
import { EventV2 } from "@opencode-ai/core/event"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool/tool"
@@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture"
const it = testEffect(PluginTestLayer)
describe("PluginV2", () => {
it.live("exposes public events through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
const events = yield* EventV2.Service
const host = yield* PluginHost.make(plugins)
const received = yield* host.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* events.publish(ConfigSchema.Event.Updated, {})
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
}),
)
it.effect("waits for a plugin and returns immediately once active", () =>
Effect.gen(function* () {
const plugins = yield* PluginV2.Service
+6 -1
View File
@@ -23,7 +23,11 @@ describe("CommandPlugin.Plugin", () => {
const command = yield* CommandV2.Service
yield* CommandPlugin.Plugin.effect(
host({
command: { transform: command.transform, reload: command.reload },
command: {
list: () => Effect.die("unused command.list"),
transform: command.transform,
reload: command.reload,
},
}),
).pipe(
Effect.provideService(
@@ -40,6 +44,7 @@ describe("CommandPlugin.Plugin", () => {
expect(yield* command.get("review")).toMatchObject({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
subagent: true,
})
}),
)
+38 -1
View File
@@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
import { Effect } from "effect"
import { Effect, Stream } from "effect"
type Overrides = Partial<Omit<PluginContext, "options">>
@@ -23,14 +23,33 @@ export function host(overrides: Overrides = {}): PluginContext {
language: () => Effect.die("unused aisdk.language"),
},
catalog: overrides.catalog ?? {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
transform: () => Effect.die("unused catalog.transform"),
reload: () => Effect.die("unused catalog.reload"),
},
command: overrides.command ?? {
list: () => Effect.die("unused command.list"),
transform: () => Effect.die("unused command.transform"),
reload: () => Effect.die("unused command.reload"),
},
event: overrides.event ?? {
subscribe: () => Stream.empty,
},
integration: overrides.integration ?? {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
transform: () => Effect.die("unused integration.transform"),
reload: () => Effect.die("unused integration.reload"),
connection: {
@@ -39,14 +58,17 @@ export function host(overrides: Overrides = {}): PluginContext {
},
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
add: () => Effect.die("unused plugin.add"),
remove: () => Effect.die("unused plugin.remove"),
},
reference: overrides.reference ?? {
list: () => Effect.die("unused reference.list"),
transform: () => Effect.die("unused reference.transform"),
reload: () => Effect.die("unused reference.reload"),
},
skill: overrides.skill ?? {
list: () => Effect.die("unused skill.list"),
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
@@ -94,6 +116,14 @@ export function agentHost(agent: AgentV2.Interface): PluginContext["agent"] {
export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"] {
return {
provider: {
list: () => Effect.die("unused catalog.provider.list"),
get: () => Effect.die("unused catalog.provider.get"),
},
model: {
list: () => Effect.die("unused catalog.model.list"),
default: () => Effect.die("unused catalog.model.default"),
},
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) =>
@@ -158,6 +188,13 @@ export function catalogHost(catalog: Catalog.Interface): PluginContext["catalog"
export function integrationHost(integration: Integration.Interface): PluginContext["integration"] {
return {
list: () => Effect.die("unused integration.list"),
get: () => Effect.die("unused integration.get"),
connectKey: () => Effect.die("unused integration.connectKey"),
connectOauth: () => Effect.die("unused integration.connectOauth"),
attemptStatus: () => Effect.die("unused integration.attemptStatus"),
attemptComplete: () => Effect.die("unused integration.attemptComplete"),
attemptCancel: () => Effect.die("unused integration.attemptCancel"),
reload: integration.reload,
connection: {
active: (id) => integration.connection.active(Integration.ID.make(id)),
+29
View File
@@ -11,6 +11,35 @@ import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("forwards standard client reads", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin)
const seen: string[] = []
const promisePlugin = define({
id: "promise-client-reads",
setup: async (ctx) => {
const results = await Promise.all([
ctx.agent.list(),
ctx.catalog.provider.list(),
ctx.catalog.model.list(),
ctx.command.list(),
ctx.integration.list(),
ctx.plugin.list(),
ctx.reference.list(),
ctx.skill.list(),
])
seen.push(...results.map((result) => result.location.directory))
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect(seen).toHaveLength(8)
expect(new Set(seen).size).toBe(1)
}),
)
it.effect("loads a promise plugin and registers a transform hook", () =>
Effect.gen(function* () {
const agents = yield* AgentV2.Service
+9 -1
View File
@@ -19,7 +19,15 @@ describe("SkillPlugin.Plugin", () => {
it.effect("registers built-in skills", () =>
Effect.gen(function* () {
const skill = yield* SkillV2.Service
yield* SkillPlugin.Plugin.effect(host({ skill: { ...skill, reload: skill.reload } })).pipe(
yield* SkillPlugin.Plugin.effect(
host({
skill: {
list: () => Effect.die("unused skill.list"),
transform: skill.transform,
reload: skill.reload,
},
}),
).pipe(
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
Effect.provideService(
Location.Service,
-85
View File
@@ -1,85 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(Policy.node, [
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
],
]),
)
describe("Policy", () => {
it.effect("returns the caller's fallback when no statement matches", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
}),
)
it.effect("evaluates wildcard provider rules in written order", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "*",
}),
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "anthropic",
}),
])
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
it.effect("matches action and resource independently", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "deny",
action: "provider.*",
resource: "company-*",
}),
])
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
}),
)
it.effect("uses the last matching loaded statement", () =>
Effect.gen(function* () {
const policy = yield* Policy.Service
yield* policy.load([
new Policy.Info({
effect: "allow",
action: "provider.use",
resource: "openai",
}),
new Policy.Info({
effect: "deny",
action: "provider.use",
resource: "openai",
}),
])
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
}),
)
})
+134
View File
@@ -0,0 +1,134 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { CommandV2 } from "@opencode-ai/core/command"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Job } from "@opencode-ai/core/job"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
Database.node,
EventV2.node,
Job.node,
SessionProjector.node,
SessionStore.node,
SessionV2.node,
LocationServiceMap.node,
]),
[
[ProjectV2.node, projects],
[
SessionExecution.node,
Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.never,
wake: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
),
],
],
),
)
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.make("anthropic") })
function withTmp<A, E, R>(f: (location: Location.Ref) => Effect.Effect<A, E, R>) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }))))
}
describe("SessionV2.command", () => {
it.effect("runs subagent commands as background child sessions", () =>
withTmp((location) =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const parent = yield* sessions.create({ location, model })
const locations = yield* LocationServiceMap.Service
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* commands.transform((draft) => {
draft.update("review", (command) => {
command.template = "Review this"
command.description = "review changes"
command.agent = "reviewer"
command.subagent = true
})
})
const admitted = yield* sessions.command({ sessionID: parent.id, command: "review" })
const children = yield* sessions.list({ parentID: parent.id })
expect(children.data).toHaveLength(1)
expect(children.data[0]).toMatchObject({
parentID: parent.id,
title: "review changes",
agent: AgentV2.ID.make("reviewer"),
model,
})
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Review this" } })
expect(yield* Job.Service.use((jobs) => jobs.get(children.data[0]!.id))).toMatchObject({
id: children.data[0]!.id,
type: "subagent",
status: "running",
})
expect(yield* sessions.messages({ sessionID: parent.id })).toEqual([
expect.objectContaining({ type: "synthetic", text: expect.stringContaining(children.data[0]!.id) }),
])
}),
),
)
it.effect("defaults subagent commands without an agent to the general agent", () =>
withTmp((location) =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const parent = yield* sessions.create({ location })
const locations = yield* LocationServiceMap.Service
const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(parent.location)))
yield* commands.transform((draft) => {
draft.update("research", (command) => {
command.template = "Legacy task"
command.subagent = true
})
})
const admitted = yield* sessions.command({ sessionID: parent.id, command: "research" })
const children = yield* sessions.list({ parentID: parent.id })
expect(children.data).toHaveLength(1)
expect(children.data[0]).toMatchObject({ agent: AgentV2.ID.make("general") })
expect(admitted).toMatchObject({ sessionID: children.data[0]!.id, prompt: { text: "Legacy task" } })
}),
),
)
})
+10 -8
View File
@@ -213,7 +213,7 @@ describe("SessionV2.create", () => {
expect(forkContext.map((message) => message.id)).not.toEqual(parentContext.map((message) => message.id))
expect(history).toHaveLength(1)
expect(history[0]).toMatchObject({
type: "forked",
type: "session.forked",
durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id },
})
@@ -378,8 +378,8 @@ describe("SessionV2.create", () => {
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(2), Stream.runCollect)),
).toMatchObject([
{ durable: { seq: 1 }, type: "prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "prompt.promoted" },
{ durable: { seq: 1 }, type: "session.prompt.admitted", data: { prompt: { text: "Hello" } } },
{ durable: { seq: 2 }, type: "session.prompt.promoted" },
])
}),
)
@@ -494,8 +494,9 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "echo hello" })
expect(shell?.output).toContain("hello")
expect(shell).toMatchObject({ type: "shell", shell: { command: "echo hello", status: "exited", exit: 0 } })
expect(shell?.output?.output).toContain("hello")
expect(shell?.output?.truncated).toBe(false)
expect(shell?.time.completed).toBeDefined()
}),
),
@@ -513,7 +514,8 @@ describe("SessionV2.create", () => {
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
const shell = messages.find((message): message is SessionMessage.Shell => message.type === "shell")
expect(shell).toMatchObject({ type: "shell", command: "false" })
expect(shell).toMatchObject({ type: "shell", shell: { command: "false", status: "exited" } })
expect(shell?.shell.exit).not.toBe(0)
expect(shell?.time.completed).toBeDefined()
}),
),
@@ -529,7 +531,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "agent.selected", data: { agent: "plan" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
}),
)
@@ -562,7 +564,7 @@ describe("SessionV2.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ model })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "model.selected", data: { model } }])
).toMatchObject([{ type: "session.model.selected", data: { model } }])
}),
)
+4 -4
View File
@@ -40,14 +40,14 @@ describe("SessionV2.log", () => {
const session = yield* SessionV2.Service
const events = yield* EventV2.Service
const created = yield* session.create({ location })
yield* session.rename({ sessionID: created.id, title: "renamed" })
yield* session.rename({ sessionID: created.id, title: "session.renamed" })
const items = Array.from(yield* Stream.runCollect(session.log({ sessionID: created.id })))
const watermark = (yield* events.sequences([created.id])).get(created.id)
// Session creation commits a non-public durable event, so the marker's
// seq covers more of the aggregate than the public events emitted.
expect(items.map((item) => item.type)).toEqual(["renamed", "log.synced"])
expect(items.map((item) => item.type)).toEqual(["session.renamed", "log.synced"])
expect(items.at(-1)).toEqual({ type: "log.synced", aggregateID: created.id, seq: watermark })
}),
)
@@ -64,7 +64,7 @@ describe("SessionV2.log", () => {
yield* session.rename({ sessionID: created.id, title: "renamed live" })
const items = Array.from(yield* Fiber.join(fiber))
expect(items.map((item) => item.type)).toEqual(["log.synced", "renamed"])
expect(items.map((item) => item.type)).toEqual(["log.synced", "session.renamed"])
}),
)
@@ -137,7 +137,7 @@ describe("SessionV2 watermarks", () => {
const events = yield* EventV2.Service
const first = yield* session.create({ location })
const second = yield* session.create({ location })
yield* session.rename({ sessionID: first.id, title: "renamed" })
yield* session.rename({ sessionID: first.id, title: "session.renamed" })
const page = yield* session.list()
const sequences = yield* events.sequences([first.id, second.id])
+25 -6
View File
@@ -19,6 +19,7 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { Shell } from "@opencode-ai/schema/shell"
import {
SessionContextCheckpointTable,
SessionInputTable,
@@ -257,15 +258,32 @@ describe("SessionProjector", () => {
})
yield* events.publish(SessionEvent.Shell.Started, {
sessionID,
callID: "shell-1",
command: "pwd",
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "running",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
metadata: {},
time: { started: 0 },
}),
})
yield* events.publish(SessionEvent.Shell.Ended, {
sessionID,
callID: "shell-1",
output: "/project",
shell: Shell.Info.make({
id: Shell.ID.make("sh_projector"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_projector.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 1 },
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
})
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
reason: "manual",
@@ -320,7 +338,8 @@ describe("SessionProjector", () => {
metadata: { source: "projector-test" },
})
expect(messages.find((message) => message.type === "shell")).toMatchObject({
output: "/project",
shell: { command: "pwd", status: "exited", exit: 0 },
output: { output: "/project", truncated: false },
time: { completed: DateTime.makeUnsafe(0) },
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
+5 -5
View File
@@ -256,16 +256,16 @@ describe("SessionV2.prompt", () => {
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "prompt.admitted"],
[1, "prompt.admitted"],
[2, "prompt.promoted"],
[3, "prompt.promoted"],
[0, "session.prompt.admitted"],
[1, "session.prompt.admitted"],
[2, "session.prompt.promoted"],
[3, "session.prompt.promoted"],
])
expect(
Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
).toEqual([[1, "prompt.admitted"]])
).toEqual([[1, "session.prompt.admitted"]])
}),
)
@@ -7,6 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Shell } from "@opencode-ai/schema/shell"
import { DateTime } from "effect"
const created = DateTime.makeUnsafe(0)
@@ -87,9 +88,18 @@ describe("toLLMMessages", () => {
SessionMessage.Shell.make({
id: id("shell"),
type: "shell",
callID: "shell-1",
command: "pwd",
output: "/project",
shell: Shell.Info.make({
id: Shell.ID.make("sh_test"),
status: "exited",
command: "pwd",
cwd: "/project",
shell: "/bin/sh",
file: "/tmp/sh_test.out",
exit: 0,
metadata: {},
time: { started: 0, completed: 0 },
}),
output: { output: "/project", cursor: 8, size: 8, truncated: false },
time: { created, completed: created },
}),
SessionMessage.Compaction.make({
@@ -193,12 +193,12 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"prompt.admitted.1",
"prompt.promoted.1",
"step.started.1",
"text.started.1",
"text.ended.1",
"step.ended.1",
"session.prompt.admitted.1",
"session.prompt.promoted.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
"session.step.ended.1",
])
}),
)
@@ -7,7 +7,7 @@ import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { createStepLedger } from "@opencode-ai/core/session/runner/step-ledger"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -40,7 +40,7 @@ const capture = () => {
})
return {
published,
publisher: createStepLedger(events, {
publisher: createLLMEventPublisher(events, {
sessionID,
agent: "build",
model: {
@@ -76,7 +76,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result))
const success = published.find((event) => event.type === "tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success).toBeDefined()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
@@ -94,7 +94,7 @@ test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "tool.success.1")
const success = published.find((event) => event.type === "session.tool.success.1")
expect(success?.data).toHaveProperty("result")
})
@@ -110,8 +110,8 @@ test("binary failure emits no success event", async () => {
}),
),
)
expect(published.some((event) => event.type === "tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "tool.failed.1")).toBe(true)
expect(published.some((event) => event.type === "session.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {
+3 -3
View File
@@ -2985,7 +2985,7 @@ describe("SessionRunnerLLM", () => {
{ type: "user", text: "Interrupt provider" },
{ type: "assistant", finish: "error", error: { type: "unknown", message: "Step interrupted" } },
])
expect(yield* recordedEventTypes(sessionID)).toContain("step.failed.1")
expect(yield* recordedEventTypes(sessionID)).toContain("session.step.failed.1")
yield* session.interrupt(sessionID)
}),
)
@@ -3029,8 +3029,8 @@ describe("SessionRunnerLLM", () => {
},
])
const eventTypes = yield* recordedEventTypes(sessionID)
expect(eventTypes).toContain("step.failed.1")
expect(eventTypes).not.toContain("step.ended.1")
expect(eventTypes).toContain("session.step.failed.1")
expect(eventTypes).not.toContain("session.step.ended.1")
}),
)
+2 -2
View File
@@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -206,7 +206,7 @@ metadata:
waitForSkillUpdate(),
({ deferred }) =>
events
.publish(FileSystemWatcher.Event.Updated, { file, event: "change" })
.publish(FileSystem.Event.Changed, { file, event: "change" })
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
({ fiber }) => Fiber.interrupt(fiber),
)
@@ -175,14 +175,18 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
return { type: "pending" as const }
}
if (!commands.some((item) => item.name === head.name)) {
const item = commands.find((entry) => entry.name === head.name)
if (!item) {
return { type: "none" as const }
}
return { type: "command" as const, command: { name: head.name, arguments: head.arguments } }
return {
type: "command" as const,
command: { name: head.name, arguments: head.arguments, ...(item.source ? { source: item.source } : {}) },
}
}
function selectedCommand(text: string, command: RunPrompt["command"]) {
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
if (!command) {
return
}
@@ -192,9 +196,14 @@ function selectedCommand(text: string, command: RunPrompt["command"]) {
return
}
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
// source; resolve it at submit time so routing never degrades to a plain
// command for a skill entry.
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
return {
name: command.name,
arguments: head.arguments,
...(source ? { source } : {}),
}
}
@@ -1178,7 +1187,7 @@ export function createPromptState(input: PromptInput): PromptState {
return
}
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
input.onExit()
return
@@ -781,6 +781,7 @@ export function RunFooterView(props: RunFooterViewProps) {
command: {
name,
arguments: "",
source: "skill",
},
})
closePanel()
@@ -158,14 +158,14 @@ export async function runNonInteractivePrompt(input: Input) {
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = toMillis(event.created)
if (event.type === "prompt.promoted") {
if (event.type === "session.prompt.promoted") {
if (event.data.inputID === messageID) {
promoted = true
continue
}
}
if (
event.type === "execution.settled" &&
event.type === "session.execution.settled" &&
event.data.outcome === "interrupted" &&
(interrupted || permissionRejected || questionRejected || formCancelled)
) {
@@ -173,7 +173,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
if (!promoted) continue
if (event.type === "step.started") {
if (event.type === "session.step.started") {
const part: StepStartPart = {
id: partID(event.id),
sessionID: input.sessionID,
@@ -189,11 +189,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "text.started") {
if (event.type === "session.text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "text.ended") {
if (event.type === "session.text.ended") {
const started = starts.get(event.data.textID)
const part: TextPart = {
id: started?.id ?? partID(event.id),
@@ -207,11 +207,11 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "reasoning.started") {
if (event.type === "session.reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time })
continue
}
if (event.type === "reasoning.ended" && input.thinking) {
if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID)
const part: ReasoningPart = {
id: started?.id ?? partID(event.id),
@@ -236,7 +236,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "tool.input.started") {
if (event.type === "session.tool.input.started") {
tools.set(event.data.callID, {
id: partID(event.id),
timestamp: time,
@@ -246,12 +246,12 @@ export async function runNonInteractivePrompt(input: Input) {
})
continue
}
if (event.type === "tool.input.ended") {
if (event.type === "session.tool.input.ended") {
const current = tools.get(event.data.callID)
if (current) current.raw = event.data.text
continue
}
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
const current = tools.get(event.data.callID)
tools.set(event.data.callID, {
id: current?.id ?? partID(event.id),
@@ -264,7 +264,7 @@ export async function runNonInteractivePrompt(input: Input) {
})
continue
}
if (event.type === "tool.success") {
if (event.type === "session.tool.success") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const part: ToolPart = {
id: current.id,
@@ -297,7 +297,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!emit("tool_use", time, { part })) await input.renderTool(part)
continue
}
if (event.type === "tool.failed") {
if (event.type === "session.tool.failed") {
const current = tools.get(event.data.callID) ?? fallbackTool(event)
const error = event.data.error.message
const part: ToolPart = {
@@ -328,7 +328,7 @@ export async function runNonInteractivePrompt(input: Input) {
continue
}
if (event.type === "step.ended") {
if (event.type === "session.step.ended") {
const part: StepFinishPart = {
id: partID(event.id),
sessionID: input.sessionID,
@@ -342,14 +342,14 @@ export async function runNonInteractivePrompt(input: Input) {
emit("step_finish", time, { part })
continue
}
if (event.type === "step.failed") {
if (event.type === "session.step.failed") {
if (interrupted || permissionRejected || questionRejected || formCancelled) continue
emittedError = true
process.exitCode = 1
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue
}
if (event.type === "execution.settled") {
if (event.type === "session.execution.settled") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
+3 -1
View File
@@ -665,7 +665,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
)
}
includeFiles = false
// Shell and skill turns never send CLI file attachments; keep them
// pending for the next prompt-shaped turn.
if (prompt.mode !== "shell" && prompt.command?.source !== "skill") includeFiles = false
} catch (error) {
if (signal.aborted || footer.isClosed) {
return
@@ -62,7 +62,7 @@ type SessionCommit = StreamCommit
// - sent: part ID → byte offset of last flushed text (for incremental output)
// - visible: part ID → rendered text for an active part after display transforms
// - end: part IDs whose time.end has arrived (part is finished)
// - shell: shell call ID → chosen transcript source for direct shell calls
// - shell: shell ID → chosen transcript source for direct shell calls
// - echo: message ID → bash outputs to strip from the next assistant chunk
type ShellCall = {
source: "shell" | "tool"
@@ -607,12 +607,12 @@ function toolCommit(
}
}
function shellPartID(callID: string): string {
return `shell:${callID}`
function shellPartID(shellID: string): string {
return `shell:${shellID}`
}
function claimShell(data: SessionData, callID: string, source: ShellCall["source"], command?: string): ShellCall {
const current = data.shell.get(callID)
function claimShell(data: SessionData, shellID: string, source: ShellCall["source"], command?: string): ShellCall {
const current = data.shell.get(shellID)
if (current) {
if (command && !current.command) {
current.command = command
@@ -625,7 +625,7 @@ function claimShell(data: SessionData, callID: string, source: ShellCall["source
source,
...(command ? { command } : {}),
} satisfies ShellCall
data.shell.set(callID, next)
data.shell.set(shellID, next)
return next
}
@@ -728,37 +728,37 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const data = input.data
const event = input.event
if (event.type === "shell.started") {
if (event.type === "session.shell.started") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
const shell = claimShell(data, event.properties.callID, "shell", event.properties.command)
const shell = claimShell(data, event.properties.shell.id, "shell", event.properties.shell.command)
if (shell.source !== "shell") {
return out(data, commits)
}
const partID = shellPartID(event.properties.callID)
const partID = shellPartID(event.properties.shell.id)
if (data.ids.has(partID) || data.tools.has(partID)) {
return out(data, commits, patch({ status: "running shell" }))
}
data.tools.add(partID)
commits.push(startShell(event.properties.callID, shell.command ?? event.properties.command))
commits.push(startShell(event.properties.shell.id, shell.command ?? event.properties.shell.command))
return out(data, commits, patch({ status: "running shell" }))
}
if (event.type === "shell.ended") {
if (event.type === "session.shell.ended") {
if (event.properties.sessionID !== input.sessionID) {
return out(data, commits)
}
const shell = claimShell(data, event.properties.callID, "shell")
const shell = claimShell(data, event.properties.shell.id, "shell")
if (shell.source !== "shell") {
return out(data, commits)
}
const partID = shellPartID(event.properties.callID)
const partID = shellPartID(event.properties.shell.id)
const seen = data.tools.has(partID)
const command = shell.command ?? ""
data.tools.delete(partID)
@@ -767,11 +767,11 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
}
if (!seen && command) {
commits.push(startShell(event.properties.callID, command))
commits.push(startShell(event.properties.shell.id, command))
}
data.ids.add(partID)
commits.push(doneShell(event.properties.callID, command, event.properties.output))
commits.push(doneShell(event.properties.shell.id, command, event.properties.output.output))
return out(data, commits)
}
@@ -424,21 +424,21 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
}
const reduce = (child: ChildState, event: V2Event) => {
if (event.type === "prompt.promoted") {
if (event.type === "session.prompt.promoted") {
if (userFrame(child, event.data.inputID, "")) {
touch(child, event.created)
notifyDetail(child)
}
return
}
if (event.type === "step.started") {
if (event.type === "session.step.started") {
touch(child, event.created)
if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent)
if (child.status !== "running") child.status = "running"
input.emit()
return
}
if (event.type === "text.delta") {
if (event.type === "session.text.delta") {
const projected = child.projectedText.get(event.data.textID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -459,7 +459,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "text.ended") {
if (event.type === "session.text.ended") {
child.text.set(event.data.textID, event.data.text)
child.projectedText.delete(event.data.textID)
setFrame(child, `text:${event.data.textID}`, {
@@ -474,7 +474,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "reasoning.delta") {
if (event.type === "session.reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID)
const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) {
@@ -495,7 +495,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "reasoning.ended") {
if (event.type === "session.reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text)
child.projectedReasoning.delete(event.data.reasoningID)
if (!input.thinking) return
@@ -510,11 +510,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "tool.input.started") {
if (event.type === "session.tool.input.started") {
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created })
return
}
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, {
name: event.data.tool,
@@ -537,10 +537,10 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "tool.success" || event.type === "tool.failed") {
if (event.type === "session.tool.success" || event.type === "session.tool.failed") {
if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID)
const failed = event.type === "tool.failed"
const failed = event.type === "session.tool.failed"
childTool(
child,
{
@@ -577,7 +577,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "step.failed") {
if (event.type === "session.step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error",
source: "system",
@@ -589,7 +589,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child)
return
}
if (event.type === "execution.settled") {
if (event.type === "session.execution.settled") {
child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
touch(child, event.created)
@@ -613,15 +613,15 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return {
main(event) {
if (event.type === "tool.called") {
if (event.type === "session.tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
return
}
if (event.type === "tool.failed") {
if (event.type === "session.tool.failed") {
pendingCalls.delete(event.data.callID)
return
}
if (event.type !== "tool.success") return
if (event.type !== "session.tool.success") return
const pending = pendingCalls.get(event.data.callID)
pendingCalls.delete(event.data.callID)
const found = childSessionID(record(event.data.structured))

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