Compare commits

..

19 Commits

Author SHA1 Message Date
Kit Langton 0574107e18 tui: active tabs now show more of their title before truncating
The selected tab in the titlebar expands from 224px to 288px so users can read more of the session or draft name before it gets cut off. Inactive tabs stay compact at 224px to keep the tab strip dense.
2026-07-30 20:46:52 -04:00
Kit Langton 6f0fa2d6c6 core: remove unused ownerHeld bookkeeping in service restarter
The ownerHeld flag was assigned but never read, making the service restart retry logic harder to follow. Removing it clarifies that the spawn delay only doubles when a previous contender finished cleanly.
2026-07-30 20:46:46 -04:00
Kit Langton 363905dcdd fix(tui): share runtime with external plugins 2026-07-28 21:00:49 -04:00
Kit Langton d71bc0055a fix(core): refresh reused session recency 2026-07-28 21:00:43 -04:00
Kit Langton 4f235fc59b feat(voice): harden controller runtime 2026-07-28 21:00:35 -04:00
Kit Langton 1d772f4896 feat(voice): add audio-reactive TUI animations 2026-07-27 16:08:14 -04:00
Kit Langton 113a117cef feat(voice): curate project session and permission tools 2026-07-27 15:46:48 -04:00
Kit Langton c103ba6056 feat(voice): show readable tool lifecycle diagnostics 2026-07-27 15:11:48 -04:00
Kit Langton 38f03c9d8b fix(voice): default coding sessions to gpt-5.6-sol medium 2026-07-27 15:08:03 -04:00
Kit Langton e5ac4ca560 fix(voice): run reactive opentui and harden shutdown 2026-07-27 14:53:27 -04:00
Kit Langton 5f0487fa87 feat(voice): opentui interface, voice switching, ordered transcripts 2026-07-27 14:23:40 -04:00
Kit Langton 00557f9078 fix(voice): rebuild audio engines on route change, cleaner output 2026-07-27 14:09:28 -04:00
Kit Langton 5ac1089c5c fix(voice): make echo cancellation opt-in, self-healing audio helper 2026-07-27 14:02:49 -04:00
Kit Langton d1843f49ac feat(voice): echo-cancelled full duplex via Apple voice processing 2026-07-27 13:49:23 -04:00
Kit Langton aac66d384e fix(voice): stop server VAD from cancelling half-duplex playback 2026-07-27 13:36:19 -04:00
Kit Langton 83108c1b5d feat(voice): --session flag to pin an existing session 2026-07-27 11:52:34 -04:00
Kit Langton e7000161f7 fix(voice): gate mic against speaker echo 2026-07-26 12:33:09 -04:00
Kit Langton 66200eee34 fix(voice): password flag and friendly connect error 2026-07-26 12:00:17 -04:00
Kit Langton 699477755f feat(voice): realtime voice control spike 2026-07-26 11:09:28 -04:00
573 changed files with 19937 additions and 13755 deletions
-1
View File
@@ -60,7 +60,6 @@ const { a, b } = obj
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use type-position `import("...")` references such as `Schema.declare<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>`. Only when two imports genuinely collide on a name and no other option exists, an aliased type import (`import type { Plugin as PluginDefinition } from "..."`) is permitted as a last resort — still strongly preferred not to.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
- Prefer dynamic imports for heavy modules that are only needed in selected code paths, especially in startup-sensitive entrypoints. Destructure dynamic import bindings near the top of the narrowest scope that needs them so they read like normal imports. Avoid inline chains such as `await import("./module").then((mod) => mod.value())` or `(await import("./module")).value()`. Keep branch-specific imports inside the branch that needs them to preserve lazy loading.
+35 -20
View File
@@ -184,7 +184,7 @@
"effect": "catalog:",
},
"peerDependencies": {
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.98",
},
"optionalPeers": [
"effect",
@@ -376,7 +376,6 @@
"@openrouter/ai-sdk-provider": "2.9.0",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"diff": "catalog:",
@@ -547,7 +546,7 @@
"name": "@opencode-ai/http-recorder",
"version": "1.18.4",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-beta.101",
"@effect/platform-node-shared": "4.0.0-beta.98",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
@@ -584,7 +583,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "1.18.5",
"@standard-schema/spec": "catalog:",
"@standard-schema/spec": "^1.1.0",
"effect": "catalog:",
"zod": "catalog:",
},
@@ -627,7 +626,6 @@
"name": "@opencode-ai/schema",
"version": "1.17.11",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
@@ -672,7 +670,6 @@
"@effect/platform-node": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/simulation": "workspace:*",
"@opencode-ai/util": "workspace:*",
"drizzle-orm": "catalog:",
@@ -970,7 +967,6 @@
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"cross-spawn": "catalog:",
"effect": "catalog:",
"glob": "13.0.5",
@@ -990,6 +986,25 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/voice": {
"name": "@opencode-ai/voice",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"effect": "catalog:",
"opentui-spinner": "catalog:",
"solid-js": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.4",
@@ -1046,16 +1061,16 @@
],
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
},
"overrides": {
"@opentui/core": "catalog:",
@@ -1063,14 +1078,13 @@
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.101",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
@@ -1090,7 +1104,6 @@
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@standard-schema/spec": "1.1.0",
"@tailwindcss/vite": "4.1.11",
"@tanstack/solid-virtual": "3.13.32",
"@tsconfig/bun": "1.0.9",
@@ -1107,7 +1120,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.98",
"fuzzysort": "3.1.0",
"get-east-asian-width": "1.6.0",
"hono": "4.10.7",
@@ -1551,13 +1564,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.101", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.101" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IdejlqRLbjRHJgVnea4s8CxTWfvkSjM0HlnpNfP07IGTbhmAvPs7PMaWt1xzYWbOnI7CriQwxv+54eW1PwIkZg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.98", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.98" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-ITfK8xhcl+9GXOvPwzADWkOQ+dgUGZrJNefT3r2+uLFmzjyKRLtHzhLOl6lZaLSsf5io13+nmt8adfMRQPq+oA=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.98", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.98", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98", "ioredis": "^5.7.0" } }, "sha512-IQu1TiLXQEDSGkDBllyYjVadf+UqdjptryqX4mmktVTTbGDq7X4uVxe7cSgXuqZvyfG6kagTzwj2lfynxOaKQg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.98", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-iySXaffnCJX1sNAIp79ghhIeui9E5qwUQyqd1VLPkB9UNO4vdpd9B5fTEXwe7S/GusL4jsk9vSvX38XJgRFG1w=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-s6AC7LXCEjCN+nKegKFY4MOi6bmT1+SLR9YHEYwhY3P5qyQQB4R5yYLgt+3J4EPp5fa3t4FIDdPEUwz9LdKm6g=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.98", "", { "peerDependencies": { "effect": "^4.0.0-beta.98" } }, "sha512-cc41uLhYBqexdbTNu4dlui+31E8hcVLEapLySa0C8d60FmBY8IEAV/RD3oF+6pqPslKEZ9p1+XVLdDm0iflw5Q=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2083,6 +2096,8 @@
"@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
"@opencode-ai/voice": ["@opencode-ai/voice@workspace:packages/voice"],
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
@@ -3835,7 +3850,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
"effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
+6 -8
View File
@@ -36,9 +36,9 @@
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.101",
"@effect/platform-node": "4.0.0-beta.101",
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
"@effect/opentelemetry": "4.0.0-beta.98",
"@effect/platform-node": "4.0.0-beta.98",
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
@@ -50,7 +50,6 @@
"@opentui/solid": "0.4.5",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
@@ -69,7 +68,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.101",
"effect": "4.0.0-beta.98",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -152,8 +151,7 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
"@types/node": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
@@ -167,7 +165,7 @@
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
}
}
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -20,6 +19,7 @@ import {
type ProviderMetadata,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -425,7 +425,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
// Tool results may carry structured text, images, and documents. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
item: Tool.Content,
item: ToolContent,
) {
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
@@ -436,7 +436,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
// with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Tool.Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, lowerToolResultContentItem)
})
+2 -2
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -17,6 +16,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
} from "../schema"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
})
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
const media: GeminiInlineDataPart[] = []
for (const item of content) {
+4 -4
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol"
import {
@@ -15,6 +14,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
type ToolResultPart,
} from "../schema"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -371,7 +371,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
// Tool results may carry structured text, images, and files. Keep media as provider-native
// content instead of JSON-stringifying base64 into a prompt string.
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
item: Content,
item: ToolContent,
request: LLMRequest,
extension: Extension,
) {
@@ -392,7 +392,7 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
@@ -496,7 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
+2 -2
View File
@@ -1,5 +1,4 @@
import { Effect, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client"
import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint"
@@ -18,6 +17,7 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolContent,
} from "../schema"
import { classifyProviderFailure } from "../provider-error"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
@@ -335,7 +335,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const content: ReadonlyArray<ToolContent> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const files = content.filter((item) => item.type === "file")
+2 -2
View File
@@ -1,5 +1,4 @@
import { Buffer } from "node:buffer"
import { Tool } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http"
@@ -10,6 +9,7 @@ import {
type ContentPart,
type LLMRequest,
type MediaPart,
type ToolFileContent,
type TextPart,
type ToolResultPart,
} from "../schema"
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
})
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
+5 -2
View File
@@ -1,5 +1,4 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literal("context-overflow")
@@ -153,4 +152,8 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
* treated as a defect and fails the stream.
*/
export class ToolFailure extends Tool.Error {}
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
message: Schema.String,
error: Schema.optional(Schema.Defect()),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}) {}
+7 -5
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record"
@@ -40,6 +40,8 @@ export const MediaPart = Schema.Struct({
}).annotate({ identifier: "LLM.Content.Media" })
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
export { ToolContent, ToolFileContent, ToolTextContent }
const isToolResultValue = (value: unknown): value is ToolResultValue =>
isRecord(value) &&
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
@@ -61,7 +63,7 @@ export const ToolResultValue = Object.assign(
}),
Schema.Struct({
type: Schema.Literal("content"),
value: Schema.Array(Tool.Content),
value: Schema.Array(ToolContent),
}),
]).annotate({ identifier: "LLM.ToolResult" }),
{
@@ -77,16 +79,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
export interface ToolOutput {
readonly structured: unknown
readonly content: ReadonlyArray<Tool.Content>
readonly content: ReadonlyArray<ToolContent>
}
export const ToolOutput = Object.assign(
Schema.Struct({
structured: Schema.Unknown,
content: Schema.Array(Tool.Content),
content: Schema.Array(ToolContent),
}).annotate({ identifier: "LLM.ToolOutput" }),
{
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }),
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
switch (result.type) {
case "json":
+1 -1
View File
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
return decodeAndExecute(tool, call).pipe(
Effect.map((value) => result(call, value)),
Effect.catchTag("Tool.Error", (failure) =>
Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
),
)
+6 -6
View File
@@ -1,7 +1,7 @@
import { Effect, JsonSchema, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import type {
ToolCallPart,
ToolContent,
ToolDefinition as ToolDefinitionClass,
ToolOutput as ToolOutputType,
} from "./schema"
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
) => ReadonlyArray<Tool.Content>
) => ReadonlyArray<ToolContent>
/**
* A type-safe LLM tool. Each tool bundles its own description, parameter
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown
}
@@ -151,7 +151,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown
}): AnyExecutableTool
export function make(config: {
@@ -159,7 +159,7 @@ export function make(config: {
readonly jsonSchema: JsonSchema.JsonSchema
readonly outputSchema?: JsonSchema.JsonSchema
readonly execute?: undefined
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
readonly toStructuredOutput?: (output: unknown) => unknown
}): AnyTool
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
}
const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown,
callID: ToolCallPart["id"],
+2 -2
View File
@@ -1,5 +1,4 @@
import { describe, expect } from "bun:test"
import { Content } from "@opencode-ai/schema/tool"
import { Effect, Schema, Stream } from "effect"
import {
GenerationOptions,
@@ -8,6 +7,7 @@ import {
LLMRequest,
LLMResponse,
ToolChoice,
ToolContent,
ToolOutput,
toDefinitions,
} from "../src"
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
it.effect("models canonical tool files with URIs", () =>
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(Content)
const decode = Schema.decodeUnknownSync(ToolContent)
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
type: "file",
@@ -88,8 +88,12 @@ function SessionTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
class="relative flex min-w-7 flex-shrink"
classList={{
hidden: !session() && !missingSession() && !persisted()?.title,
"w-72 max-w-72": props.active(),
"w-56 max-w-56": !props.active(),
}}
>
<TabNavItem
ref={(el) => {
@@ -144,7 +148,11 @@ function DraftTabSlot(props: {
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
class="relative flex min-w-7 flex-shrink"
classList={{
"w-72 max-w-72": props.active(),
"w-56 max-w-56": !props.active(),
}}
>
<DraftTabItem
ref={(el) => {
@@ -8,8 +8,8 @@ import type {
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -171,7 +171,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
break
}
@@ -5,8 +5,8 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
const msg = (id: string, sessionID: string) =>
@@ -32,7 +32,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SessionDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -63,7 +63,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SessionDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -4,14 +4,14 @@ import type {
PermissionRequest,
QuestionRequest,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, SessionDiff[] | undefined>
session_diff: Record<string, FileDiffInfo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
permission: Record<string, PermissionRequest[] | undefined>
@@ -13,9 +13,9 @@ import type {
ReferenceInfo,
Session,
SessionStatus,
FileDiffInfo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -50,7 +50,7 @@ export type State = {
}
session_working(id: string): boolean
session_diff: {
[sessionID: string]: SessionDiff[]
[sessionID: string]: FileDiffInfo[]
}
permission: {
[sessionID: string]: PermissionRequest[]
+1 -1
View File
@@ -25,7 +25,7 @@ describe("adaptServerEvent", () => {
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.v2.asked",
type: "permission.asked",
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
+8 -16
View File
@@ -1,5 +1,5 @@
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event, PermissionRequest } from "@opencode-ai/sdk/v2/client"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -18,15 +18,7 @@ const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
type PermissionEvent = {
id: string
type: "permission.v2.asked"
properties: PermissionRequest
current?: OpenCodeEvent
}
export type ServerEvent = (Event | PermissionEvent) & {
current?: OpenCodeEvent
}
export type ServerEvent = Event & { current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type CurrentDelta = Extract<
OpenCodeEvent,
@@ -37,7 +29,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
if (event.type === "permission.v2.asked") {
return {
id: event.id,
type: "permission.v2.asked",
type: "permission.asked",
properties: {
id: event.data.id,
sessionID: event.data.sessionID,
@@ -51,16 +43,16 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
: undefined,
},
current: event,
}
} as ServerEvent
}
if (event.type === "permission.v2.replied")
return { id: event.id, type: "permission.v2.replied", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.asked")
return { id: event.id, type: "question.v2.asked", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.replied")
return { id: event.id, type: "question.v2.replied", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
if (event.type === "question.v2.rejected")
return { id: event.id, type: "question.v2.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
+3 -3
View File
@@ -8,8 +8,8 @@ import type {
QuestionRequest,
Session,
SessionStatus,
FileDiffInfo,
} from "@opencode-ai/sdk/v2/client"
import type { SessionDiff } from "@/utils/diffs"
import { batch } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
@@ -140,7 +140,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
const [data, setData] = createStore({
info: {} as Record<string, Session | undefined>,
session_status: {} as Record<string, SessionStatus>,
session_diff: {} as Record<string, SessionDiff[]>,
session_diff: {} as Record<string, FileDiffInfo[]>,
permission: {} as Record<string, PermissionRequest[]>,
question: {} as Record<string, QuestionRequest[]>,
message: {} as Record<string, Message[]>,
@@ -773,7 +773,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
return
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
return
}
+7 -2
View File
@@ -909,8 +909,13 @@ export default function Page() {
)
const stopVcs = sdk().event.listen((evt) => {
if (evt.details.type !== "file.watcher.updated") return
if (evt.details.properties.file.startsWith(".git/")) 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>)
: undefined
const file = typeof props?.file === "string" ? props.file : undefined
if (!file || file.startsWith(".git/")) return
refreshVcs()
})
onCleanup(stopVcs)
@@ -1,7 +1,6 @@
import { createEffect, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/session-ui/session-review"
import type {
SessionReviewCommentActions,
@@ -15,7 +14,7 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = SessionDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
@@ -23,8 +23,7 @@ import { Mark } from "@opencode-ai/ui/logo"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -60,7 +59,7 @@ import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => (SessionDiff | VcsFileDiff)[]
diffs: () => (FileDiffInfo | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean
@@ -1,9 +1,8 @@
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { Kind } from "@/components/file-tree-v2"
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
export type RenderDiff = SessionDiff | VcsFileDiff
export type RenderDiff = FileDiffInfo | VcsFileDiff
export function normalizePath(p: string) {
return normalizeFileTreeV2Path(p)
@@ -1,6 +1,5 @@
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { SessionDiff } from "@/utils/diffs"
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
import {
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
@@ -30,7 +29,7 @@ import {
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
type ReviewDiff = SessionDiff | VcsFileDiff
type ReviewDiff = FileDiffInfo | VcsFileDiff
export type ReviewPanelV2Props = {
title?: JSX.Element
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { diffs, message } from "./diffs"
@@ -9,7 +9,7 @@ const item = {
additions: 1,
deletions: 1,
status: "modified",
} satisfies SnapshotFileDiff
} satisfies FileDiffInfo
describe("diffs", () => {
test("keeps valid arrays", () => {
+4 -4
View File
@@ -1,9 +1,9 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
import type { Message } from "@opencode-ai/sdk/v2/client"
export type SessionDiff = SnapshotFileDiff & { file: string; patch: string }
type Diff = FileDiffInfo
function diff(value: unknown): value is SessionDiff {
function diff(value: unknown): value is Diff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
if (!("patch" in value) || typeof value.patch !== "string") return false
@@ -17,7 +17,7 @@ function object(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function diffs(value: unknown): SessionDiff[] {
export function diffs(value: unknown): Diff[] {
if (Array.isArray(value) && value.every(diff)) return value
if (Array.isArray(value)) return value.filter(diff)
if (diff(value)) return [value]
+1 -1
View File
@@ -71,7 +71,7 @@ export async function streamTurn(input: {
const next = await stream.next()
if (next.done) throw new Error("event stream disconnected during prompt execution")
const event = next.value
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
await replyPermission({
client: input.client,
+1 -1
View File
@@ -5,7 +5,7 @@ import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Tool = { readonly name: string; readonly input: ToolInput }
+1 -2
View File
@@ -5,7 +5,7 @@ import { readDisplayText } from "@opencode-ai/tui/mini/tool"
export type ToolInput = Record<string, unknown>
export type ToolContent = ReadonlyArray<
| { readonly type: "text"; readonly text: string }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string | null }
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
>
export function toToolKind(toolName: string): ToolKind {
@@ -44,7 +44,6 @@ export function toLocations(toolName: string, input: ToolInput, cwd?: string): T
return workdir ? [{ path: workdir }] : []
}
case "read":
return locationFrom(input.path)
case "edit":
case "write":
case "patch":
+1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import "@opentui/solid/runtime-plugin-support"
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Commands } from "./commands/commands"
@@ -9,8 +9,8 @@ import {
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin/effect"
import { Tool } from "@opencode-ai/schema/tool"
} from "@opencode-ai/plugin/v2/effect"
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
const key = Symbol.for("opencode.plugin.v2.effect")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@@ -24,5 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.effect")
Provider,
Reference,
Skill,
Tool: { Error: Tool.Error },
Tool,
}
@@ -9,7 +9,8 @@ import {
Provider,
Reference,
Skill,
} from "@opencode-ai/plugin"
} from "@opencode-ai/plugin/v2"
import { Tool } from "@opencode-ai/plugin/v2/tool"
const key = Symbol.for("opencode.plugin.v2.promise")
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
@@ -23,4 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.promise")
Provider,
Reference,
Skill,
Tool,
}
+3 -3
View File
@@ -1,11 +1,11 @@
import type {
EventSubscribeOutput,
JsonValue,
LLMToolContent,
LocationRef,
OpenCodeClient,
SessionMessageAssistantTool,
SessionMessageInfo,
ToolContent,
} from "@opencode-ai/client/promise"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os"
@@ -56,7 +56,7 @@ type ToolState = StartedPart & {
provider?: unknown
providerState?: SessionMessageAssistantTool["providerState"]
metadata: Record<string, JsonValue>
content: ToolContent[]
content: LLMToolContent[]
}
type V2Event = EventSubscribeOutput
@@ -177,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
const event = next.value
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
await replyPermission(event.data)
continue
}
+1 -3
View File
@@ -13,13 +13,11 @@ type Options = {
readonly command?: ReadonlyArray<string>
}
const startupDirectory = process.cwd()
function command(password: string, options: Options) {
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
if (!executable) throw new Error("Failed to resolve standalone server command")
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
cwd: startupDirectory,
cwd: process.cwd(),
// Explicit entry wins over anything inherited, so a user-exported
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
env: { OPENCODE_PASSWORD: password },
+3 -5
View File
@@ -1,13 +1,11 @@
import path from "node:path"
const entrypoint = process.argv[1] ? path.resolve(process.argv[1]) : undefined
export function selfCommand() {
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
if (!entrypoint) throw new Error("Failed to resolve CLI entrypoint")
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), entrypoint]
return [process.execPath, entrypoint]
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
return [process.execPath, process.argv[1]]
}
function nodeFlags() {
+2 -2
View File
@@ -244,7 +244,7 @@ describe("acp event behavior", () => {
sessionID: "ses_tools",
assistantMessageID: "msg_tools",
callID: "call_fail",
input: { path: "/workspace/missing.ts" },
input: { filePath: "/workspace/missing.ts" },
executed: false,
}),
)
@@ -638,7 +638,7 @@ function replayFixtureMessages(): SessionMessageInfo[] {
time: { created: 2, completed: 3 },
state: {
status: "error",
input: { path: "/workspace/missing.ts" },
input: { filePath: "/workspace/missing.ts" },
metadata: { bytes: 0 },
content: [{ type: "text", text: "partial" }],
error: { type: "tool.error", message: "failed hard" },
@@ -49,7 +49,7 @@ describe("acp permission behavior", () => {
send(
permissionAsked("ses_allow", "perm_always", {
action: "read",
metadata: { path: "/workspace/file.ts" },
metadata: { filePath: "/workspace/file.ts" },
source: { type: "tool", messageID: "msg_allow", callID: "call_always" },
}),
)
@@ -96,7 +96,7 @@ describe("acp permission behavior", () => {
title: "/workspace/file.ts",
kind: "read",
locations: [{ path: "/workspace/file.ts" }],
rawInput: { path: "/workspace/file.ts" },
rawInput: { filePath: "/workspace/file.ts" },
},
})
expect(permissionReplies(fixture)).toEqual([
@@ -502,7 +502,7 @@ function permissionAsked(
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
} = {},
) {
return ephemeralEvent("permission.asked", {
return ephemeralEvent("permission.v2.asked", {
id,
sessionID,
action: input.action ?? "shell",
+5 -6
View File
@@ -28,7 +28,7 @@ describe("acp tools", () => {
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { path: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
@@ -43,7 +43,7 @@ describe("acp tools", () => {
])
expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }])
expect(toLocations("bash", { command: "printf hello" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([{ path: "/tmp/missing-file-path.ts" }])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text and image attachments", () => {
@@ -205,13 +205,12 @@ describe("acp tools", () => {
runningToolUpdate({
toolCallId: "call",
toolName: "read",
state: { input: { path: "/tmp/a" } },
state: { input: { filePath: "/tmp/a" } },
content: [{ type: "text", text: "done" }],
}),
).toMatchObject({
toolCallId: "call",
status: "in_progress",
locations: [{ path: "/tmp/a" }],
content: [{ type: "content", content: { type: "text", text: "done" } }],
})
})
@@ -274,7 +273,7 @@ describe("acp tools", () => {
errorToolUpdate({
toolCallId: "call",
toolName: "read",
input: { path: "/tmp/a" },
input: { filePath: "/tmp/a" },
content: [{ type: "text", text: "partial output" }],
metadata: { path: "/tmp/a" },
error: "failed",
@@ -285,7 +284,7 @@ describe("acp tools", () => {
kind: "read",
title: "read",
locations: [{ path: "/tmp/a" }],
rawInput: { path: "/tmp/a" },
rawInput: { filePath: "/tmp/a" },
content: [
{ type: "content", content: { type: "text", text: "partial output" } },
{ type: "content", content: { type: "text", text: "failed" } },
@@ -3,14 +3,12 @@ import { Service } from "@opencode-ai/client/effect/service"
import path from "node:path"
import { Standalone } from "../../src/services/standalone"
process.chdir(path.join(import.meta.dir, "../../../.."))
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const endpoint = yield* Standalone.start({
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
})
const endpoint = yield* Standalone.start()
const response = yield* Effect.promise(() =>
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
)
+20 -11
View File
@@ -75,11 +75,20 @@ export const define = sdk.Plugin.define`
const effectPluginModule = promisePluginModule
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
.replace("Promise plugin", "Effect plugin")
const promiseToolModule = `export {}`
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Tool = sdk.Tool
export const make = sdk.Tool.make`
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
export const Error = sdk.Tool.Error
`
export const Tool = sdk.Tool
export const Failure = sdk.Tool.Failure
export const RegistrationError = sdk.Tool.RegistrationError
export const make = sdk.Tool.make
export const validateName = sdk.Tool.validateName
export const registrationEntries = sdk.Tool.registrationEntries
export const validateNamespace = sdk.Tool.validateNamespace
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
import __cjs_mod__ from "node:module"
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
@@ -91,17 +100,17 @@ const __filename = import.meta.filename
const __dirname = import.meta.dirname
const require = __cjs_mod__.createRequire(import.meta.url)
const __ocPluginModules = ${JSON.stringify({
"@opencode-ai/plugin": "opencode:plugin-v2",
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
})}
const __ocPluginSources = ${JSON.stringify({
"opencode:plugin-v2": promiseModule,
"opencode:plugin-promise-plugin": promisePluginModule,
"opencode:plugin-promise-tool": promiseToolModule,
"opencode:plugin-v2-plugin": promisePluginModule,
"opencode:plugin-v2-tool": promiseToolModule,
"opencode:plugin-v2-effect": effectModule,
"opencode:plugin-v2-effect-plugin": effectPluginModule,
"opencode:plugin-v2-effect-tool": effectToolModule,
+1 -1
View File
@@ -36,7 +36,7 @@
"@opencode-ai/protocol": "workspace:*"
},
"peerDependencies": {
"effect": "4.0.0-beta.101"
"effect": "4.0.0-beta.98"
},
"peerDependenciesMeta": {
"effect": {
+3 -104
View File
@@ -6,86 +6,11 @@ import {
groupNames,
promiseOmitEndpoints,
} from "@opencode-ai/protocol/client"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Credential } from "@opencode-ai/schema/credential"
import { Event } from "@opencode-ai/schema/event"
import { EventLog } from "@opencode-ai/schema/event-log"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Form } from "@opencode-ai/schema/form"
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import { Integration } from "@opencode-ai/schema/integration"
import { Location } from "@opencode-ai/schema/location"
import { Mcp } from "@opencode-ai/schema/mcp"
import { Model } from "@opencode-ai/schema/model"
import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
import { Pty } from "@opencode-ai/schema/pty"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { Question } from "@opencode-ai/schema/question"
import { Reference } from "@opencode-ai/schema/reference"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Effect, Schema } from "effect"
import { Effect } from "effect"
import { fileURLToPath } from "url"
const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
const effectTypeReferences = [
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
...namespaceTypes("FileDiff", "@opencode-ai/schema/file-diff", FileDiff),
...namespaceTypes("FileSystem", "@opencode-ai/schema/filesystem", FileSystem),
...namespaceTypes("Form", "@opencode-ai/schema/form", Form),
...namespaceTypes("InstructionEntry", "@opencode-ai/schema/instruction-entry", InstructionEntry),
...namespaceTypes("Integration", "@opencode-ai/schema/integration", Integration),
...namespaceTypes("Location", "@opencode-ai/schema/location", Location),
...namespaceTypes("Mcp", "@opencode-ai/schema/mcp", Mcp),
...namespaceTypes("Model", "@opencode-ai/schema/model", Model),
...namespaceTypes("Permission", "@opencode-ai/schema/permission", Permission),
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
...namespaceTypes("PtyTicket", "@opencode-ai/schema/pty-ticket", PtyTicket),
...namespaceTypes("Question", "@opencode-ai/schema/question", Question),
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
...namespaceTypes("WebSearch", "@opencode-ai/schema/websearch", WebSearch),
...namespaceTypes("Workspace", "@opencode-ai/schema/workspace", Workspace),
typeReference("Prompt", "@opencode-ai/schema/prompt", Prompt),
typeReference("PromptMention", "@opencode-ai/schema/prompt", PromptMention),
typeReference("FileAttachment", "@opencode-ai/schema/prompt", FileAttachment),
typeReference("AgentAttachment", "@opencode-ai/schema/prompt", AgentAttachment),
typeReference("AbsolutePath", "@opencode-ai/schema/schema", AbsolutePath),
typeReference("PositiveInt", "@opencode-ai/schema/schema", PositiveInt),
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
]
await Effect.runPromise(
Effect.all(
@@ -97,40 +22,14 @@ await Effect.runPromise(
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
),
write(
emitEffectImported(effectContract, {
module: "../../contract",
api: "ClientApi",
shapeModule: "../api/api.js",
}),
emitEffectImported(effectContract, { module: "../../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
),
write(
emitEffectShape(effectContract, {
typeReferences: effectTypeReferences,
outputTypes: {
"event.subscribe": {
name: "OpenCodeEvent",
import: 'import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"',
},
},
}),
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
),
],
{ concurrency: 3, discard: true },
).pipe(Effect.provide(NodeFileSystem.layer)),
)
function namespaceTypes(namespace: string, module: string, values: object) {
return Object.entries(values).flatMap(([name, schema]) =>
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema)] : [],
)
}
function typeReference(name: string, module: string, schema: Schema.Top) {
return {
schema,
name,
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,8 +7,6 @@ import type {
LocationGetOutput,
AgentListInput,
AgentListOutput,
AgentGetInput,
AgentGetOutput,
PluginListInput,
PluginListOutput,
SessionListInput,
@@ -405,18 +403,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
request<AgentGetOutput>(
{
method: "GET",
path: `/api/agent/${encodeURIComponent(input.agentID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
},
plugin: {
list: (input?: PluginListInput, requestOptions?: RequestOptions) =>
+192 -154
View File
@@ -6,11 +6,11 @@ export type ServiceStopResponse = { accepted: boolean }
export type ModelRef = { id: string; providerID: string; variant?: string }
export type ProviderSettings = { [x: string]: any }
export type ProviderSettings = { [x: string]: JsonValue }
export type AgentColor = string
export type PermissionEffect = "allow" | "deny" | "ask"
export type PermissionV2Effect = "allow" | "deny" | "ask"
export type PluginInfo = { id: string }
@@ -110,7 +110,7 @@ export type SessionMessageToolStateRunning = {
export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string }
export type SessionStructuredError = { type: string; message: string }
@@ -163,8 +163,6 @@ export type SessionMessageProviderState6 = { [x: string]: any }
export type SessionMessageProviderState7 = { [x: string]: any }
export type ToolFileContent1 = { type: "file"; uri: string; mime: string; name?: string | undefined }
export type SessionMessageProviderState8 = { [x: string]: any }
export type SessionMessageProviderState9 = { [x: string]: any }
@@ -177,24 +175,24 @@ export type ModelCapabilities = { tools: boolean; input: Array<string>; output:
export type ModelVariant = {
id: string
settings?: { [x: string]: any }
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: any }
body?: { [x: string]: JsonValue }
}
export type MoneyUSDPerMillionTokens = number
export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
export type ProviderV2Info = {
id: string
integrationID?: string
name: string
disabled?: boolean
package: string
settings?: { [x: string]: any }
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: any }
body?: { [x: string]: JsonValue }
}
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
@@ -301,7 +299,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
export type FormValue = string | number | boolean | Array<string>
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
export type PermissionV2Source = { type: "tool"; messageID: string; callID: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
@@ -325,9 +323,9 @@ export type FileDiffLegacyInfo = {
status?: "added" | "deleted" | "modified"
}
export type PermissionV1Action = "allow" | "deny" | "ask"
export type PermissionAction = "allow" | "deny" | "ask"
export type SessionV1JSONSchema = { [x: string]: any }
export type JSONSchema = { [x: string]: any }
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
@@ -358,7 +356,7 @@ export type APIError = {
}
}
export type SessionV1TextPart = {
export type TextPart = {
id: string
sessionID: string
messageID: string
@@ -370,7 +368,7 @@ export type SessionV1TextPart = {
metadata?: { [x: string]: any } | undefined
}
export type SessionV1SubtaskPart = {
export type SubtaskPart = {
id: string
sessionID: string
messageID: string
@@ -382,7 +380,7 @@ export type SessionV1SubtaskPart = {
command?: string | undefined
}
export type SessionV1ReasoningPart = {
export type ReasoningPart = {
id: string
sessionID: string
messageID: string
@@ -392,13 +390,13 @@ export type SessionV1ReasoningPart = {
time: { start: number; end?: number | undefined }
}
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
export type FilePartSourceText = { value: string; start: number; end: number }
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
export type Range = { start: { line: number; character: number }; end: { line: number; character: number } }
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
export type ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
export type SessionV1ToolStateRunning = {
export type ToolStateRunning = {
status: "running"
input: { [x: string]: any }
title?: string | undefined
@@ -406,7 +404,7 @@ export type SessionV1ToolStateRunning = {
time: { start: number }
}
export type SessionV1ToolStateError = {
export type ToolStateError = {
status: "error"
input: { [x: string]: any }
error: string
@@ -414,7 +412,7 @@ export type SessionV1ToolStateError = {
time: { start: number; end: number }
}
export type SessionV1StepStartPart = {
export type StepStartPart = {
id: string
sessionID: string
messageID: string
@@ -422,7 +420,7 @@ export type SessionV1StepStartPart = {
snapshot?: string | undefined
}
export type SessionV1StepFinishPart = {
export type StepFinishPart = {
id: string
sessionID: string
messageID: string
@@ -439,15 +437,9 @@ export type SessionV1StepFinishPart = {
}
}
export type SessionV1SnapshotPart = {
id: string
sessionID: string
messageID: string
type: "snapshot"
snapshot: string
}
export type SnapshotPart = { id: string; sessionID: string; messageID: string; type: "snapshot"; snapshot: string }
export type SessionV1PatchPart = {
export type PatchPart = {
id: string
sessionID: string
messageID: string
@@ -456,7 +448,7 @@ export type SessionV1PatchPart = {
files: Array<string>
}
export type SessionV1AgentPart = {
export type AgentPart = {
id: string
sessionID: string
messageID: string
@@ -465,7 +457,7 @@ export type SessionV1AgentPart = {
source?: { value: string; start: number; end: number } | undefined
}
export type SessionV1CompactionPart = {
export type CompactionPart = {
id: string
sessionID: string
messageID: string
@@ -475,7 +467,7 @@ export type SessionV1CompactionPart = {
tail_start_id?: string | undefined
}
export type PermissionReply = "once" | "always" | "reject"
export type PermissionV2Reply = "once" | "always" | "reject"
export type Pty = {
id: string
@@ -488,11 +480,11 @@ export type Pty = {
exitCode?: number
}
export type QuestionOption = { label: string; description: string }
export type QuestionV2Option = { label: string; description: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionV2Tool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string>
export type QuestionV2Answer = Array<string>
export type FormMetadata1 = { [x: string]: any }
@@ -509,6 +501,12 @@ export type SessionStatus =
}
| { type: "busy" }
export type QuestionOption = { label: string; description: string }
export type QuestionTool = { messageID: string; callID: string }
export type QuestionAnswer = Array<string>
export type ShellInfo1 = {
id: string
status: "running" | "exited" | "timeout" | "killed"
@@ -566,10 +564,10 @@ export type CommandInfo = {
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
body: { [x: string]: any }
body: { [x: string]: JsonValue }
}
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
export type PermissionV2Rule = { action: string; resource: string; effect: PermissionV2Effect }
export type SessionAgentSelected = {
id: string
@@ -1046,11 +1044,11 @@ export type ShellDeleted = {
data: { id: string }
}
export type QuestionRejected = {
export type QuestionV2Rejected = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.rejected"
type: "question.v2.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
}
@@ -1188,6 +1186,41 @@ export type McpResourcesChanged = {
data: { server: string }
}
export type PermissionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.asked"
location?: LocationRef
data: {
id: string
sessionID: string
permission: string
patterns: Array<string>
metadata: { [x: string]: any }
always: Array<string>
tool?: { messageID: string; callID: string } | undefined
}
}
export type PermissionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
}
export type QuestionRejected = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.rejected"
location?: LocationRef
data: { sessionID: string; requestID: string }
}
export type V2EventServerConnected = {
id: string
metadata?: { [x: string]: any } | undefined
@@ -1228,7 +1261,7 @@ export type SessionMessageAssistantReasoning = {
time?: { created: number; completed?: number }
}
export type ToolContent = ToolTextContent | ToolFileContent
export type LLMToolContent = ToolTextContent | ToolFileContent
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
@@ -1390,8 +1423,6 @@ export type SessionToolCalled = {
}
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = { reasoningField?: ModelReasoningField }
export type ModelCost = {
@@ -1513,21 +1544,21 @@ export type FormMultiselectField = {
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
export type PermissionV2Request = {
id: string
sessionID: string
action: string
resources: Array<string>
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
source?: PermissionV2Source
}
export type PermissionAsked = {
export type PermissionV2Asked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.asked"
type: "permission.v2.asked"
location?: LocationRef
data: {
id: string
@@ -1536,17 +1567,17 @@ export type PermissionAsked = {
resources: Array<string>
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionSource
source?: PermissionV2Source
}
}
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
export type PermissionRule = { permission: string; pattern: string; action: PermissionAction }
export type SessionV1OutputFormat =
export type OutputFormat =
| { type: "text" }
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
| { type: "json_schema"; schema: JSONSchema; retryCount?: number | undefined | undefined }
export type SessionV1AssistantMessage = {
export type AssistantMessage = {
id: string
sessionID: string
role: "assistant"
@@ -1581,7 +1612,7 @@ export type SessionV1AssistantMessage = {
finish?: string | undefined
}
export type SessionV1RetryPart = {
export type RetryPart = {
id: string
sessionID: string
messageID: string
@@ -1612,31 +1643,26 @@ export type SessionError = {
}
}
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
export type FileSource = { text: FilePartSourceText; type: "file"; path: string }
export type SessionV1ResourceSource = {
text: SessionV1FilePartSourceText
type: "resource"
clientName: string
uri: string
}
export type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
export type SessionV1SymbolSource = {
text: SessionV1FilePartSourceText
export type SymbolSource = {
text: FilePartSourceText
type: "symbol"
path: string
range: SessionV1Range
range: Range
name: string
kind: number
}
export type PermissionReplied = {
export type PermissionV2Replied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "permission.replied"
type: "permission.v2.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; reply: PermissionReply }
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
}
export type PtyCreated = {
@@ -1657,21 +1683,21 @@ export type PtyUpdated = {
data: { info: Pty }
}
export type QuestionInfo = {
export type QuestionV2Info = {
question: string
header: string
options: Array<QuestionOption>
options: Array<QuestionV2Option>
multiple?: boolean
custom?: boolean
}
export type QuestionReplied = {
export type QuestionV2Replied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.replied"
type: "question.v2.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
}
export type FormStringField1 = {
@@ -1748,9 +1774,26 @@ export type SessionStatus2 = {
data: { sessionID: string; status: SessionStatus }
}
export type QuestionInfo = {
question: string
header: string
options: Array<QuestionOption>
multiple?: boolean | undefined
custom?: boolean | undefined
}
export type QuestionReplied = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.replied"
location?: LocationRef
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
}
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type PermissionRuleset = Array<PermissionRule>
export type PermissionV2Ruleset = Array<PermissionV2Rule>
export type SessionInfo = {
id: string
@@ -1805,7 +1848,7 @@ export type SessionPendingUserData1 = {
export type SessionMessageToolStateCompleted = {
status: "completed"
input: { [x: string]: JsonValue }
content: [ToolContent, ...Array<ToolContent>]
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
@@ -1813,15 +1856,10 @@ export type SessionMessageToolStateError = {
status: "error"
input: { [x: string]: JsonValue }
error: SessionStructuredError
content?: [ToolContent, ...Array<ToolContent>]
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type SessionToolSuccess = {
id: string
created: number
@@ -1833,7 +1871,7 @@ export type SessionToolSuccess = {
sessionID: string
assistantMessageID: string
callID: string
content: [ToolContent1, ...Array<ToolContent1>]
content: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState8
@@ -1852,13 +1890,18 @@ export type SessionToolFailed = {
assistantMessageID: string
callID: string
error: SessionStructuredError
content?: [ToolContent1, ...Array<ToolContent1>]
content?: [LLMToolContent, ...Array<LLMToolContent>]
metadata?: { [x: string]: JsonValue }
executed: boolean
resultState?: SessionMessageProviderState9
}
}
export type SessionMessageCompaction =
| SessionMessageCompactionRunning
| SessionMessageCompactionCompleted
| SessionMessageCompactionFailed
export type ModelInfo = {
id: string
modelID: string
@@ -1867,9 +1910,9 @@ export type ModelInfo = {
name: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: any }
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: any }
body?: { [x: string]: JsonValue }
capabilities: ModelCapabilities
variants: Array<ModelVariant>
time: { released: number }
@@ -1905,14 +1948,14 @@ export type FormReplied = {
data: { id: string; sessionID: string; answer: FormAnswer }
}
export type PermissionV1Ruleset = Array<PermissionV1Rule>
export type PermissionRuleset = Array<PermissionRule>
export type SessionV1UserMessage = {
export type UserMessage = {
id: string
sessionID: string
role: "user"
time: { created: number }
format?: SessionV1OutputFormat | undefined
format?: OutputFormat | undefined
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
agent: string
model: { providerID: string; modelID: string; variant?: string | undefined }
@@ -1920,18 +1963,23 @@ export type SessionV1UserMessage = {
tools?: { [x: string]: boolean } | undefined
}
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
export type FilePartSource = FileSource | SymbolSource | ResourceSource
export type QuestionAsked = {
export type QuestionV2Asked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.asked"
type: "question.v2.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
}
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
export type QuestionV2Request = {
id: string
sessionID: string
questions: Array<QuestionV2Info>
tool?: QuestionV2Tool
}
export type FormField1 =
| FormStringField1
@@ -1941,6 +1989,15 @@ export type FormField1 =
| FormMultiselectField1
| FormExternalField
export type QuestionAsked = {
id: string
created: number
metadata?: { [x: string]: any }
type: "question.asked"
location?: LocationRef
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
}
export type ReferenceInfo = {
name: string
path: string
@@ -1960,7 +2017,7 @@ export type AgentInfo = {
hidden: boolean
color?: AgentColor
steps?: number
permissions: PermissionRuleset
permissions: PermissionV2Ruleset
}
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
@@ -2018,13 +2075,13 @@ export type SessionV1Info = {
version: string
metadata?: { [x: string]: any }
time: { created: number; updated: number; compacting?: number; archived?: number }
permission?: PermissionV1Ruleset
permission?: PermissionRuleset
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
}
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
export type Message = UserMessage | AssistantMessage
export type SessionV1FilePart = {
export type FilePart = {
id: string
sessionID: string
messageID: string
@@ -2032,7 +2089,7 @@ export type SessionV1FilePart = {
mime: string
filename?: string | undefined
url: string
source?: SessionV1FilePartSource | undefined
source?: FilePartSource | undefined
}
export type FormFields1 = [FormField1, ...Array<FormField1>]
@@ -2103,17 +2160,17 @@ export type MessageUpdated = {
type: "message.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; info: SessionV1Message }
data: { sessionID: string; info: Message }
}
export type SessionV1ToolStateCompleted = {
export type ToolStateCompleted = {
status: "completed"
input: { [x: string]: any }
output: string
title: string
metadata: { [x: string]: any }
time: { start: number; end: number; compacted?: number | undefined }
attachments?: Array<SessionV1FilePart> | undefined
attachments?: Array<FilePart> | undefined
}
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
@@ -2139,11 +2196,7 @@ export type SessionMessageInfo =
| SessionMessageAssistant
| SessionMessageCompaction
export type SessionV1ToolState =
| SessionV1ToolStatePending
| SessionV1ToolStateRunning
| SessionV1ToolStateCompleted
| SessionV1ToolStateError
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export type FormCreated = {
id: string
@@ -2199,32 +2252,32 @@ export type SessionMessagesResponse = {
cursor: { previous?: string | null; next?: string | null }
}
export type SessionV1ToolPart = {
export type ToolPart = {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: SessionV1ToolState
state: ToolState
metadata?: { [x: string]: any } | undefined
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionV1Part =
| SessionV1TextPart
| SessionV1SubtaskPart
| SessionV1ReasoningPart
| SessionV1FilePart
| SessionV1ToolPart
| SessionV1StepStartPart
| SessionV1StepFinishPart
| SessionV1SnapshotPart
| SessionV1PatchPart
| SessionV1AgentPart
| SessionV1RetryPart
| SessionV1CompactionPart
export type Part =
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
export type MessagePartUpdated = {
id: string
@@ -2233,7 +2286,7 @@ export type MessagePartUpdated = {
type: "message.part.updated"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; part: SessionV1Part; time: number }
data: { sessionID: string; part: Part; time: number }
}
export type V2Event =
@@ -2294,8 +2347,8 @@ export type V2Event =
| SessionRevertCommitted
| FilesystemChanged
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PermissionV2Asked
| PermissionV2Replied
| PluginAdded
| PluginUpdated
| ProjectDirectoriesUpdated
@@ -2309,9 +2362,9 @@ export type V2Event =
| ShellCreated
| ShellExited
| ShellDeleted
| QuestionAsked
| QuestionReplied
| QuestionRejected
| QuestionV2Asked
| QuestionV2Replied
| QuestionV2Rejected
| FormCreated
| FormReplied
| FormCancelled
@@ -2327,6 +2380,11 @@ export type V2Event =
| VcsBranchUpdated
| McpStatusChanged
| McpResourcesChanged
| PermissionAsked
| PermissionReplied
| QuestionAsked
| QuestionReplied
| QuestionRejected
| SessionError
| V2EventServerConnected
@@ -2343,14 +2401,6 @@ export type InvalidRequestError = {
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
export type AgentNotFoundError = {
readonly _tag: "AgentNotFoundError"
readonly agentID: string
readonly message: string
}
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2534,18 +2584,6 @@ export type AgentListOutput = {
data: Array<AgentInfo>
}
export type AgentGetInput = {
readonly agentID: { readonly agentID: string }["agentID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type AgentGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: AgentInfo
}
export type PluginListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -3274,7 +3312,7 @@ export type ProviderListInput = {
export type ProviderListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<ProviderInfo>
data: Array<ProviderV2Info>
}
export type ProviderGetInput = {
@@ -3286,7 +3324,7 @@ export type ProviderGetInput = {
export type ProviderGetOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: ProviderInfo
data: ProviderV2Info
}
export type IntegrationListInput = {
@@ -4438,7 +4476,7 @@ export type PermissionRequestListInput = {
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<PermissionRequest>
data: Array<PermissionV2Request>
}
export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
@@ -4516,18 +4554,18 @@ export type PermissionCreateInput = {
}["agent"]
}
export type PermissionCreateOutput = { data: { id: string; effect: PermissionEffect } }["data"]
export type PermissionCreateOutput = { data: { id: string; effect: PermissionV2Effect } }["data"]
export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type PermissionListOutput = { data: Array<PermissionRequest> }["data"]
export type PermissionListOutput = { data: Array<PermissionV2Request> }["data"]
export type PermissionGetInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
}
export type PermissionGetOutput = { data: PermissionRequest }["data"]
export type PermissionGetOutput = { data: PermissionV2Request }["data"]
export type PermissionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
@@ -4826,12 +4864,12 @@ export type QuestionRequestListInput = {
export type QuestionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
data: Array<QuestionRequest>
data: Array<QuestionV2Request>
}
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
export type QuestionListOutput = { data: Array<QuestionV2Request> }["data"]
export type QuestionReplyInput = {
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
-3
View File
@@ -37,7 +37,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
let announced = false
let lastSpawn = 0
let spawnDelay = 5_000
let ownerHeld = false
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
if (announced) return
@@ -65,7 +64,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
const registration = await registered(options.file, true)
if (registration.service !== undefined) {
ownerHeld = false
spawnDelay = 5_000
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
@@ -82,7 +80,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (failure !== undefined) throw failure
const finished = [...contenders].filter(contenderFinished)
if (finished.some((item) => item.child.exitCode === 0)) {
ownerHeld = true
spawnDelay = Math.min(spawnDelay * 2, 30_000)
}
finished.forEach((item) => contenders.delete(item))
+1 -6
View File
@@ -1,6 +1,5 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -10,10 +9,6 @@ declare const promiseClient: PromiseClient
const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
sessionID: "ses_test" as Session.ID,
})
declare const sessionID: Parameters<typeof effectApi.session.instructions.entry.list>[0]["sessionID"]
const effectList: Effect.Effect<
@@ -42,4 +37,4 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes",
})
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
void [effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
@@ -14,14 +14,6 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
expect(Client.Session).toBe(Session)
})
test("generated Effect API names canonical and composed outputs", async () => {
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
expect(source).not.toContain("HttpApiClient.ForApi")
})
test("shared DTO schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
-1
View File
@@ -99,7 +99,6 @@
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"@openrouter/ai-sdk-provider": "2.9.0",
+1 -1
View File
@@ -1,4 +1,4 @@
export * as Account from "./account"
export * as AccountV2 from "./account"
import { Schema } from "effect"
import type { HttpClientError } from "effect/unstable/http"
+8 -8
View File
@@ -1,14 +1,14 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { Account } from "../account"
import { AccountV2 } from "../account"
import { Timestamps } from "../database/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().$type<Account.ID>().primaryKey(),
id: text().$type<AccountV2.ID>().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<Account.AccessToken>().notNull(),
refresh_token: text().$type<Account.RefreshToken>().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
token_expiry: integer(),
...Timestamps,
})
@@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", {
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<Account.ID>()
.$type<AccountV2.ID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<Account.OrgID>(),
active_org_id: text().$type<AccountV2.OrgID>(),
})
// LEGACY
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<Account.AccessToken>().notNull(),
refresh_token: text().$type<Account.RefreshToken>().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
+12 -12
View File
@@ -1,9 +1,9 @@
export * as Agent from "./agent"
export * as AgentV2 from "./agent"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Bus } from "./bus"
import { EventV2 } from "./event"
import { State } from "./state"
export const ID = Agent.ID
@@ -17,7 +17,7 @@ export const Color = Agent.Color
export const Info = Agent.Info
export type Info = Agent.Info
export { Event } from "@opencode-ai/schema/agent"
export const Event = Agent.Event
export interface Selection {
readonly id: ID
@@ -45,12 +45,12 @@ export interface Interface extends State.Transformable<Draft> {
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const state = State.create<Data, Draft>({
name: "agent",
initial: () => ({ agents: new Map() }),
@@ -70,7 +70,7 @@ const layer = Layer.effect(
draft.agents.delete(id)
},
}),
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
@@ -89,17 +89,17 @@ const layer = Layer.effect(
return Service.of({
transform: state.transform,
reload: state.reload,
get: Effect.fn("Agent.get")(function* (id) {
get: Effect.fn("AgentV2.get")(function* (id) {
return state.get().agents.get(id)
}),
default: Effect.fn("Agent.default")(function* () {
default: Effect.fn("AgentV2.default")(function* () {
return selectedDefault()
}),
resolve: Effect.fn("Agent.resolve")(function* (id) {
resolve: Effect.fn("AgentV2.resolve")(function* (id) {
if (id !== undefined) return state.get().agents.get(ID.make(id))
return selectedDefault()
}),
select: Effect.fn("Agent.select")(function* (id) {
select: Effect.fn("AgentV2.select")(function* (id) {
if (id !== undefined) {
const selected = ID.make(id)
return { id: selected, info: state.get().agents.get(selected) }
@@ -107,11 +107,11 @@ const layer = Layer.effect(
const info = selectedDefault()
return { id: info?.id ?? defaultID, info }
}),
list: Effect.fn("Agent.list")(function* () {
list: Effect.fn("AgentV2.list")(function* () {
return Array.fromIterable(state.get().agents.values())
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
+20 -20
View File
@@ -32,8 +32,8 @@ import {
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
import { Provider } from "./provider"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { State } from "./state"
type SDK = any
@@ -42,14 +42,14 @@ type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
export interface SDKEvent {
readonly model: Info
readonly model: ModelV2.Info
readonly package: string
readonly options: Record<string, any>
sdk?: SDK
}
export interface LanguageEvent {
readonly model: Info
readonly model: ModelV2.Info
readonly sdk: SDK
readonly options: Record<string, any>
language?: LanguageModelV3
@@ -103,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: Info, pkg: string) {
function prepareOptions(model: ModelV2.Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
@@ -146,7 +146,7 @@ function prepareOptions(model: Info, pkg: string) {
if (typeof opts.body === "string" && model.body !== undefined) {
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body))
if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) {
opts.body = JSON.stringify(Provider.mergeOverlay(decoded, model.body))
opts.body = JSON.stringify(ProviderV2.mergeOverlay(decoded, model.body))
}
}
@@ -162,11 +162,11 @@ function prepareOptions(model: Info, pkg: string) {
}
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
providerID: Provider.ID,
providerID: ProviderV2.ID,
cause: Schema.Defect(),
}) {}
function initError(providerID: Provider.ID) {
function initError(providerID: ProviderV2.ID) {
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
}
@@ -181,11 +181,11 @@ export interface Interface {
}
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: Info) => Effect.Effect<Model, InitError>
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
readonly model: (model: ModelV2.Info) => Effect.Effect<Model, InitError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
export const locationLayer = Layer.effect(
Service,
@@ -260,13 +260,13 @@ export const locationLayer = Layer.effect(
})
const existing = languages.get(key)
if (existing) return existing
if (!Provider.isAISDK(model.package))
if (!ProviderV2.isAISDK(model.package))
return yield* new InitError({
providerID: model.providerID,
cause: new Error(`Unsupported package ${model.package}`),
})
const packageName = Provider.packageName(model.package)
const packageName = ProviderV2.packageName(model.package)
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
@@ -301,8 +301,8 @@ export const locationLayer = Layer.effect(
export const defaultLayer = locationLayer
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
const packageName = ProviderV2.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const providerOptions = (() => {
@@ -352,7 +352,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
})
}
function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, unknown>>) {
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
const gateway =
typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway)
? Object.fromEntries(Object.entries(settings.gateway))
@@ -369,7 +369,7 @@ function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, u
return { gateway: model }
}
function providerOptionKey(packageName: string | undefined, providerID: Provider.ID) {
function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) {
if (packageName === "@ai-sdk/google") return "google"
if (packageName === "@ai-sdk/google-vertex") return "vertex"
if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic"
@@ -395,18 +395,18 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
return Object.keys(result).length === 0 ? undefined : result
}
function mapBodyToProviderOptions(model: Info, packageName: string) {
function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) {
const settings = requestSettings(model.settings)
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
const forceReasoning =
["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) &&
(pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined)
const normalized = forceReasoning ? Provider.mergeOverlay(settings, { forceReasoning: true }) : settings
const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings
if (!pro) return { settings: normalized, body: model.body }
const body = { ...model.body }
delete body.reasoning
return {
settings: Provider.mergeOverlay(normalized, { reasoningMode: "pro" }),
settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }),
body: Object.keys(body).length === 0 ? undefined : body,
}
}
+51 -51
View File
@@ -3,82 +3,82 @@ export * as Catalog from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog"
import { Model } from "./model"
import { Provider } from "./provider"
import { Bus } from "./bus"
import { ModelV2 } from "./model"
import { ProviderV2 } from "./provider"
import { EventV2 } from "./event"
import { State } from "./state"
import { Integration } from "./integration"
export type ProviderRecord = {
provider: Provider.MutableInfo
models: Map<Model.ID, Model.MutableInfo>
provider: ProviderV2.MutableInfo
models: Map<ModelV2.ID, ModelV2.MutableInfo>
}
export type DefaultModel = { providerID: Provider.ID; modelID: Model.ID }
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
export { Event } from "@opencode-ai/schema/catalog"
export const Event = Catalog.Event
type Data = {
providers: Map<Provider.ID, ProviderRecord>
providers: Map<ProviderV2.ID, ProviderRecord>
defaultModel?: DefaultModel
}
export type Draft = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: Provider.ID) => ProviderRecord | undefined
update: (providerID: Provider.ID, fn: (provider: Provider.MutableInfo) => void) => void
remove: (providerID: Provider.ID) => void
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
get: (providerID: Provider.ID, modelID: Model.ID) => Model.Info | undefined
update: (providerID: Provider.ID, modelID: Model.ID, fn: (model: Model.MutableInfo) => void) => void
remove: (providerID: Provider.ID, modelID: Model.ID) => void
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
default: {
get: () => DefaultModel | undefined
set: (providerID: Provider.ID, modelID: Model.ID) => void
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
}
}
export interface Interface extends State.Transformable<Draft> {
readonly provider: {
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
readonly all: () => Effect.Effect<Provider.Info[]>
readonly available: () => Effect.Effect<Provider.Info[]>
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
readonly model: {
readonly get: (providerID: Provider.ID, modelID: Model.ID) => Effect.Effect<Model.Info | undefined>
readonly all: () => Effect.Effect<Model.Info[]>
readonly available: () => Effect.Effect<Model.Info[]>
readonly default: () => Effect.Effect<Model.Info | undefined>
readonly small: (providerID: Provider.ID) => Effect.Effect<Model.Info | undefined>
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Catalog") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const integrations = yield* Integration.Service
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.settings?.apiKey === "string") return true
if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration
}
const projectModel = (model: Model.Info, provider: Provider.Info) => {
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
return {
...model,
package: model.package ?? provider.package,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
body: Provider.mergeOverlay(provider.body, model.body),
} satisfies Model.Info
settings: ProviderV2.mergeOverlay(provider.settings, model.settings),
headers: ProviderV2.mergeHeaders(provider.headers, model.headers),
body: ProviderV2.mergeOverlay(provider.body, model.body),
} satisfies ModelV2.Info
}
const state = State.create<Data, Draft>({
@@ -93,8 +93,8 @@ const layer = Layer.effect(
let current = draft.providers.get(providerID)
if (!current) {
current = {
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, current)
}
@@ -110,13 +110,13 @@ const layer = Layer.effect(
let record = draft.providers.get(providerID)
if (!record) {
record = {
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
}
draft.providers.set(providerID, record)
}
const model =
record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
fn(model)
model.id = modelID
@@ -135,8 +135,8 @@ const layer = Layer.effect(
}
return result
},
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
yield* bus.publish(Catalog.Event.Updated, {})
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
yield* events.publish(Event.Updated, {})
}),
})
const result: Interface = {
@@ -144,15 +144,15 @@ const layer = Layer.effect(
reload: state.reload,
provider: {
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("Catalog.provider.all")(function* () {
all: Effect.fn("CatalogV2.provider.all")(function* () {
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
}),
available: Effect.fn("Catalog.provider.available")(function* () {
available: Effect.fn("CatalogV2.provider.available")(function* () {
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
return (yield* result.provider.all()).filter((provider) =>
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
@@ -161,14 +161,14 @@ const layer = Layer.effect(
},
model: {
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
return model && projectModel(model, record.provider)
}),
all: Effect.fn("Catalog.model.all")(function* () {
all: Effect.fn("CatalogV2.model.all")(function* () {
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => {
@@ -178,9 +178,9 @@ const layer = Layer.effect(
)
}),
available: Effect.fn("Catalog.model.available")(function* () {
available: Effect.fn("CatalogV2.model.available")(function* () {
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
const models: Model.Info[] = []
const models: ModelV2.Info[] = []
for (const record of state.get().providers.values()) {
if (!providers.has(record.provider.id)) continue
for (const model of record.models.values()) {
@@ -194,7 +194,7 @@ const layer = Layer.effect(
)
}),
default: Effect.fn("Catalog.model.default")(function* () {
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = yield* result.provider.get(defaultModel.providerID)
@@ -207,18 +207,18 @@ const layer = Layer.effect(
return (yield* result.model.available())[0]
}),
small: Effect.fn("Catalog.model.small")(function* (providerID) {
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return
const provider = record.provider
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
return
}
if (providerID === Provider.ID.opencode) {
const gpt5Nano = record.models.get(Model.ID.make("gpt-5-nano"))
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
}
@@ -268,4 +268,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: [Bus.node, Integration.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
+77
View File
@@ -0,0 +1,77 @@
export * as CodeMode from "./codemode"
import { Context, Effect, Layer, Scope } from "effect"
import { CodeModeCatalog } from "./codemode/catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { PermissionV2 } from "./permission"
import { ExecuteTool } from "./tool/execute"
import type { Any, Registration } from "./tool/tool"
import { Wildcard } from "./util/wildcard"
export interface Materialization {
readonly tool?: Any
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
}
export interface Interface {
readonly register: (
registrations: ReadonlyArray<Registration & { readonly key: string }>,
) => Effect.Effect<void, never, Scope.Scope>
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
return Service.of({
register: Effect.fn("CodeMode.register")(function* (registrations) {
if (registrations.length === 0) return
yield* Effect.uninterruptible(
Effect.gen(function* () {
const token = {}
for (const registration of registrations)
local.set(registration.key, [
...(local.get(registration.key) ?? []),
{
token,
registration,
},
])
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const registration of registrations) {
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
if (remaining.length > 0) local.set(registration.key, remaining)
else local.delete(registration.key)
}
}),
)
}),
)
}),
materialize: Effect.fn("CodeMode.materialize")(function* (permissions) {
const registrations = new Map<string, ExecuteTool.Registration>()
const rules = permissions ?? []
for (const [name, entries] of local) {
const registration = entries.at(-1)?.registration
if (!registration) continue
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
if (rule?.resource === "*" && rule.effect === "deny") continue
registrations.set(name, registration)
}
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
return {
tool: ExecuteTool.create(registrations),
catalog: ExecuteTool.catalog(registrations),
}
}),
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [] })
+10 -10
View File
@@ -1,11 +1,11 @@
export * as Command from "./command"
export * as CommandV2 from "./command"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema, Types } from "effect"
import { Command } from "@opencode-ai/schema/command"
import { State } from "./state"
import { MCP } from "./mcp/index"
import { Bus } from "./bus"
import { EventV2 } from "./event"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "./config"
@@ -14,7 +14,7 @@ import { ShellSelect } from "./shell/select"
export const Info = Command.Info
export type Info = Command.Info
export { Event } from "@opencode-ai/schema/command"
export const Event = Command.Event
export type Evaluation = {
readonly text: string
@@ -50,13 +50,13 @@ export interface Interface extends State.Transformable<Draft> {
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
export const layer = (options?: ShellSelect.Options) => Layer.effect(
Service,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const processes = yield* AppProcess.Service
const config = yield* Config.Service
const location = yield* Location.Service
@@ -76,7 +76,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
draft.commands.delete(name)
},
}),
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
const mcpCommands = Effect.fnUntraced(function* () {
@@ -92,12 +92,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return Service.of({
reload: state.reload,
transform: state.transform,
get: Effect.fn("Command.get")(function* (name) {
get: Effect.fn("CommandV2.get")(function* (name) {
const command = staticCommand(name)
if (command) return command
return (yield* mcpCommands()).find((command) => command.name === name)
}),
list: Effect.fn("Command.list")(function* () {
list: Effect.fn("CommandV2.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const names = new Set(commands.map((command) => command.name))
return [
@@ -105,7 +105,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
]
}),
evaluate: Effect.fn("Command.evaluate")(function* (input) {
evaluate: Effect.fn("CommandV2.evaluate")(function* (input) {
const command = staticCommand(input.name)
if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
config,
@@ -247,7 +247,7 @@ export function configured(options?: ShellSelect.Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
})
}
+20 -53
View File
@@ -4,12 +4,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { Context, Effect, Fiber, Layer, Option, PubSub, Schema, Semaphore, Stream } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { Bus } from "./bus"
import { EventV2 } from "./event"
import { Watcher } from "./filesystem/watcher"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
@@ -159,12 +159,6 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
/**
* Streams raw filesystem updates under config roots. Config owns root
* topology and watch reconciliation; domain owners filter this feed for the
* source files they parse and rebuild their own state.
*/
readonly changes: () => Stream.Stream<Watcher.Update>
}
export const Options = Schema.Struct({
@@ -174,32 +168,7 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
export interface TestInterface extends Interface {
/** Replaces the entries returned by subsequent entries() calls. */
readonly setEntries: (entries: Entry[]) => Effect.Effect<void>
/** Emits one filesystem update to every changes() subscriber. */
readonly emitChange: (update: Watcher.Update) => Effect.Effect<void>
}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/Config/Test") {}
/** In-memory config for tests: static entries with replaceable state and a test-driven change feed. */
export const testLayer = (initial: Entry[] = []) =>
Layer.effectContext(
Effect.gen(function* () {
const entries = yield* Ref.make(initial)
const updates = yield* PubSub.unbounded<Watcher.Update>()
const service = Test.of({
entries: () => Ref.get(entries),
changes: () => Stream.fromPubSub(updates),
setEntries: (next) => Ref.set(entries, next),
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
})
return Context.empty().pipe(Context.add(Service, service), Context.add(Test, service))
}),
)
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
export const layer = (options?: Options) => Layer.effect(
Service,
@@ -208,7 +177,7 @@ export const layer = (options?: Options) => Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const credentials = yield* Credential.Service
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
@@ -373,30 +342,29 @@ export const layer = (options?: Options) => Layer.effect(
const initial = yield* discover()
let configs = initial
const updates = yield* PubSub.unbounded<Watcher.Update>()
// Vendored trees inside config roots (a plugin's node_modules, a nested
// .git) produce event blizzards that can never change discovery output.
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
// Watch-once: roots leave discovery only by deletion, so a stale watch is
// inert, bounded, and dies with this layer — and keeping a deleted root's
// watch alive is exactly what makes its recreation observable.
const watched = new Set<string>()
const subscriptions = new Map<string, Effect.Effect<unknown>>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...directories.map((path) => ({ path, type: "directory" as const })),
...files
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
.map((path) => ({ path, type: "file" as const })),
]
for (const target of targets) {
const key = JSON.stringify(target)
if (watched.has(key)) continue
watched.add(key)
yield* watcher.subscribe(target).pipe(
const next = new Map(targets.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))
}
})
@@ -407,7 +375,7 @@ export const layer = (options?: Options) => Layer.effect(
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* bus.publish(ConfigSchema.Event.Updated, {})
yield* events.publish(ConfigSchema.Event.Updated, {})
}),
),
)
@@ -421,7 +389,7 @@ export const layer = (options?: Options) => Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
@@ -433,7 +401,7 @@ export const layer = (options?: Options) => Layer.effect(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(WellKnown.Event.Updated).pipe(
yield* events.subscribe(WellKnown.Event.Updated).pipe(
Stream.runForEach(() =>
reload().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload wellknown sources", { cause }))),
),
@@ -462,7 +430,6 @@ export const layer = (options?: Options) => Layer.effect(
entries: Effect.fn("Config.entries")(function* () {
return configs
}),
changes: () => Stream.fromPubSub(updates),
})
}),
)
@@ -471,7 +438,7 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Watcher.node, Bus.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
deps: [Watcher.node, EventV2.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node],
})
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { PositiveInt } from "../schema"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
export class Info extends Schema.Class<Info>("ConfigV2.Agent")({
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
+2 -2
View File
@@ -3,13 +3,13 @@ export * as ConfigAttachments from "./attachments"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
export class Image extends Schema.Class<Image>("ConfigV2.Attachments.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Attachments")({
export class Info extends Schema.Class<Info>("ConfigV2.Attachments")({
image: Image.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigCommand from "./command"
import { Schema } from "effect"
import { ConfigModel } from "./model"
export class Info extends Schema.Class<Info>("Config.Command")({
export class Info extends Schema.Class<Info>("ConfigV2.Command")({
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
+2 -2
View File
@@ -3,11 +3,11 @@ export * as ConfigCompaction from "./compaction"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
export class Info extends Schema.Class<Info>("ConfigV2.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ConfigFormatter from "./formatter"
import { Schema } from "effect"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
export class Entry extends Schema.Class<Entry>("ConfigV2.Formatter.Entry")({
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+1 -1
View File
@@ -6,7 +6,7 @@ export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
})
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
export class Server extends Schema.Class<Server>("ConfigV2.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
+1 -1
View File
@@ -15,7 +15,7 @@ export const Remote = Mcp.RemoteConfig
export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
export class Info extends Schema.Class<Info>("ConfigV2.MCP")({
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ConfigPlugin from "./plugin"
import { Schema } from "effect"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
export class Entry extends Schema.Class<Entry>("ConfigV2.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}) {}
+19 -40
View File
@@ -1,9 +1,9 @@
export * as ConfigAgentPlugin from "./agent"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Agent } from "../../agent"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { ConfigAgent } from "../agent"
import { ConfigMarkdown } from "../markdown"
@@ -11,17 +11,15 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigAgentV1 } from "../../v1/config/agent"
import { ConfigMigrateV1 } from "../../v1/config/migrate"
import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission"
import { PermissionV2 } from "../../permission"
import type { LocationMutation } from "../../location-mutation"
import type { ReadTool } from "../../tool/plugin/read"
import type { EditTool } from "../../tool/plugin/edit"
import type { ReadTool } from "../../tool/read"
import type { EditTool } from "../../tool/edit"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
{ pattern: "{mode,modes}/*.md", primary: true },
] as const
// Keep in sync with the legacySources patterns and the name-strip regex in decode.
const sourceDirectories = ["agent", "agents", "mode", "modes"] as const
const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Config.Info)
@@ -69,40 +67,21 @@ export const Plugin = define({
})
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as Config.Document[] }
const reload = load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.agent.reload()),
)
// One merged trigger stream serializes reloads and shares one debounce
// window; subscribing before the initial scan means updates racing the
// scan still trigger a rebuild.
const sourceChanges = config
.changes()
.pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
)
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
yield* Stream.merge(sourceChanges, configUpdates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.documents = yield* load()
const loaded = { documents: yield* load() }
yield* ctx.agent.transform((draft) => {
const permissions = expandPermissions(
loaded.documents.flatMap((document) => document.info.permissions ?? []),
global.home,
)
const configuredDefault = Config.latest(loaded.documents, "default_agent")
if (configuredDefault !== undefined) draft.default(Agent.ID.make(configuredDefault))
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
}
for (const document of loaded.documents) {
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
const agentID = Agent.ID.make(id)
const agentID = AgentV2.ID.make(id)
if (item.disabled) {
draft.remove(agentID)
continue
@@ -134,20 +113,20 @@ export const Plugin = define({
}
}
})
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 }),
)
}),
})
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
// check: directory-level events such as renames carry no per-file paths.
function isAgentSource(entries: Config.Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
)
}
function expandPermissions(rules: Permission.Ruleset, home: string): Permission.Ruleset {
function expandPermissions(rules: PermissionV2.Ruleset, home: string): PermissionV2.Ruleset {
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
return rules.map((rule) =>
+13 -37
View File
@@ -1,9 +1,9 @@
export * as ConfigCommandPlugin from "./command"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Command } from "../../command"
import { CommandV2 } from "../../command"
import { Config } from "../../config"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { ConfigCommand } from "../command"
@@ -27,28 +27,7 @@ export const Plugin = define({
)
}).pipe(Effect.map((documents) => documents.flat()))
})
const loaded = { documents: [] as { commands: Config.Info["commands"] }[] }
const reload = load().pipe(
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
Effect.andThen(ctx.command.reload()),
)
// One merged trigger stream serializes reloads and shares one debounce
// window; subscribing before the initial scan means updates racing the
// scan still trigger a rebuild.
const sourceChanges = config
.changes()
.pipe(
Stream.filterEffect((update) =>
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
),
)
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
yield* Stream.merge(sourceChanges, configUpdates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.documents = yield* load()
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 ?? {})) {
@@ -67,22 +46,19 @@ export const Plugin = define({
}
}
})
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 }),
)
}),
})
// Keep in sync with the loadDirectory scan pattern and the name-strip regex in decode.
const sourceDirectories = ["command", "commands"] as const
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
// directory-level events such as renames carry no per-file paths.
function isCommandSource(entries: Config.Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
)
}
function loadDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigPolicyPlugin from "./policy"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Wildcard } from "../../util/wildcard"
+11 -11
View File
@@ -1,10 +1,10 @@
export * as ConfigProviderPlugin from "./provider"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { Provider } from "../../provider"
import { ProviderV2 } from "../../provider"
export const Plugin = define({
id: "opencode.config.provider",
@@ -49,9 +49,9 @@ export const Plugin = define({
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined)
provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
provider.settings = ProviderV2.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = ProviderV2.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = ProviderV2.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, id, (model) => {
@@ -62,9 +62,9 @@ export const Plugin = define({
model.compatibility = { ...model.compatibility, ...config.compatibility }
if (config.package !== undefined) model.package = config.package
if (config.settings !== undefined)
model.settings = Provider.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
model.settings = ProviderV2.mergeOverlay(model.settings, config.settings)
if (config.headers !== undefined) model.headers = ProviderV2.mergeHeaders(model.headers, config.headers)
if (config.body !== undefined) model.body = ProviderV2.mergeOverlay(model.body, config.body)
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
@@ -81,10 +81,10 @@ export const Plugin = define({
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, variant.settings)
existing.settings = ProviderV2.mergeOverlay(existing.settings, variant.settings)
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body)
existing.headers = ProviderV2.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined) existing.body = ProviderV2.mergeOverlay(existing.body, variant.body)
}
}
if (config.cost !== undefined) {
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigReferencePlugin from "./reference"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
+7 -7
View File
@@ -1,11 +1,11 @@
export * as ConfigSkillPlugin from "./skill"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import path from "path"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
import { AbsolutePath } from "../../schema"
import { Skill } from "../../skill"
import { SkillV2 } from "../../skill"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location"
@@ -23,7 +23,7 @@ export const Plugin = define({
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
for (const directory of [...claude, ...agents]) {
draft.source(
Skill.DirectorySource.make({
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
@@ -31,10 +31,10 @@ export const Plugin = define({
}
for (const directory of directories) {
draft.source(
Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
)
draft.source(
Skill.DirectorySource.make({
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.join(directory, "skills")),
}),
@@ -42,12 +42,12 @@ export const Plugin = define({
}
for (const item of items) {
if (URL.canParse(item) && /^(https?:)$/.test(new URL(item).protocol)) {
draft.source(Skill.UrlSource.make({ type: "url", url: item }))
draft.source(SkillV2.UrlSource.make({ type: "url", url: item }))
continue
}
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
draft.source(
Skill.DirectorySource.make({
SkillV2.DirectorySource.make({
type: "directory",
path: AbsolutePath.make(path.isAbsolute(expanded) ? expanded : path.join(location.directory, expanded)),
}),
+1 -1
View File
@@ -1,6 +1,6 @@
export * as ConfigWebSearchPlugin from "./websearch"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
import { Effect, Stream } from "effect"
import { Config } from "../../config"
+12 -12
View File
@@ -2,7 +2,7 @@ export * as ConfigProvider from "./provider"
import { Schema } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model"
import { ModelV2 } from "../model"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
@@ -12,17 +12,17 @@ export const Overlays = {
body: JsonRecord.pipe(Schema.optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
export class Request extends Schema.Class<Request>("ConfigV2.Provider.Request")({
headers: Overlays.headers,
body: Overlays.body,
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
class Cache extends Schema.Class<Cache>("ConfigV2.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
class Cost extends Schema.Class<Cost>("ConfigV2.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
@@ -32,22 +32,22 @@ class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
cache: Cache.pipe(Schema.optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
class Limit extends Schema.Class<Limit>("ConfigV2.Model.Limit")({
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
class Model extends Schema.Class<Model>("ConfigV2.Model")({
modelID: ModelV2.ID.pipe(Schema.optional),
family: ModelV2.Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
compatibility: ModelV2.Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
...Overlays,
capabilities: Capabilities.pipe(Schema.optional),
capabilities: ModelV2.Capabilities.pipe(Schema.optional),
variants: Schema.Struct({
id: VariantID,
id: ModelV2.VariantID,
...Overlays,
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
@@ -55,7 +55,7 @@ class Model extends Schema.Class<Model>("Config.Model")({
limit: Limit.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
export class Info extends Schema.Class<Info>("ConfigV2.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
+2 -2
View File
@@ -2,14 +2,14 @@ export * as ConfigReference from "./reference"
import { Schema } from "effect"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
+1 -1
View File
@@ -3,7 +3,7 @@ export * as ConfigToolOutput from "./tool-output"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
export class Info extends Schema.Class<Info>("ConfigV2.ToolOutput")({
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
}) {}
+1 -1
View File
@@ -2,7 +2,7 @@ export * as ConfigWarming from "./warming"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("Config.Warming")({
export class Info extends Schema.Class<Info>("ConfigV2.Warming")({
prompt: Schema.String.pipe(Schema.optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
+1 -1
View File
@@ -2,6 +2,6 @@ export * as ConfigWatcher from "./watcher"
import { Schema } from "effect"
export class Info extends Schema.Class<Info>("Config.Watcher")({
export class Info extends Schema.Class<Info>("ConfigV2.Watcher")({
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
}) {}
+12 -12
View File
@@ -5,8 +5,8 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Global } from "@opencode-ai/util/global"
import { Project } from "../project"
import { Session } from "../session"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
@@ -28,8 +28,8 @@ export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: Project.ID,
actual: Project.ID,
expected: ProjectV2.ID,
actual: ProjectV2.ID,
},
) {}
@@ -64,12 +64,12 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
) {}
export type Error =
| Session.NotFoundError
| SessionV2.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Session.DestinationNotFoundError
| Session.DestinationNotDirectoryError
| SessionV2.DestinationNotFoundError
| SessionV2.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
@@ -86,14 +86,14 @@ const layer = Layer.effect(
const git = yield* Git.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* Project.Service
const project = yield* ProjectV2.Service
const sessions = yield* SessionStore.Service
const session = yield* Session.Service
const session = yield* SessionV2.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
const value = input.destination.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
@@ -173,8 +173,8 @@ export const node = makeGlobalNode({
FSUtil.node,
Git.node,
Global.node,
Project.node,
Session.node,
ProjectV2.node,
SessionV2.node,
SessionStore.node,
SessionExecution.node,
],
@@ -1,17 +1,17 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/sql"
import { Project } from "../project"
import { Workspace } from "../workspace"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<Workspace.ID>().primaryKey(),
id: text().$type<WorkspaceV2.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<Project.ID>()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
+1 -1
View File
@@ -46,7 +46,7 @@ export interface Interface {
readonly remove: (id: ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Credential") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Credential") {}
const layer = Layer.effect(
Service,
+1 -1
View File
@@ -20,7 +20,7 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/storage/Database") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
const databaseLayer = Layer.effect(
Service,
+4 -4
View File
@@ -2,7 +2,7 @@ export * as EventLogger from "./event-logger"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { EventV2 } from "./event"
const Types = new Set([
"agent.updated",
@@ -13,12 +13,12 @@ const Types = new Set([
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const bus = yield* Bus.Service
const unsubscribe = yield* bus.listen((event) =>
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
Types.has(event.type) ? Effect.logInfo("event", { event }) : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
}),
)
export const node = makeGlobalNode({ name: "event-logger", layer, deps: [Bus.node] })
export const node = makeGlobalNode({ name: "event-logger", layer, deps: [EventV2.node] })
@@ -1,7 +1,8 @@
export * as Bus from "./bus"
export * as EventV2 from "./event"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { Database } from "./database/database"
@@ -11,10 +12,18 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
export type Subscriber<D extends Event.Definition = Event.Definition> = (event: Event.Payload<D>) => Effect.Effect<void>
export const ID = Event.ID
export type ID = import("@opencode-ai/schema/event").ID
export const Seq = Event.Seq
export type Seq = import("@opencode-ai/schema/event").Seq
export const Version = Event.Version
export type Version = import("@opencode-ai/schema/event").Version
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
export const latestSequence = Effect.fn("Bus.latestSequence")(function* (
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
) {
@@ -27,7 +36,7 @@ export const latestSequence = Effect.fn("Bus.latestSequence")(function* (
return row?.seq ?? -1
})
export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
db: Database.Interface["db"],
aggregateID: string,
seq: number,
@@ -44,7 +53,7 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
})
export type SerializedEvent = {
readonly id: Event.ID
readonly id: ID
readonly type: string
readonly created?: DateTime.Utc
readonly seq: number
@@ -53,7 +62,7 @@ export type SerializedEvent = {
}
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
"Bus.InvalidDurableEvent",
"EventV2.InvalidDurableEvent",
{
type: Schema.String,
message: Schema.String,
@@ -62,11 +71,11 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
const envelope = (aggregateID: string, seq: number, version: number) => ({
aggregateID,
seq: Event.Seq.make(seq),
version: Event.Version.make(version),
seq: Seq.make(seq),
version: Version.make(version),
})
const decodeSerializedEvent = (event: SerializedEvent): Event.Payload => {
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
@@ -85,7 +94,7 @@ export const durable = Event.durable
export const ephemeral = Event.ephemeral
export interface PublishOptions {
readonly id?: Event.ID
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
@@ -93,13 +102,13 @@ export interface PublishOptions {
}
/** Marker/event union emitted by `log`. */
export type LogItem = Event.Payload | EventLog.Synced
export type LogItem = Payload | EventLog.Synced
export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced"
export type SubscribePayload<D extends readonly Event.Definition[]> = D[number] extends infer Item
? Item extends Event.Definition
? Event.Payload<Item>
export type SubscribePayload<D extends readonly Definition[]> = D[number] extends infer Item
? Item extends Definition
? Payload<Item>
: never
: never
@@ -108,19 +117,19 @@ export interface Subscribe {
* Volatile live channel: every event published from now on, nothing before or
* across a disconnect. Consumers that need reliability combine it with `log`.
*/
(): Stream.Stream<Event.Payload>
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
<const D extends readonly [Event.Definition, ...Event.Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
(): Stream.Stream<Payload>
<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
<const D extends readonly [Definition, ...Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
}
const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition => !Array.isArray(input)
const isDefinition = (input: Definition | readonly Definition[]): input is Definition => !Array.isArray(input)
export interface Interface {
readonly publish: <D extends Event.Definition>(
readonly publish: <D extends Definition>(
definition: D,
data: Event.Data<D>,
data: Data<D>,
options?: PublishOptions,
) => Effect.Effect<Event.Payload<D>>
) => Effect.Effect<Payload<D>>
readonly subscribe: Subscribe
/**
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
@@ -135,10 +144,10 @@ export interface Interface {
readonly follow?: boolean
}) => Stream.Stream<LogItem>
/** Latest committed seq per aggregate. Aggregates without events are absent. */
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
/** @deprecated Use `subscribe()` and consume the returned stream. */
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
readonly replay: (
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -151,8 +160,7 @@ export interface Interface {
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
@@ -165,20 +173,20 @@ export const layerWith = (options?: LayerOptions) =>
Service,
Effect.gen(function* () {
const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(),
live: yield* PubSub.unbounded<Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
typed: new Map<string, PubSub.PubSub<Event.Payload>>(),
typed: new Map<string, PubSub.PubSub<Payload>>(),
}
const projectors = new Map<string, Subscriber[]>()
const listeners = new Array<Subscriber>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const getOrCreate = (definition: Event.Definition) =>
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
const existing = pubsub.typed.get(definition.type)
if (existing) return existing
const created = yield* PubSub.unbounded<Event.Payload>()
const created = yield* PubSub.unbounded<Payload>()
pubsub.typed.set(definition.type, created)
return created
})
@@ -196,8 +204,8 @@ export const layerWith = (options?: LayerOptions) =>
)
function commitDurableEvent(
definition: Event.Definition,
event: Event.Payload,
definition: Definition,
event: Payload,
input?: {
readonly seq: number
readonly aggregateID: string
@@ -310,7 +318,7 @@ export const layerWith = (options?: LayerOptions) =>
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
} as Payload
for (const projector of list) {
yield* projector(committed)
}
@@ -361,7 +369,7 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publishEvent<D extends Event.Definition>(definition: D, event: Event.Payload<D>, commit?: PublishOptions["commit"]) {
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
return Effect.gen(function* () {
if (!definition?.durable && commit)
return yield* Effect.die(
@@ -371,22 +379,22 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
if (definition?.durable) {
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
if (committed) {
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
yield* notify(event as Payload, true)
return event
}
}
yield* notify(event as Event.Payload, false)
yield* notify(event as Payload, false)
return event
})
}
const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
Effect.suspend(() => observer(event)).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
@@ -394,7 +402,7 @@ export const layerWith = (options?: LayerOptions) =>
),
)
function notify(event: Event.Payload, isolateListeners: boolean) {
function notify(event: Payload, isolateListeners: boolean) {
return Effect.gen(function* () {
yield* Effect.forEach(
listeners,
@@ -407,7 +415,7 @@ export const layerWith = (options?: LayerOptions) =>
})
}
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location =
@@ -418,13 +426,13 @@ export const layerWith = (options?: LayerOptions) =>
return yield* publishEvent(
definition,
{
id: options?.id ?? Event.ID.create(),
id: options?.id ?? ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
data,
} as Event.Payload<D>,
} as Payload<D>,
options?.commit,
)
})
@@ -446,7 +454,7 @@ export const layerWith = (options?: LayerOptions) =>
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
} as Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
@@ -508,7 +516,7 @@ export const layerWith = (options?: LayerOptions) =>
.pipe(Effect.orDie)
}
const local = <A extends Event.Payload>(stream: Stream.Stream<A>) =>
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
Stream.unwrap(
Effect.serviceOption(Location.Service).pipe(
Effect.map((location) =>
@@ -528,12 +536,12 @@ export const layerWith = (options?: LayerOptions) =>
),
)
function subscribe(): Stream.Stream<Event.Payload>
function subscribe<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
function subscribe<const D extends readonly [Event.Definition, ...Event.Definition[]]>(
function subscribe(): Stream.Stream<Payload>
function subscribe<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
function subscribe<const D extends readonly [Definition, ...Definition[]]>(
definitions: D,
): Stream.Stream<SubscribePayload<D>>
function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream<Event.Payload> {
function subscribe(input?: Definition | readonly Definition[]): Stream.Stream<Payload> {
if (input === undefined) return streamLive()
if (isDefinition(input)) {
return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))))
@@ -542,7 +550,7 @@ export const layerWith = (options?: LayerOptions) =>
return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
}
const streamLive = (): Stream.Stream<Event.Payload> => local(Stream.fromPubSub(pubsub.live))
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
const readAfter = (
aggregateID: string,
@@ -616,7 +624,7 @@ export const layerWith = (options?: LayerOptions) =>
Stream.unwrap(
Effect.gen(function* () {
let sequence = input.after ?? -1
const readThrough = (through: number): Stream.Stream<Event.Payload> =>
const readThrough = (through: number): Stream.Stream<Payload> =>
Stream.paginate(sequence, (cursor) =>
readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe(
Effect.tap((page) =>
@@ -640,7 +648,7 @@ export const layerWith = (options?: LayerOptions) =>
const marker: EventLog.Synced = {
type: "log.synced",
aggregateID: input.aggregateID,
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
...(target >= 0 ? { seq: Seq.make(target) } : {}),
}
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
Stream.map((event): LogItem => event),
@@ -657,7 +665,7 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
return db
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
@@ -666,7 +674,7 @@ export const layerWith = (options?: LayerOptions) =>
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Seq.make(row.seq)]))),
)
}
@@ -679,11 +687,11 @@ export const layerWith = (options?: LayerOptions) =>
})
})
const project = <D extends Event.Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
Effect.sync(() => {
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
const list = projectors.get(key) ?? []
list.push((event) => projector(event as Event.Payload<D>))
list.push((event) => projector(event as Payload<D>))
projectors.set(key, list)
})
+2 -2
View File
@@ -1,5 +1,5 @@
import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Event } from "@opencode-ai/schema/event"
import type { EventV2 } from "../event"
export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(),
@@ -10,7 +10,7 @@ export const EventSequenceTable = sqliteTable("event_sequence", {
export const EventTable = sqliteTable(
"event",
{
id: text().$type<Event.ID>().primaryKey(),
id: text().$type<EventV2.ID>().primaryKey(),
aggregate_id: text()
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
+6 -6
View File
@@ -64,7 +64,7 @@ export interface Interface {
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileMutation") {}
/**
* Serialize file changes by canonical target. Conditional writes compare and
@@ -194,12 +194,12 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/**
* Deferred until the corresponding integrations exist.
* Deferred until the corresponding V2 integrations exist.
*/
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
// TODO: Add formatter integration after V2 formatter runtime exists.
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
// TODO: Add snapshots / undo after V2 snapshot design exists.
// TODO: Notify LSP and collect diagnostics after V2 LSP runtime exists.
// TODO: Design multi-file transactions / rollback if patch needs atomic edits.
// Until then, edits are sequential and report partial application.
// TODO: Define crash recovery and idempotency for side effects between Tool.Called and durable settlement.
+6 -7
View File
@@ -32,19 +32,18 @@ export type ListInput = typeof ListInput.Type
export { FindInput }
export const DEFAULT_SEARCH_LIMIT = 100
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
path: Schema.optionalKey(RelativePath),
limit: Schema.optionalKey(PositiveInt),
path: RelativePath.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
pattern: Schema.String,
path: Schema.optionalKey(RelativePath),
include: Schema.optionalKey(Schema.String),
limit: Schema.optionalKey(PositiveInt),
path: RelativePath.pipe(Schema.optional),
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export const Event = FileSystem.Event
@@ -57,7 +56,7 @@ export interface Interface {
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
const baseLayer = Layer.effect(
Service,
@@ -6,7 +6,7 @@ import { FileSystem } from "@opencode-ai/schema/filesystem"
import os from "os"
import path from "path"
import { Config } from "../config"
import { Bus } from "../bus"
import { EventV2 } from "../event"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Location } from "../location"
@@ -30,12 +30,12 @@ const layer = Layer.effect(
Effect.gen(function* () {
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const configService = yield* Config.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
events.publish(FileSystem.Event.Changed, {
file: update.path,
event: update.type === "create" ? "add" : update.type === "update" ? "change" : "unlink",
})
@@ -86,5 +86,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, Bus.node],
deps: [Watcher.node, FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
+1 -1
View File
@@ -22,7 +22,7 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem/Search") {}
export const ripgrepLayer = Layer.effect(
Service,
+90 -169
View File
@@ -5,7 +5,8 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "effect"
import { Cause, Context, Effect, Layer, PubSub, Schema, Scope, Stream } from "effect"
import { KeyedMutex } from "../effect/keyed-mutex"
import { lazy } from "../util/lazy"
import { watch as watchFileSystem } from "node:fs"
import path from "path"
@@ -44,29 +45,6 @@ export type WatchInput =
| { readonly path: string; readonly type: "file" }
| { readonly path: string; readonly type: "directory"; readonly ignore?: readonly string[] }
export type Subscription = {
readonly unsubscribe: () => Promise<void>
/** Backend name for logging, e.g. "node" or "fs-events". */
readonly backend?: string
}
export interface NativeInterface {
/** Starts one OS-level watch, reporting events through `publish` until unsubscribed. */
readonly subscribe: (input: {
readonly type: WatchInput["type"]
readonly target: string
readonly ignore: readonly string[]
readonly publish: (update: Update) => void
}) => Effect.Effect<Subscription | undefined>
}
/**
* The OS-level watch implementation behind the Watcher service. The default
* layer uses `node:fs.watch` for files and `@parcel/watcher` for directories;
* tests provide implementations they can control.
*/
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
export interface Interface {
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
}
@@ -78,144 +56,96 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
export interface TestInterface extends Interface {
/** Broadcasts one update to every subscriber. */
readonly emit: (update: Update) => Effect.Effect<void>
/** Returns every subscribe call observed so far, in order. */
readonly subscriptions: () => Effect.Effect<readonly WatchInput[]>
}
export const layer = (options?: Options) => Layer.effect(
Service,
Effect.gen(function* () {
const backend = getBackend()
const native = watcher()
if (options?.enabled === false) {
return Service.of({ subscribe: () => Stream.empty })
}
export class Test extends Context.Service<Test, TestInterface>()("@opencode/Watcher/Test") {}
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>()
export const layer = (options?: Options) =>
Layer.effect(
Service,
Effect.gen(function* () {
if (options?.enabled === false) {
return Service.of({ subscribe: () => Stream.empty })
}
const native = yield* Native
// Keys compare structurally (effect Equal), so equivalent watches share one entry.
type Key = { readonly type: WatchInput["type"]; readonly target: string; readonly ignore: readonly string[] }
const watchers = yield* RcMap.make({
lookup: (key: Key) =>
Effect.gen(function* () {
const pubsub = yield* Effect.acquireRelease(PubSub.unbounded<Update>(), (pubsub) =>
PubSub.shutdown(pubsub),
)
const subscription = yield* Effect.acquireRelease(
native.subscribe({
type: key.type,
target: key.target,
ignore: key.ignore,
publish: (update) => PubSub.publishUnsafe(pubsub, update),
}),
(subscription) =>
subscription
? Effect.promise(() => subscription.unsubscribe()).pipe(
Effect.ignoreCause,
Effect.andThen(Effect.logInfo("watcher stopped", { path: key.target, type: key.type })),
)
: Effect.void,
// Native subscription may stay pending up to SUBSCRIBE_TIMEOUT_MS;
// scope shutdown must not wait behind an uninterruptible acquisition.
{ interruptible: true },
)
if (!subscription) {
// Unsupported backend: end subscriber streams instead of hanging them.
yield* PubSub.shutdown(pubsub)
return pubsub
}
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: key.target,
type: key.type,
backend: subscription.backend,
ignores: key.ignore.length,
path: target,
type: input.type,
backend: input.type === "file" ? "node" : backend,
ignores: ignore.length,
})
return pubsub
}),
})
const subscribe = (input: WatchInput) => {
const target = path.resolve(input.path)
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
return Stream.unwrap(
RcMap.get(watchers, { type: input.type, target, ignore }).pipe(
Effect.map((pubsub) => Stream.fromPubSub(pubsub)),
),
)
}
return Service.of({ subscribe })
}),
)
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and broadcasts emitted updates to every active watch.
*/
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
const active = new Set<(update: Update) => void>()
const native = Native.of({
subscribe: (input) =>
Effect.sync(() => {
subscriptions.push(
input.type === "file"
? { path: input.target, type: "file" }
: input.ignore.length > 0
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
active.add(input.publish)
return {
unsubscribe: () => {
active.delete(input.publish)
return Promise.resolve()
},
}
yield* PubSub.shutdown(pubsub)
return pubsub
}),
)
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 context = yield* Layer.build(layer().pipe(Layer.provide(Layer.succeed(Native, native))))
const test = Test.of({
subscribe: Context.get(context, Service).subscribe,
emit: (update) => Effect.sync(() => active.forEach((publish) => publish(update))),
subscriptions: () => Effect.sync(() => [...subscriptions]),
})
return Context.empty().pipe(Context.add(Service, test), Context.add(Test, test))
const subscribe = (input: WatchInput) =>
Stream.unwrap(acquire(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub))))
return Service.of({ subscribe })
}),
)
export const nativeLayer = Layer.succeed(
Native,
Native.of({
subscribe: (input) => {
if (input.type === "file") {
return Effect.sync(() => {
const directory = path.dirname(input.target)
const subscription = watchFileSystem(directory, { recursive: false }, (_event, file) => {
if (file && path.resolve(directory, file.toString()) !== input.target) return
input.publish({ path: input.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: input.target, error })),
)
}
return { unsubscribe: () => Promise.resolve(subscription.close()), backend: "node" }
})
}
return subscribeDirectory(watcher(), getBackend(), input.target, input.ignore, input.publish)
},
}),
)
export const nativeNode = makeGlobalNode({ service: Native, layer: nativeLayer, deps: [] })
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [nativeNode] })
return makeGlobalNode({ service: Service, layer: layer(options), deps: [] })
}
export const node = configured()
@@ -224,9 +154,9 @@ function subscribeDirectory(
native: typeof import("@parcel/watcher") | undefined,
backend: ParcelWatcher.BackendType | undefined,
directory: string,
ignore: readonly string[],
publish: (update: Update) => void,
): Effect.Effect<Subscription | undefined> {
ignore: string[],
pubsub: PubSub.PubSub<Update>,
) {
if (!native || !backend) {
return Effect.logError("watcher backend not supported", { directory, platform: process.platform }).pipe(
Effect.as(undefined),
@@ -234,26 +164,17 @@ function subscribeDirectory(
}
const callback: ParcelWatcher.SubscribeCallback = (error, updates) => {
if (error) Effect.runFork(Effect.logError("watcher callback failed", { error }))
for (const update of updates) publish(update)
for (const update of updates) PubSub.publishUnsafe(pubsub, update)
}
// Copy `ignore`: it aliases the RcMap key, whose structural hash is cached,
// so the array handed to native code must never be the mutable original.
const pending = native.subscribe(directory, callback, { ignore: [...ignore], backend })
const pending = native.subscribe(directory, callback, { ignore, backend })
return Effect.promise(() => pending).pipe(
Effect.map((subscription) => ({ unsubscribe: () => subscription.unsubscribe(), backend })),
// Interruption (including the timeout below) abandons the pending native
// subscription, so close it once it eventually resolves.
Effect.onInterrupt(() =>
Effect.sync(() => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
}),
),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) =>
Effect.logError("failed to subscribe", {
Effect.catchCause((cause) => {
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", {
directory,
cause: Cause.pretty(cause),
}).pipe(Effect.as(undefined)),
),
}).pipe(Effect.as(undefined))
}),
)
}
+7 -7
View File
@@ -3,7 +3,7 @@ export * as Form from "./form"
import { Form } from "@opencode-ai/schema/form"
import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus"
import { EventV2 } from "./event"
const RETENTION = Duration.minutes(10)
@@ -32,7 +32,7 @@ export type Answer = typeof Answer.Type
export const Reply = Form.Reply
export type Reply = typeof Reply.Type
export { Event } from "@opencode-ai/schema/form"
export const Event = Form.Event
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Form.NotFoundError", {
id: ID,
@@ -99,7 +99,7 @@ interface Entry {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const forms = yield* Cache.makeWith<ID, Entry>(
() => Effect.die(new Error("Form cache must be used via set/getSuccess, never get")),
{
@@ -141,7 +141,7 @@ export const layer = Layer.effect(
deferred: yield* Deferred.make<TerminalState>(),
}
yield* Cache.set(forms, id, entry)
yield* bus.publish(Form.Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
return form
}),
),
@@ -183,7 +183,7 @@ export const layer = Layer.effect(
const invalid = validateAnswer(entry.form, input.answer)
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
const next: TerminalState = { status: "answered", answer: input.answer }
yield* bus.publish(Form.Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
yield* Cache.set(forms, input.id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
}),
@@ -196,7 +196,7 @@ export const layer = Layer.effect(
const entry = yield* find(id)
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
const next: TerminalState = { status: "cancelled" }
yield* bus.publish(Form.Event.Cancelled, { id, sessionID: entry.form.sessionID })
yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
yield* Cache.set(forms, id, { ...entry, state: next })
yield* Deferred.succeed(entry.deferred, next)
}),
@@ -221,7 +221,7 @@ export const layer = Layer.effect(
export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
function validateAnswer(form: Info, answer: Answer) {
const fields = new Map(form.fields.map((field) => [field.key, field] as const))
+3 -3
View File
@@ -5,11 +5,11 @@ import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform"
import { ModelResolver } from "./model-resolver"
import { Model } from "./model"
import { ModelV2 } from "./model"
export interface TextInput {
readonly prompt: string
readonly model?: Model.Ref
readonly model?: ModelV2.Ref
}
export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionError>()(
@@ -28,7 +28,7 @@ export interface Interface {
readonly text: (input: TextInput) => Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Generate") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Generate") {}
export const layer = Layer.effect(
Service,
+1 -1
View File
@@ -189,7 +189,7 @@ export interface Interface {
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
const layer = Layer.effect(
Service,
+17 -17
View File
@@ -2,8 +2,8 @@ export * as CopilotModels from "./models"
import { Money } from "@opencode-ai/schema/money"
import { Option, Schema } from "effect"
import { Model } from "../model"
import { Provider } from "../provider"
import { ModelV2 } from "../model"
import { ProviderV2 } from "../provider"
const RemoteModel = Schema.Struct({
model_picker_enabled: Schema.Boolean,
@@ -70,7 +70,7 @@ type UsableModel = RemoteModel & {
}
}
export async function get(baseURL: string, headers: RequestInit["headers"], existing: readonly Model.Info[]) {
export async function get(baseURL: string, headers: RequestInit["headers"], existing: readonly ModelV2.Info[]) {
const response = await fetch(`${baseURL}/models`, {
headers,
signal: AbortSignal.timeout(5_000),
@@ -97,7 +97,7 @@ export async function get(baseURL: string, headers: RequestInit["headers"], exis
}
for (const [id, model] of remote) {
const key = Model.ID.make(id)
const key = ModelV2.ID.make(id)
if (result.has(key)) continue
result.set(key, build(key, model, baseURL))
}
@@ -114,7 +114,7 @@ function usable(model: RemoteModel): model is UsableModel {
)
}
function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Model.Info) {
function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: ModelV2.Info) {
const messages = remote.supported_endpoints?.includes("/v1/messages") ?? false
const endpoint = messages
? "messages"
@@ -134,15 +134,15 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
: remote.version
const released = previous?.time.released || Date.parse(version)
return Model.Info.make({
...Model.Info.default(Provider.ID.githubCopilot, id),
return ModelV2.Info.make({
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, id),
id,
modelID: Model.ID.make(remote.id),
providerID: Provider.ID.githubCopilot,
family: previous?.family ?? Model.Family.make(remote.capabilities.family),
modelID: ModelV2.ID.make(remote.id),
providerID: ProviderV2.ID.githubCopilot,
family: previous?.family ?? ModelV2.Family.make(remote.capabilities.family),
name: previous?.name ?? remote.name,
package: Provider.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"),
settings: Provider.mergeOverlay(previous?.settings, {
package: ProviderV2.aisdk(messages ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"),
settings: ProviderV2.mergeOverlay(previous?.settings, {
baseURL: messages ? `${baseURL}/v1` : baseURL,
...(endpoint ? { endpoint } : {}),
}),
@@ -175,11 +175,11 @@ function build(id: Model.ID, remote: UsableModel, baseURL: string, previous?: Mo
})
}
function variants(remote: UsableModel, messages: boolean): Model.Info["variants"] {
function variants(remote: UsableModel, messages: boolean): ModelV2.Info["variants"] {
const efforts = remote.capabilities.supports.reasoning_effort ?? []
if (!messages && efforts.length) {
return efforts.map((effort) => ({
id: Model.VariantID.make(effort),
id: ModelV2.VariantID.make(effort),
settings: {
reasoningEffort: effort,
reasoningSummary: "auto",
@@ -189,7 +189,7 @@ function variants(remote: UsableModel, messages: boolean): Model.Info["variants"
}
if (efforts.length && remote.capabilities.supports.adaptive_thinking) {
return efforts.map((effort) => ({
id: Model.VariantID.make(effort),
id: ModelV2.VariantID.make(effort),
settings: {
thinking: {
type: "adaptive",
@@ -203,11 +203,11 @@ function variants(remote: UsableModel, messages: boolean): Model.Info["variants"
if (max === undefined) return []
return [
{
id: Model.VariantID.make("max"),
id: ModelV2.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: max - 1 } },
},
{
id: Model.VariantID.make("high"),
id: ModelV2.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: Math.floor(max / 2) } },
},
]
+1 -1
View File
@@ -26,7 +26,7 @@ export const Options = Schema.Struct({
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionDiscovery") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionDiscovery") {}
export const layer = (options?: Options) => Layer.effect(
Service,
+1 -1
View File
@@ -10,7 +10,7 @@ export interface Interface {
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.Instructions>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/InstructionBuiltIns") {}
const layer = Layer.effect(
Service,
+16 -16
View File
@@ -20,7 +20,7 @@ import {
import { Integration } from "@opencode-ai/schema/integration"
import { Credential } from "./credential"
import { State } from "./state"
import { Bus } from "./bus"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
@@ -129,7 +129,7 @@ export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationErr
export type Error = CodeRequiredError | AuthorizationError
export { Event } from "@opencode-ai/schema/integration"
export const Event = Integration.Event
export const Ref = Integration.Ref
export type Ref = Integration.Ref
@@ -222,7 +222,7 @@ export interface Interface extends State.Transformable<Draft> {
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Integration") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Integration") {}
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
@@ -271,7 +271,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const events = yield* EventV2.Service
const processes = yield* AppProcess.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
@@ -338,7 +338,7 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
@@ -433,8 +433,8 @@ const layer = Layer.effect(
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* events.publish(Event.Updated, {})
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
@@ -489,8 +489,8 @@ const layer = Layer.effect(
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
if (Exit.isFailure(persistence)) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
yield* events.publish(Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* events.publish(Event.Updated, {})
}),
)
})
@@ -706,24 +706,24 @@ const layer = Layer.effect(
label: input.label,
value: Credential.Key.make({ type: "key", key: input.key }),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
yield* events.publish(Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* events.publish(Event.Updated, {})
}),
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
const credential = yield* credentials.get(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
yield* events.publish(Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
yield* events.publish(Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
yield* events.publish(Event.Updated, {})
}),
},
oauth: {
@@ -809,5 +809,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Credential.node, Bus.node, AppProcess.node],
deps: [Credential.node, EventV2.node, AppProcess.node],
})
+1 -1
View File
@@ -14,7 +14,7 @@ export interface Interface {
readonly remove: (key: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/KV") {}
const layer = Layer.effect(
Service,

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