Compare commits

..
Author SHA1 Message Date
LukeParkerDev 713802f907 fix(sdk): resolve packaged worker paths on Windows 2026-09-03 08:31:27 +10:00
LukeParkerDev c0bbec60fd fix(plugin-browser): keep release helpers out of the package 2026-09-03 08:29:03 +10:00
LukeParkerDev c95327bb97 refactor(browser): extract the plugin into its own package 2026-09-03 08:29:02 +10:00
LukeParkerDev 38ced14b61 fix(browser): let a newer attachment replace a stale one 2026-09-03 08:29:00 +10:00
LukeParkerDev e5d410f4cb refactor(browser): use canonical public schemas 2026-09-03 08:28:59 +10:00
LukeParkerDev e97f5ed2ed refactor(browser): defer permission enforcement 2026-09-03 08:28:58 +10:00
LukeParkerDev b886045b40 refactor(browser): colocate the public API plugin 2026-09-03 08:28:56 +10:00
LukeParkerDev 2382da0c2b feat(browser): add public RPC browser plugin 2026-09-03 08:28:55 +10:00
Dax Raad 68a40e2d21 chore: remove package reservation script 2026-09-02 18:15:53 -04:00
Dax 57c02cd04b fix(tui): refresh plugin dialog after updates (#46911) 2026-09-02 17:59:18 -04:00
Dax Raad 46515df4a3 refactor(updates): use useragent event field 2026-09-02 17:51:55 -04:00
Dax Raad 51250e4346 feat(updates): include client IP in request events 2026-09-02 17:51:33 -04:00
Aiden Cline dbd47702b2 fix(core): tell plan agent to discuss plans instead of writing files by default (#46905) 2026-09-02 16:49:04 -05:00
Dax Raad a085bf62a4 feat(updates): log requests to the shared data lake 2026-09-02 17:25:02 -04:00
Luke Parker b605f355ca fix(core): share the models.dev snapshot across Locations (#46784) 2026-09-03 07:22:07 +10:00
opencode-agent[bot] 85e2b0a23a chore: update nix node_modules hashes 2026-09-02 21:21:54 +00:00
Kit Langton 3dc187bf3f refactor(client): share location resource reads (#46831) 2026-09-02 17:05:09 -04:00
Dax ed3259a9b7 refactor(plugin): centralize host resolution (#46901) 2026-09-02 17:04:15 -04:00
Kit Langton db09cc842e fix(util): stage npm installs under the cache directory's real path (#46887) 2026-09-02 16:40:45 -04:00
Aiden Cline 44e0b35303 fix(core): narrow compaction additional context guidance (#46889) 2026-09-02 15:12:27 -05:00
Kit Langton e0c0d5691d fix(core): wait for plugin activation before session entry points (#46878) 2026-09-02 15:58:03 -04:00
Kit Langton 2ae235078a feat(tui): check for plugin updates from the plugins dialog (#46884) 2026-09-02 19:40:40 +00:00
fc051e49ee feat(core): add pinned session move tool (#46862)
Co-authored-by: thdxr <826656+thdxr@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-02 14:26:45 -05:00
Dax Raad af332a2363 chore(cli): enable bytecode compilation 2026-09-02 15:13:29 -04:00
Kit Langton 4772b6a3e8 refactor: rename State drafts to editors (#46876) 2026-09-02 18:39:02 +00:00
Kit Langton 318a82f784 feat(sdk): resolve instance configuration from provided services (#46875) 2026-09-02 14:24:12 -04:00
Kit Langton 4b6e879ba8 fix(core): subscribe before debouncing config plugin updates (#46874) 2026-09-02 14:17:50 -04:00
opencode-agent[bot]andJames Long d37122350b fix(tui): make prompt metadata responsive (#46801)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-02 14:08:50 -04:00
Kit Langton b45882ec87 fix(core): notify reloads immediately and debounce at bursty sources (#46843) 2026-09-02 13:53:55 -04:00
Kit Langton bbc44310dc fix(core): scope the migration lock to each database (#46866) 2026-09-02 13:49:18 -04:00
Aiden Cline a7b8174917 fix(core): replace GPT autonomy section with scope guidance (#46864) 2026-09-02 12:24:17 -05:00
Kit Langton 7aabfd3554 test(core): route test watcher updates to matching watches (#46840) 2026-09-02 13:16:38 -04:00
Kit Langton d4fe3758c4 refactor(core): pass the rebuilt value to State notify (#46837) 2026-09-02 13:04:53 -04:00
Kit Langton 473c292521 fix(core): discover project config once under symlinked paths (#46841) 2026-09-02 13:04:43 -04:00
Dax 36095decd7 fix(core): preserve unchanged plugin prefix (#46857) 2026-09-02 12:50:14 -04:00
253 changed files with 4265 additions and 2674 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
if: github.ref_name == 'v2'
working-directory: packages/updates
run: bun run deploy
env:
+21 -4
View File
@@ -358,6 +358,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -575,6 +576,7 @@
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
"zod": "catalog:",
@@ -585,6 +587,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -603,6 +606,21 @@
"solid-js",
],
},
"packages/plugin-browser": {
"name": "@opencode-ai/plugin-browser",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/posts": {
"name": "@opencode-ai/posts",
"dependencies": {
@@ -672,6 +690,7 @@
"devDependencies": {
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/httpapi-codegen": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -971,7 +990,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1123,7 +1141,6 @@
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.1",
"resolve.exports": "2.0.3",
"semver": "7.7.4",
"shiki": "4.4.3",
"solid-js": "1.9.15",
@@ -2143,6 +2160,8 @@
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/plugin-browser": ["@opencode-ai/plugin-browser@workspace:packages/plugin-browser"],
"@opencode-ai/posts": ["@opencode-ai/posts@workspace:packages/posts"],
"@opencode-ai/protocol": ["@opencode-ai/protocol@workspace:packages/protocol"],
@@ -5171,8 +5190,6 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
}
}
-2
View File
@@ -27,7 +27,6 @@
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky",
"reserve-packages": "bun script/reserve-package-names.ts",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
@@ -93,7 +92,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"resolve.exports": "2.0.3",
"sst": "4.13.1",
"shiki": "4.4.3",
"solid-list": "0.3.0",
+1
View File
@@ -128,6 +128,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
bytecode: true,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
@@ -89,12 +89,7 @@ export default Runtime.handler(Commands, (input) =>
}
: undefined,
packages: {
resolve: (spec, install = true) =>
runPromise(
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
Effect.map((result) => result.entrypoint),
),
),
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
},
environment: requestedServer === undefined ? Env.session() : undefined,
terminalHandoff: () => preflight.finish(),
@@ -5,6 +5,7 @@ import { Effect } from "effect"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { resolveConfigPath } from "../mcp/add"
@@ -16,9 +17,9 @@ export default Runtime.handler(
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
const installed = yield* npm.add(input.package)
const entrypoints = Host.resolve(installed)
const target = configurationTarget(entrypoints.server, entrypoints.tui)
if (!target)
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const installed = yield* npm.resolve(target)
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
@@ -9,8 +9,9 @@ import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { Host } from "@opencode-ai/plugin/host"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,27 +23,27 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
Effect.gen(function* () {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
return []
const local = localSource(target, path.dirname(config.path))
if (local) return [{ target: fileURLToPath(local), version: "local" }]
if (local) {
const directory = fileURLToPath(local)
const entrypoints = Host.resolve({ directory })
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
}
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
const installed = yield* npm.resolve(target)
if (!Host.resolve(installed).tui) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
const output = format(response.data, configured.flat(), input.builtin)
if (!output) {
process.stdout.write("No plugins found" + EOL)
return
@@ -81,9 +82,7 @@ export function format(
? plugin.source.target === item.target
: plugin.source.type === "local" &&
(plugin.source.path === item.target ||
(plugin.features.tui &&
path.dirname(plugin.source.path) ===
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
@@ -26,7 +26,7 @@ export default Runtime.handler(
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
: npm.update(item.target).pipe(Effect.asVoid)
).pipe(
Effect.exit,
Effect.map((result) => ({ item, result })),
-1
View File
@@ -967,7 +967,6 @@ export type SessionLogOutput =
readonly reason: "auto" | "manual"
readonly text: string
readonly recent: string
readonly retained?: { readonly from: SessionMessage.ID; readonly through: SessionMessage.ID } | undefined
}
}
| {
+1 -21
View File
@@ -147,7 +147,6 @@ export type SessionMessageCompactionCompleted = {
reason: "auto" | "manual"
summary: string
recent: string
retained?: { from: string; through: string }
}
export type SessionActive = { type: "running" }
@@ -817,13 +816,7 @@ export type SessionCompactionEnded = {
type: "session.compaction.ended"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: {
sessionID: string
reason: "auto" | "manual"
text: string
recent: string
retained?: { from: string; through: string }
}
data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string }
}
export type SessionCompactionFailed = {
@@ -992,15 +985,6 @@ export type ReferenceUpdated = {
data: {}
}
export type PluginAdded = {
id: string
created: number
metadata?: { [x: string]: any }
type: "plugin.added"
location?: LocationRef
data: { id: string }
}
export type PluginUpdated = {
id: string
created: number
@@ -2306,7 +2290,6 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
@@ -3082,7 +3065,6 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
@@ -3360,7 +3342,6 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
@@ -3638,7 +3619,6 @@ export type SessionImportInput = {
readonly reason: "auto" | "manual"
readonly summary: string
readonly recent: string
readonly retained?: { readonly from: string; readonly through: string }
}
| {
readonly type: "compaction"
+55 -204
View File
@@ -83,10 +83,8 @@ type LocationData = {
agent?: AgentInfo[]
command?: CommandInfo[]
integration?: IntegrationInfo[]
mcp?: {
server?: McpServer[]
resource?: McpResource[]
}
mcpServer?: McpServer[]
mcpResource?: McpResource[]
model?: ModelInfo[]
provider?: ProviderInfo[]
reference?: ReferenceInfo[]
@@ -125,8 +123,8 @@ export function locationKey(location: LocationRef) {
return JSON.stringify([location.directory, location.workspaceID])
}
function locationQuery(ref?: LocationRef) {
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
function locationQuery(ref: LocationRef) {
return { directory: ref.directory, workspace: ref.workspaceID }
}
function formRequestOptions(sessionID: string, ref?: LocationRef) {
@@ -1103,7 +1101,6 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
})
return
}
@@ -1114,7 +1111,6 @@ export function createData(config: CreateDataInput) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
time: { created: event.created },
})
})
@@ -1275,6 +1271,41 @@ export function createData(config: CreateDataInput) {
}
}
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
// effective location, and publishes under the server's canonical location; `alias`
// also publishes under the requested key when the two differ.
function locationResource<Field extends keyof LocationData>(
field: Field,
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
options?: { alias?: boolean },
) {
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
return {
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
sync: (ref?: LocationRef) => {
const location = ref ?? defaultLocation()
const id = locationKey(location)
return sync.run(`location.${field}:${id}`, async () => {
const response = await load(locationQuery(location))
const key = locationKey(response.location)
publish(key, response.data)
if (options?.alias && key !== id) publish(id, response.data)
})
},
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
}
}
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
const shells = locationResource("shell", async (location) => {
const response = await api().shell.list({ location })
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
return {
location: response.location,
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
}
})
const result = {
on: config.event.on,
listen: config.event.listen,
@@ -1742,7 +1773,7 @@ export function createData(config: CreateDataInput) {
},
shell: {
list(location?: LocationRef) {
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
return Object.values(shells.list(location) ?? {})
},
listBySession(sessionID: string) {
return Object.values(store.location)
@@ -1754,31 +1785,8 @@ export function createData(config: CreateDataInput) {
.map((data) => data.shell?.[id])
.find((shell) => shell !== undefined)
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.shell:${id}`, async () => {
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
shell: Object.fromEntries(
response.data.map((info) => [
info.id,
{
...info,
location: {
directory: response.location.directory,
workspaceID: response.location.workspaceID,
},
},
]),
),
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
},
sync: shells.sync,
invalidate: shells.invalidate,
},
location: {
info(ref?: LocationRef) {
@@ -1833,162 +1841,20 @@ export function createData(config: CreateDataInput) {
result.shell.invalidate(location)
result.session.form.invalidate("global", location)
},
vcs: {
info(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.vcs
},
sync(ref?: LocationRef) {
const location = ref ?? defaultLocation()
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
const response = await api().vcs.get({ location: locationQuery(location) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], vcs: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
},
},
agent: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.agent
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.agent:${id}`, async () => {
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], agent: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
},
},
command: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.command
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.command:${id}`, async () => {
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], command: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
},
},
integration: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.integration
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.integration:${id}`, async () => {
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], integration: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
},
},
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
integration: locationResource("integration", (location) => api().integration.list({ location })),
mcp: {
server: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.server:${id}`, async () => {
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, server: response.data },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
},
},
resource: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.mcp.resource:${id}`, async () => {
const response = await api().mcp.resource.catalog({
location: locationQuery(ref ?? defaultLocation()),
})
const key = locationKey(response.location)
setStore("location", key, {
...store.location[key],
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
})
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
},
},
},
model: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.model
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.model:${id}`, async () => {
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], model: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
},
},
provider: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.provider
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.provider:${id}`, async () => {
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], provider: response.data })
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
},
},
reference: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.reference
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.reference:${id}`, async () => {
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], reference: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
},
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
resource: locationResource("mcpResource", async (location) => {
const response = await api().mcp.resource.catalog({ location })
return { location: response.location, data: response.data.resources }
}),
},
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
reference: locationResource("reference", (location) => api().reference.list({ location })),
websearch: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.websearch
@@ -2003,22 +1869,7 @@ export function createData(config: CreateDataInput) {
})
},
},
skill: {
list(location?: LocationRef) {
return store.location[locationKey(location ?? defaultLocation())]?.skill
},
sync(ref?: LocationRef) {
const id = locationKey(ref ?? defaultLocation())
return sync.run(`location.skill:${id}`, async () => {
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
const key = locationKey(response.location)
setStore("location", key, { ...store.location[key], skill: response.data })
})
},
invalidate(ref?: LocationRef) {
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
},
},
skill: locationResource("skill", (location) => api().skill.list({ location })),
},
}
+2 -13
View File
@@ -101,21 +101,10 @@ test.each(["started", "cancelled", "failed"])(
fixture.emit({
...event,
type: "session.compaction.ended",
data: {
sessionID,
reason: "manual",
text: "Summary",
recent: "",
retained: { from: "msg_retained_user", through: "msg_retained_assistant" },
},
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
})
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
{
type: "compaction",
status: "completed",
summary: "Summary",
retained: { from: "msg_retained_user", through: "msg_retained_assistant" },
},
{ type: "compaction", status: "completed", summary: "Summary" },
])
}
},
@@ -20378,44 +20378,6 @@
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.added": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["plugin.added"]
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": ["id"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "data"],
"additionalProperties": false
},
"plugin.updated": {
"type": "object",
"properties": {
@@ -23123,9 +23085,6 @@
{
"$ref": "#/components/schemas/permission.v2.replied"
},
{
"$ref": "#/components/schemas/plugin.added"
},
{
"$ref": "#/components/schemas/plugin.updated"
},
+2
View File
@@ -20,6 +20,7 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun run script/test.ts",
@@ -121,6 +122,7 @@
"@opencode-ai/pty": "0.1.13",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/plugin-browser": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@standard-schema/spec": "catalog:",
"@parcel/watcher": "2.5.1",
@@ -0,0 +1,210 @@
// Measures the heap retained by Location service graphs and by the models.dev
// catalog plugin. Everything runs against a temporary global directory with a
// temporary home, an in-memory database, no filesystem watchers, and no network,
// so it never touches a live server, database, or user configuration.
//
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
//
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
// highest heapUsed sampled without forcing GC and are reported separately.
import fs from "fs/promises"
import os from "os"
import path from "path"
import { heapStats } from "bun:jsc"
import { Effect, Layer, Logger, Scope } from "effect"
import { Global } from "@opencode-ai/util/global"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "../src/effect/app-node-builder"
import { Bus } from "../src/bus"
import { Catalog } from "../src/catalog"
import { Database } from "../src/database/database"
import { Integration } from "../src/integration"
import { Location } from "../src/location"
import { LocationServiceMap } from "../src/location-service-map"
import { ModelsDev } from "../src/models-dev"
import { Plugin } from "../src/plugin"
import { ModelsDevPlugin } from "../src/plugin/models-dev"
import { AbsolutePath } from "../src/schema"
import { Watcher } from "../src/filesystem/watcher"
import { location } from "../test/fixture/location"
import { catalogHost, host, integrationHost } from "../test/plugin/host"
const args = process.argv.slice(2)
const flag = (name: string, fallback: number) => {
const index = args.indexOf(`--${name}`)
if (index === -1) return fallback
const value = Number(args[index + 1])
if (!Number.isInteger(value) || value < 1) {
console.error(`--${name} must be a positive integer`)
process.exit(1)
}
return value
}
const locationCount = flag("locations", 6)
const pluginCount = flag("plugins", 8)
const jsonIndex = args.indexOf("--json")
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
type Sample = { heapUsed: number; rss: number; objects: number }
const sample = (): Sample => {
Bun.gc(true)
Bun.gc(true)
const usage = process.memoryUsage()
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
}
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
const median = (values: ReadonlyArray<number>) => {
const sorted = values.toSorted((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
const upper = sorted[middle] ?? 0
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
}
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
const globalLayer = Global.layerWith({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "cache", "bin"),
log: path.join(root, "data", "log"),
repos: path.join(root, "data", "repos"),
})
const replacements = [
Global.node.replace(globalLayer),
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
Watcher.node.replace(Watcher.configured({ enabled: false })),
]
// One full Location graph per directory, retained for the rest of the run, the
// way a long-running server retains every directory a client has touched.
const locationsProgram = Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
const rss: number[] = []
let previous = before
for (let index = 0; index < locationCount; index++) {
const directory = path.join(root, "projects", `location-${index}`)
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
const context = yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
.pipe(Scope.provide(scope))
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
yield* plugins.awaitActivation
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
rss.push(current.rss)
previous = current
}
const catalog = yield* Catalog.Service.pipe(
Effect.provideContext(
yield* locations
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
.pipe(Scope.provide(scope)),
),
)
const models = yield* catalog.model.all()
const providers = yield* catalog.provider.all()
return {
before,
after: previous,
deltas,
rss,
catalog: { providers: providers.length, models: models.length },
}
}).pipe(Effect.scoped)
// The models.dev plugin alone, against a real Catalog and Integration state per
// instance, isolates the catalog-copy contribution from the rest of the graph.
const pluginProgram = Effect.gen(function* () {
const modelsDev = yield* ModelsDev.Service
const snapshot = yield* modelsDev.get()
const scope = yield* Scope.Scope
const before = sample()
const deltas: number[] = []
let previous = before
for (let index = 0; index < pluginCount; index++) {
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location(Location.Ref.make({ directory }))),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
Location.node.replace(locationLayer),
...replacements,
]),
).pipe(Scope.provide(scope))
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
yield* ModelsDevPlugin.effect(
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
yield* catalog.model.all()
yield* integration.list()
const current = sample()
deltas.push(current.heapUsed - previous.heapUsed)
previous = current
}
return {
before,
after: previous,
deltas,
snapshot: {
providers: snapshot.length,
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
},
}
}).pipe(Effect.scoped)
const program = Effect.gen(function* () {
const plugin = yield* pluginProgram.pipe(
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
)
const locations = yield* locationsProgram.pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
),
)
return { plugin, locations }
}).pipe(Effect.provide(Logger.layer([])))
const result = await Effect.runPromise(program)
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
console.log(
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
)
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
console.log(
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
)
console.log(
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
)
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
console.log(
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
)
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
if (jsonPath) {
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
await fs.writeFile(
jsonPath,
JSON.stringify(
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
null,
2,
),
)
}
+11 -11
View File
@@ -34,7 +34,7 @@ type Data = {
default?: ID
}
export type Draft = {
export type Editor = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
default: (id: ID | undefined) => void
@@ -42,7 +42,7 @@ export type Draft = {
remove: (id: ID) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
readonly select: (id?: ID | string) => Effect.Effect<Selection>
@@ -62,32 +62,32 @@ const layer = Layer.effect(
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
]
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "agent",
initial: () => ({ agents: new Map() }),
draft: (draft) => ({
list: () => Array.fromIterable(draft.agents.values()) as Info[],
get: (id) => draft.agents.get(id),
editor: (editor) => ({
list: () => Array.fromIterable(editor.agents.values()) as Info[],
get: (id) => editor.agents.get(id),
default: (id) => {
draft.default = id
editor.default = id
},
update: (id, fn) => {
const defaults = Info.default(id)
const current =
draft.agents.get(id) ??
editor.agents.get(id) ??
({
...defaults,
permissions: [...defaults.permissions, ...permissions],
} as Types.DeepMutable<Info>)
if (!draft.agents.has(id)) draft.agents.set(id, current)
if (!editor.agents.has(id)) editor.agents.set(id, current)
fn(current)
current.id = id
},
remove: (id) => {
draft.agents.delete(id)
editor.agents.delete(id)
},
}),
notify: bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
})
const selectable = (agent: Info | undefined) =>
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
+17 -17
View File
@@ -23,7 +23,7 @@ type Data = {
defaultModel?: DefaultModel
}
export type Draft = {
export type Editor = {
provider: {
list: () => readonly ProviderRecord[]
get: (providerID: Provider.ID) => ProviderRecord | undefined
@@ -41,7 +41,7 @@ export type Draft = {
}
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly provider: {
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
readonly all: () => Effect.Effect<Provider.Info[]>
@@ -82,39 +82,39 @@ const layer = Layer.effect(
} satisfies Model.Info
}
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "catalog",
initial: () => ({ providers: new Map() }),
draft: (draft) => {
const result: Draft = {
editor: (editor) => {
const result: Editor = {
provider: {
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
get: (providerID) => draft.providers.get(providerID),
list: () => Array.fromIterable(editor.providers.values()) as ProviderRecord[],
get: (providerID) => editor.providers.get(providerID),
update: (providerID, fn) => {
let current = draft.providers.get(providerID)
let current = editor.providers.get(providerID)
if (!current) {
current = {
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
}
draft.providers.set(providerID, current)
editor.providers.set(providerID, current)
}
fn(current.provider)
},
remove: (providerID) => {
draft.providers.delete(providerID)
editor.providers.delete(providerID)
},
},
model: {
get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
get: (providerID, modelID) => editor.providers.get(providerID)?.models.get(modelID),
update: (providerID, modelID, fn) => {
let record = draft.providers.get(providerID)
let record = editor.providers.get(providerID)
if (!record) {
record = {
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
models: new Map<Model.ID, Model.MutableInfo>(),
}
draft.providers.set(providerID, record)
editor.providers.set(providerID, record)
}
const model = record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
if (!record.models.has(modelID)) record.models.set(modelID, model)
@@ -123,19 +123,19 @@ const layer = Layer.effect(
model.providerID = providerID
},
remove: (providerID, modelID) => {
draft.providers.get(providerID)?.models.delete(modelID)
editor.providers.get(providerID)?.models.delete(modelID)
},
default: {
get: () => draft.defaultModel,
get: () => editor.defaultModel,
set: (providerID, modelID) => {
draft.defaultModel = { providerID, modelID }
editor.defaultModel = { providerID, modelID }
},
},
},
}
return result
},
notify: bus.publish(Catalog.Event.Updated, {}).pipe(Effect.asVoid, Effect.withSpan("Catalog.notify")),
notify: () => bus.publish(Catalog.Event.Updated, {}).pipe(Effect.asVoid, Effect.withSpan("Catalog.notify")),
})
const result: Interface = {
transform: state.transform,
+6 -6
View File
@@ -25,7 +25,7 @@ export interface Definition {
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
}
export type Draft = {
export type Editor = {
add: (definition: Definition) => void
}
@@ -39,7 +39,7 @@ export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Comman
message: Schema.String,
}) {}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
readonly execute: (input: {
@@ -54,13 +54,13 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Map<string, Definition>, Draft>({
const state = State.create<Map<string, Definition>, Editor>({
name: "command",
initial: () => new Map(),
draft: (draft) => ({
add: (definition) => draft.set(definition.name, definition),
editor: (editor) => ({
add: (definition) => editor.set(definition.name, definition),
}),
notify: bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
})
const info = (definition: Definition) =>
Info.make({
+28 -12
View File
@@ -188,7 +188,15 @@ export const layer = (options?: Options) =>
const globalDirectory = AbsolutePath.make(global.config)
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
// Global roots and the walk are compared by canonical path: the same
// directory reached under two spellings (a symlinked checkout, macOS
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
// classify identically or it enters discovery twice.
const globalRoots = yield* Effect.forEach(
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
(item) => fs.resolve(item),
)
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
const discovered =
locationIsGlobal || options?.project === false
? []
@@ -197,22 +205,30 @@ export const layer = (options?: Options) =>
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
start: location.directory,
})
.pipe(Effect.orDie)
.pipe(
Effect.flatMap((items) =>
Effect.forEach(items, (item) =>
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
),
),
Effect.orDie,
)
const globalEnabled = options?.global !== false
// A walked path that resolves into a global root is global config
// however the walk reached it (home above the project, or a location
// beneath the global config dir), so global: false excludes it
// uniformly — classified once here, not per consumer below.
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
path.resolve(item),
)
const visible = globalEnabled
? discovered
: discovered.filter((item) => {
const resolved = path.resolve(item)
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
})
// uniformly — classified once here, not per consumer below. With
// global enabled, the roots themselves and the global config files are
// already loaded below, so the walk must not add them a second time.
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
const visible = discovered
.filter(({ resolved }) =>
globalEnabled
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
)
.map(({ item }) => item)
// We load certain files from a few other folders in the ecosystem
const claude = [
...new Set([
+27 -18
View File
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
import { ConfigMarkdown } from "../markdown.js"
@@ -59,42 +59,51 @@ export const Plugin = define({
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(
// One trigger feed serializes reloads and shares one debounce window;
// subscribing before the initial scan means updates racing the scan still
// trigger a rebuild. Each source is subscribed eagerly on its own fiber
// (Stream.merge and Stream.debounce both open upstream a fiber hop later)
// so no update slips through while the debounce starts its pull.
const changes = yield* PubSub.sliding<void>(1)
const notify = () => PubSub.publish(changes, undefined)
yield* config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.documents = yield* load()
yield* ctx.agent.transform((draft) => {
yield* ctx.agent.transform((editor) => {
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))
for (const current of draft.list()) {
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
if (configuredDefault !== undefined) editor.default(Agent.ID.make(configuredDefault))
for (const current of editor.list()) {
editor.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)
if (item.disabled) {
draft.remove(agentID)
editor.remove(agentID)
continue
}
const exists = draft.get(agentID) !== undefined
draft.update(agentID, (agent) => {
const exists = editor.get(agentID) !== undefined
editor.update(agentID, (agent) => {
if (!exists) agent.permissions.push(...permissions)
if (item.model !== undefined)
agent.model = {
+22 -15
View File
@@ -8,7 +8,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { AppProcess } from "@opencode-ai/util/process"
import path from "path"
import { Effect, Option, Schema, Stream } from "effect"
import { Effect, Option, PubSub, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Config } from "../../config.js"
import { Location } from "../../location.js"
@@ -40,27 +40,34 @@ export const Plugin = define({
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(
// One trigger feed serializes reloads and shares one debounce window;
// subscribing before the initial scan means updates racing the scan still
// trigger a rebuild. Each source is subscribed eagerly on its own fiber
// (Stream.merge and Stream.debounce both open upstream a fiber hop later)
// so no update slips through while the debounce starts its pull.
const changes = yield* PubSub.sliding<void>(1)
const notify = () => PubSub.publish(changes, undefined)
yield* config.changes().pipe(
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path))),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(notify),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.documents = yield* load()
yield* ctx.command.transform((draft) => {
yield* ctx.command.transform((editor) => {
for (const document of loaded.documents) {
for (const [name, command] of Object.entries(document.commands ?? {})) {
draft.add({
editor.add({
name,
description: command.description,
execute: (input) =>
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
yield* compaction.transform((draft) => {
yield* compaction.transform((editor) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
editor.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
+4 -4
View File
@@ -24,7 +24,7 @@ export const Plugin = define({
const processes = yield* AppProcess.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
yield* formatter.transform((draft) => {
yield* formatter.transform((editor) => {
const configured = Config.latest(loaded.entries, "formatter")
if (!configured) return
const builtIns = make({
@@ -35,12 +35,12 @@ export const Plugin = define({
processes,
bin: global.bin,
})
builtIns.forEach(draft.set)
builtIns.forEach(editor.set)
if (configured === true) return
for (const [name, entry] of Object.entries(configured)) {
if (entry.disabled) {
draft.remove(name)
editor.remove(name)
continue
}
const builtIn = builtIns.find((formatter) => formatter.name === name)
@@ -51,7 +51,7 @@ export const Plugin = define({
enabled:
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
}
draft.set(current)
editor.set(current)
}
})
}),
+2 -2
View File
@@ -12,12 +12,12 @@ export const Plugin = define({
const config = yield* Config.Service
const image = yield* Image.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, image.reload())
yield* image.transform((draft) => {
yield* image.transform((editor) => {
for (const entry of loaded.entries) {
if (entry.type !== "document") continue
const configured = entry.info.media?.image
if (!configured) continue
draft.configure({
editor.configure({
...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }),
...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }),
...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }),
@@ -103,22 +103,26 @@ export const Plugin = define({
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
yield* Stream.fromPubSub(changes).pipe(
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
// before debouncing so no update slips through while the debounce starts its pull.
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* discovery.transform((draft) => {
yield* discovery.transform((editor) => {
if (loaded.current.type === "unavailable") {
draft.unavailable()
editor.unavailable()
return
}
for (const file of loaded.current.files) draft.add(file)
for (const file of loaded.current.files) editor.add(file)
})
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
Effect.andThen(discovery.transform((editor) => editor.unavailable())),
Effect.asVoid,
),
),
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const policy = yield* LocationWatcherPolicy.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, policy.reload())
yield* policy.transform((draft) => {
yield* policy.transform((editor) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
draft.add(entry.info.watcher.ignore)
editor.add(entry.info.watcher.ignore)
}
})
}),
+3 -3
View File
@@ -36,7 +36,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
// Subscribe before the initial load so updates racing it trigger a rebuild.
loaded.entries = yield* config.entries()
yield* mcp.transform((draft) => {
yield* mcp.transform((editor) => {
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
// Global timeout defaults merge in config order; each server can override them.
const timeout = Object.assign(
@@ -50,8 +50,8 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
}
}
for (const [name, server] of servers) {
if (draft.get(name)) continue
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
if (editor.get(name)) continue
editor.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
}
})
})
+2 -2
View File
@@ -19,14 +19,14 @@ export const Plugin = define({
const location = yield* Location.Service
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
yield* ctx.reference.transform((editor) => {
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
draft.add(
editor.add(
name,
local(entry)
? Reference.LocalSource.make({
+2 -2
View File
@@ -12,9 +12,9 @@ export const Plugin = define({
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, shell.reload())
yield* shell.transform((draft) => {
yield* shell.transform((editor) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) draft.configure(configured)
if (configured) editor.configure(configured)
})
}),
})
+7 -3
View File
@@ -171,13 +171,17 @@ export const Plugin = define({
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
)
yield* Stream.fromPubSub(changes).pipe(
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
// before debouncing so no update slips through while the debounce starts its pull.
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* refresh()
yield* ctx.skill.transform((draft) => {
for (const skill of loaded.skills) draft.add(skill)
yield* ctx.skill.transform((editor) => {
for (const skill of loaded.skills) editor.add(skill)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
+2 -2
View File
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, snapshot.reload())
yield* snapshot.transform((draft) => {
yield* snapshot.transform((editor) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
draft.configure(configured)
editor.configure(configured)
})
}),
})
+39 -19
View File
@@ -3,10 +3,11 @@ export * as ConfigPluginSource from "./source.js"
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Host } from "@opencode-ai/plugin/host"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { fileURLToPath, pathToFileURL } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
@@ -41,8 +42,8 @@ export const layer = Layer.effect(
const configuredChanges = yield* PubSub.unbounded<void>()
const watched = new Set<string>()
// Configured local plugin entrypoints can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// Configured local plugins can live outside config roots, where the
// config change feed cannot see them; watch those targets directly.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
@@ -56,7 +57,10 @@ export const layer = Layer.effect(
// The config change feed already covers {plugin,plugins} directories.
if (isPluginSource(entries, operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
const updates = yield* watcher.subscribe({
path: operation.target,
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
})
yield* updates.pipe(
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
@@ -148,24 +152,40 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
return Option.none<Operation>()
}
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
return Option.none<Operation>()
return Option.some<Operation>(operation)
}),
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
// Explicit config is applied last so it can remove auto-discovered packages.
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
})
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
Effect.gen(function* () {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
if (!(yield* fs.existsSafe(operation.target))) return [operation]
const directory = yield* fs.isDir(operation.target)
const entrypoints: Host.Entrypoints = directory
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
: { server: pathToFileURL(operation.target).href }
if (!entrypoints.server) return []
if (directory) {
const root = yield* fs.resolve(operation.target)
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
if (!FSUtil.contains(root, server)) return []
}
const times = yield* Effect.forEach(
[
...Object.values(entrypoints)
.filter((entry) => entry !== undefined)
.map((entry) => fileURLToPath(entry)),
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
],
(entry) =>
fs.stat(entry).pipe(
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
Effect.orElseSucceed(() => 0),
),
)
return [{ ...operation, mtime: Math.max(...times) }]
}),
).pipe(Effect.map((operations) => operations.flat()))
})
function isPluginSource(entries: readonly Entry[], file: string) {
@@ -12,10 +12,10 @@ export const Plugin = define({
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, output.reload())
yield* output.transform((draft) => {
yield* output.transform((editor) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
draft.configure({
editor.configure({
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
})
+46 -21
View File
@@ -2,7 +2,7 @@ export * as Database from "./database.js"
import { EffectDrizzleSqlite } from "./drizzle.js"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
@@ -23,30 +23,52 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/storage/Database") {}
const databaseLayer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
// The bootstrap lock is scoped to the database being built, never to this
// module: on workerd every Durable Object in an isolate shares module state, and
// releasing a shared semaphore resumes the waiting object's fiber inside the
// releasing object's I/O context, where its first storage call is rejected as
// cross-object I/O.
const databaseLayer = (lock: Effect.Effect<Semaphore.Semaphore>) =>
Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* DatabaseMigration.apply(db)
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
const semaphore = yield* lock
yield* semaphore.withPermit(DatabaseMigration.apply(db))
return { db }
}).pipe(Effect.orDie),
)
return { db }
}).pipe(Effect.orDie),
)
// Two instances over one file bootstrap the same schema, so file databases
// share a lock per path. Each in-memory database is its own connection.
const locks = new Map<string, Semaphore.Semaphore>()
function lockFor(filename: string) {
const existing = locks.get(filename)
if (existing) return existing
const lock = Semaphore.makeUnsafe(1)
locks.set(filename, lock)
return lock
}
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const provide = (filename: string) =>
databaseLayer(filename === ":memory:" ? Semaphore.make(1) : Effect.succeed(lockFor(filename))).pipe(
Layer.provide(sqliteLayer({ filename })),
)
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -58,8 +80,11 @@ export function layer(options: Options = { path: ":memory:" }) {
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
// because migrations may read it (the v1 import). The lock is created per build
// because every Durable Object builds this layer over its own storage.
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer(
Semaphore.make(1),
)
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
+32 -33
View File
@@ -1,7 +1,7 @@
export * as DatabaseMigration from "./migration.js"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { Effect } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "./drizzle.js"
import { migrations } from "./migration.gen.js"
@@ -10,7 +10,6 @@ import { Global } from "@opencode-ai/util/global"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
@@ -18,38 +17,38 @@ export type Migration = {
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
}
// Not serialized here: the Database layer holds a lock scoped to the database
// it is bootstrapping, since two instances over one file must not race.
export function apply(db: Database) {
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
const started = Date.now()
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* schema.up(tx)
yield* tx.run(
sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
yield* Effect.forEach(migrations, (migration) =>
tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
),
)
}),
)
yield* Effect.logInfo("database schema bootstrap completed", {
migrations: migrations.length,
durationMs: Date.now() - started,
})
}),
)
return Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
const started = Date.now()
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* schema.up(tx)
yield* tx.run(
sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
yield* Effect.forEach(migrations, (migration) =>
tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
),
)
}),
)
yield* Effect.logInfo("database schema bootstrap completed", {
migrations: migrations.length,
durationMs: Date.now() - started,
})
})
}
export function applyOnly(db: Database, input: Migration[]) {
@@ -8,12 +8,12 @@ type Data = {
ignore: string[]
}
export type Draft = {
export type Editor = {
add: (ignore: readonly string[]) => void
list: () => readonly string[]
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly current: () => readonly string[]
readonly observe: (
listener: (ignore: readonly string[]) => Effect.Effect<void>,
@@ -26,14 +26,15 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "location-watcher-policy",
initial: () => ({ ignore: [] }),
draft: (draft) => ({
add: (ignore) => draft.ignore.push(...ignore),
list: () => draft.ignore,
editor: (editor) => ({
add: (ignore) => editor.ignore.push(...ignore),
list: () => editor.ignore,
}),
notify: Effect.forEach(listeners, (listener) => listener(current()), { discard: true }),
// Read per listener: a reentrant transform inside an earlier listener must reach later ones.
notify: () => Effect.forEach(listeners, (listener) => listener(current()), { discard: true }),
})
// Annotated to break the inference cycle through notify: notify reads current, current reads state.
const current = (): readonly string[] => state.get().ignore
@@ -24,7 +24,6 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const plugins = yield* Plugin.Service
const policy = yield* LocationWatcherPolicy.Service
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
bus.publish(FileSystem.Event.Changed, {
@@ -93,7 +92,7 @@ const layer = Layer.effect(
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* plugins.awaitActivation
yield* Plugin.awaitActivation
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
+24 -5
View File
@@ -5,6 +5,7 @@ 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 { FSUtil } from "@opencode-ai/util/fs-util"
import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "effect"
import { lazy } from "../util/lazy.js"
import { watch } from "node:fs"
@@ -70,7 +71,7 @@ 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. */
/** Delivers one update to every active watch whose target covers `update.path`. */
readonly emit: (update: Update) => Effect.Effect<void>
/** Returns every subscribe call observed so far, in order. */
readonly subscriptions: () => Effect.Effect<readonly WatchInput[]>
@@ -150,12 +151,14 @@ export const layer = (options?: Options) =>
/**
* Watcher for tests: the real lifecycle over an in-memory Native that records
* acquired watches and broadcasts emitted updates to every active watch.
* acquired watches and routes emitted updates the way the OS watches would: a
* file watch receives updates for its own path, a directory watch receives
* updates for paths inside it that no ignore entry covers.
*/
export const testLayer = Layer.effectContext(
Effect.gen(function* () {
const subscriptions: WatchInput[] = []
const active = new Set<(update: Update) => void>()
const active = new Map<(update: Update) => void, (path: string) => boolean>()
const native = Native.of({
subscribe: (input) =>
Effect.sync(() => {
@@ -166,7 +169,17 @@ export const testLayer = Layer.effectContext(
? { path: input.target, type: "directory", ignore: input.ignore }
: { path: input.target, type: "directory" },
)
active.add(input.publish)
// Ignore entries resolve against the target like the parcel wrapper's
// literal paths. Glob entries resolve to paths nothing lives under, so
// they are inert here rather than compiled the way parcel compiles them.
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
active.set(
input.publish,
input.type === "file"
? (target) => target === input.target
: (target) =>
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
)
return {
unsubscribe: () => {
active.delete(input.publish)
@@ -178,7 +191,13 @@ export const testLayer = Layer.effectContext(
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))),
emit: (update) =>
Effect.sync(() => {
const target = path.resolve(update.path)
active.forEach((matches, publish) => {
if (matches(target)) publish(update)
})
}),
subscriptions: () => Effect.sync(() => [...subscriptions]),
})
return Context.empty().pipe(Context.add(Service, test), Context.add(Test, test))
+8 -8
View File
@@ -13,12 +13,12 @@ type Data = {
formatters: Info[]
}
export type Draft = {
export type Editor = {
set: (formatter: Info) => void
remove: (name: string) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly file: (filepath: string) => Effect.Effect<boolean>
}
@@ -30,17 +30,17 @@ const layer = Layer.effect(
const location = yield* Location.Service
const processes = yield* AppProcess.Service
const commands = new WeakMap<Info, string[] | false>()
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "formatter",
initial: () => ({ formatters: [] }),
draft: (draft) => ({
editor: (editor) => ({
set: (formatter) => {
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) draft.formatters.push(formatter)
else draft.formatters[index] = formatter
const index = editor.formatters.findIndex((item) => item.name === formatter.name)
if (index === -1) editor.formatters.push(formatter)
else editor.formatters[index] = formatter
},
remove: (name) => {
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
editor.formatters = editor.formatters.filter((formatter) => formatter.name !== name)
},
}),
})
+8 -8
View File
@@ -39,11 +39,11 @@ export type Limits = {
maxBase64Bytes: number
}
export type Draft = {
export type Editor = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly normalize: (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
@@ -58,7 +58,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Limits, Draft>({
const state = State.create<Limits, Editor>({
name: "image",
initial: () => ({
autoResize: true,
@@ -66,12 +66,12 @@ const layer = Layer.effect(
maxHeight: 2_000,
maxBase64Bytes: 5 * 1024 * 1024,
}),
draft: (draft) => ({
editor: (editor) => ({
configure: (limits) => {
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
if (limits.autoResize !== undefined) editor.autoResize = limits.autoResize
if (limits.maxWidth !== undefined) editor.maxWidth = limits.maxWidth
if (limits.maxHeight !== undefined) editor.maxHeight = limits.maxHeight
if (limits.maxBase64Bytes !== undefined) editor.maxBase64Bytes = limits.maxBase64Bytes
},
}),
})
+10 -10
View File
@@ -25,7 +25,7 @@ export type Data = {
available: boolean
}
export type Draft = {
export type Editor = {
list: () => readonly Types.DeepMutable<File>[]
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
// sibling contributors interleave by transform registration order.
@@ -35,7 +35,7 @@ export type Draft = {
unavailable: () => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
// Discovery policy lives here because internal plugins have no per-composition options channel.
// Move it into plugin config once plugins can consume their own options.
readonly project: boolean
@@ -57,24 +57,24 @@ export const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "instruction-discovery",
initial: () => ({ files: new Map(), available: true }),
draft: (draft) => ({
list: () => Array.from(draft.files.values()),
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
editor: (editor) => ({
list: () => Array.from(editor.files.values()),
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = draft.files.get(AbsolutePath.make(path))
const current = editor.files.get(AbsolutePath.make(path))
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
},
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
unavailable: () => {
draft.available = false
editor.available = false
},
}),
notify: bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
+15 -15
View File
@@ -130,7 +130,7 @@ type Data = {
integrations: Map<ID, Entry>
}
export type Draft = {
export type Editor = {
list: () => readonly Ref[]
get: (id: ID) => Ref | undefined
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
@@ -142,7 +142,7 @@ export type Draft = {
}
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
/** Registers a scoped transform over the integration registry. */
/** Returns one integration with its methods and current connections. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
@@ -266,27 +266,27 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "integration",
initial: () => ({ integrations: new Map<ID, Entry>() }),
draft: (draft) => ({
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
editor: (editor) => ({
list: () => Array.from(editor.integrations.values(), (entry) => entry.ref) as Ref[],
get: (id) => editor.integrations.get(id)?.ref as Ref | undefined,
update: (id, update) => {
const current = draft.integrations.get(id) ?? {
const current = editor.integrations.get(id) ?? {
ref: { id, name: id },
methods: [],
implementations: new Map(),
}
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
if (!editor.integrations.has(id)) editor.integrations.set(id, current)
update(current.ref)
current.ref.id = id
},
remove: (id) => draft.integrations.delete(id),
remove: (id) => editor.integrations.delete(id),
method: {
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
list: (integrationID) => (editor.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
update: (implementation) => {
const current = draft.integrations.get(implementation.integrationID) ?? {
const current = editor.integrations.get(implementation.integrationID) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
@@ -294,8 +294,8 @@ const layer = Layer.effect(
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
if (!editor.integrations.has(implementation.integrationID)) {
editor.integrations.set(implementation.integrationID, current)
}
const index = current.methods.findIndex((method) => {
if (method.type !== implementation.method.type) return false
@@ -315,7 +315,7 @@ const layer = Layer.effect(
}
},
remove: (integrationID, method) => {
const current = draft.integrations.get(integrationID)
const current = editor.integrations.get(integrationID)
if (!current) return
const index = current.methods.findIndex((candidate) => {
if (candidate.type !== method.type) return false
@@ -328,7 +328,7 @@ const layer = Layer.effect(
},
},
}),
notify: bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
+14 -14
View File
@@ -133,7 +133,7 @@ type Data = {
removed: Set<ServerName>
}
export type Draft = {
export type Editor = {
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
@@ -143,7 +143,7 @@ export type Draft = {
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly servers: () => Effect.Effect<ServerInfo[]>
readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect<void>
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
@@ -227,12 +227,12 @@ export const layer = (options?: Options) =>
const scope = yield* Scope.fork(root)
entry.registration = { dispose: Scope.close(scope, Exit.void) }
yield* integration
.transform((draft) => {
draft.update(integrationID, (ref) => {
.transform((editor) => {
editor.update(integrationID, (ref) => {
ref.name = name
ref.metadata = { source: "mcp" }
})
draft.method.update({
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: name },
authorize: () =>
@@ -678,7 +678,7 @@ export const layer = (options?: Options) =>
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
),
)
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "mcp",
initial: () => ({
servers: new Map(
@@ -688,22 +688,22 @@ export const layer = (options?: Options) =>
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
get: (server) => draft.servers.get(ServerName.make(server)),
editor: (editor) => ({
list: () => Array.from(editor.servers),
get: (server) => editor.servers.get(ServerName.make(server)),
set: (server, serverConfig) => {
const name = ServerName.make(server)
if (draft.removed.has(name)) return
draft.servers.set(name, cloneConfig(serverConfig))
if (editor.removed.has(name)) return
editor.servers.set(name, cloneConfig(serverConfig))
},
update: (server, update) => {
const current = draft.servers.get(ServerName.make(server))
const current = editor.servers.get(ServerName.make(server))
if (!current) return
update(current)
},
remove: (server) => draft.servers.delete(ServerName.make(server)),
remove: (server) => editor.servers.delete(ServerName.make(server)),
}),
notify: State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
notify: () => State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
})
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
+30 -48
View File
@@ -2,7 +2,6 @@ export * as Plugin from "./plugin.js"
export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Node } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import type { PersistentPty } from "./persistent-pty.js"
@@ -10,27 +9,10 @@ import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope,
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { PluginHost } from "./plugin/host.js"
import { type Failure, type Generation, Service } from "./plugin/service.js"
import { State } from "./state.js"
export interface Interface {
readonly activate: (plugins: readonly Generation[], failures?: readonly Failure[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
readonly close: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>
/** Wait for announced updates and activation to settle; failures remain in the inventory. */
readonly awaitActivation: Effect.Effect<void>
/** Keep readiness pending while preparing an update. Run the returned Effect to release it. */
readonly hold: () => Effect.Effect<Effect.Effect<void>>
}
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Generation = PluginDefinition & {
readonly revision: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
export { awaitActivation, type Generation, type Interface, Service } from "./plugin/service.js"
const layer = Layer.effect(
Service,
@@ -70,7 +52,6 @@ const layer = Layer.effect(
),
),
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
Effect.exit,
)
@@ -98,17 +79,17 @@ const layer = Layer.effect(
() =>
lock.withPermit(
Effect.gen(function* () {
if (
active.size === definitions.length &&
Array.from(active.values()).every((entry, index) => {
const definition = definitions[index]
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
})
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
const current = Array.from(active.values())
const changed = definitions.findIndex((definition, index) => {
const entry = current[index]
return entry?.plugin.id !== definition.id || entry.plugin.revision !== definition.revision
})
const prefix = changed === -1 ? definitions.length : changed
for (const definition of definitions.slice(0, prefix)) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
if (prefix === definitions.length && active.size === definitions.length) {
const nextInventory = [...definitions.map(activeInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
@@ -118,12 +99,19 @@ const layer = Layer.effect(
yield* State.batch(
Effect.gen(function* () {
const nextInventory: Plugin.Info[] = []
for (const definition of definitions) {
const previous = active.get(definition.id)
active.delete(definition.id)
if (previous) yield* Scope.close(previous.scope, Exit.void)
// Registrations are ordered by setup, so only the unchanged prefix can stay alive.
const previous = new Map(Array.from(active.entries()).slice(prefix))
yield* Effect.forEach(
Array.from(previous.entries()).toReversed(),
([id, entry]) =>
Effect.gen(function* () {
active.delete(id)
yield* Scope.close(entry.scope, Exit.void)
}),
{ discard: true },
)
const nextInventory = definitions.slice(0, prefix).map(activeInfo)
for (const definition of definitions.slice(prefix)) {
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
@@ -137,10 +125,11 @@ const layer = Layer.effect(
features: { server: true, ...definition.features },
})
if (!previous) continue
const restored = yield* load(previous.plugin)
const fallback = previous.get(definition.id)
if (!fallback) continue
const restored = yield* load(fallback.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
active.set(definition.id, { plugin: fallback.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -148,13 +137,6 @@ const layer = Layer.effect(
})
}
const removed = Array.from(active.entries())
.filter(([id]) => !ids.has(id))
.toReversed()
removed.forEach(([id]) => active.delete(id))
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
discard: true,
})
inventory = [...nextInventory, ...failures]
}),
)
+7 -7
View File
@@ -83,15 +83,15 @@ Rules:
export const Plugin = define({
id: "opencode.agent",
effect: Effect.fn(function* (ctx) {
yield* ctx.agent.transform((draft) => {
draft.update(Agent.defaultID, (item) => {
yield* ctx.agent.transform((editor) => {
editor.update(Agent.defaultID, (item) => {
item.name = Agent.Name.make("Build")
item.description = "The default agent. Executes tools based on configured permissions."
item.mode = "primary"
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("general"), (item) => {
editor.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
@@ -102,7 +102,7 @@ export const Plugin = define({
)
})
draft.update(Agent.ID.make("explore"), (item) => {
editor.update(Agent.ID.make("explore"), (item) => {
const externalDirectories = item.permissions.filter(
(rule) => rule.action === "external_directory" && rule.effect === "allow",
)
@@ -130,13 +130,13 @@ export const Plugin = define({
)
})
draft.update(Agent.ID.make("compaction"), (item) => {
editor.update(Agent.ID.make("compaction"), (item) => {
item.name = Agent.Name.make("Compaction")
item.mode = "primary"
item.hidden = true
})
draft.update(Agent.ID.make("title"), (item) => {
editor.update(Agent.ID.make("title"), (item) => {
item.name = Agent.Name.make("Title")
item.mode = "primary"
item.hidden = true
@@ -144,7 +144,7 @@ export const Plugin = define({
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
})
draft.update(Agent.ID.make("summary"), (item) => {
editor.update(Agent.ID.make("summary"), (item) => {
item.name = Agent.Name.make("Summary")
item.mode = "primary"
item.hidden = true
+4 -4
View File
@@ -25,8 +25,8 @@ export const Plugin = define({
Effect.forkScoped({ startImmediately: true }),
)
loaded.prompts = yield* mcp.prompts()
yield* ctx.command.transform((draft) => {
draft.add({
yield* ctx.command.transform((editor) => {
editor.add({
name: "init",
description: "guided AGENTS.md setup",
execute: (input) =>
@@ -39,7 +39,7 @@ export const Plugin = define({
})
.pipe(Effect.asVoid),
})
draft.add({
editor.add({
name: "review",
description: "review changes [commit|branch|pr], defaults to uncommitted",
execute: (input) =>
@@ -53,7 +53,7 @@ export const Plugin = define({
.pipe(Effect.asVoid),
})
for (const prompt of loaded.prompts) {
draft.add({
editor.add({
name: mcpCommandName(prompt.server, prompt.name),
description: prompt.description,
execute: (input) =>
+44 -43
View File
@@ -136,13 +136,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
reload: agents.reload,
transform: (callback) =>
agents.transform((draft) => {
agents.transform((editor) => {
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Agent.ID.make(id))),
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
update: (id, update) => draft.update(Agent.ID.make(id), update),
remove: (id) => draft.remove(Agent.ID.make(id)),
list: () => mutable(editor.list()),
get: (id) => mutable(editor.get(Agent.ID.make(id))),
default: (id) => editor.default(id === undefined ? undefined : Agent.ID.make(id)),
update: (id, update) => editor.update(Agent.ID.make(id), update),
remove: (id) => editor.remove(Agent.ID.make(id)),
})
}),
},
@@ -196,24 +196,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
reload: catalog.reload,
transform: (callback) =>
catalog.transform((draft) => {
catalog.transform((editor) => {
callback({
provider: {
list: () => mutable(draft.provider.list()),
get: (id) => mutable(draft.provider.get(Provider.ID.make(id))),
update: (id, update) => draft.provider.update(Provider.ID.make(id), update),
remove: (id) => draft.provider.remove(Provider.ID.make(id)),
list: () => mutable(editor.provider.list()),
get: (id) => mutable(editor.provider.get(Provider.ID.make(id))),
update: (id, update) => editor.provider.update(Provider.ID.make(id), update),
remove: (id) => editor.provider.remove(Provider.ID.make(id)),
},
model: {
get: (providerID, modelID) =>
mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
mutable(editor.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
update: (providerID, modelID, update) =>
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
editor.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
remove: (providerID, modelID) =>
editor.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
default: {
get: draft.model.default.get,
get: editor.model.default.get,
set: (providerID, modelID) =>
draft.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
editor.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
},
},
})
@@ -315,17 +316,17 @@ export const make = Effect.fn("PluginHost.make")(function* (
),
},
transform: (callback) =>
integration.transform((draft) => {
integration.transform((editor) => {
callback({
list: () => mutable(draft.list()),
get: (id) => mutable(draft.get(Integration.ID.make(id))),
update: (id, update) => draft.update(Integration.ID.make(id), update),
remove: (id) => draft.remove(Integration.ID.make(id)),
list: () => mutable(editor.list()),
get: (id) => mutable(editor.get(Integration.ID.make(id))),
update: (id, update) => editor.update(Integration.ID.make(id), update),
remove: (id) => editor.remove(Integration.ID.make(id)),
method: {
list: (id) => draft.method.list(Integration.ID.make(id)),
update: (input) => draft.method.update(methodImplementation(input)),
list: (id) => editor.method.list(Integration.ID.make(id)),
update: (input) => editor.method.update(methodImplementation(input)),
remove: (id, method) =>
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
editor.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
},
})
}),
@@ -350,13 +351,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
},
reload: mcp.reload,
transform: (callback) =>
mcp.transform((draft) => {
mcp.transform((editor) => {
callback({
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(draft.get(name)),
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: draft.update,
remove: draft.remove,
list: () => editor.list().map(([name, config]) => [name, mutable(config)]),
get: (name) => mutable(editor.get(name)),
set: (name, config) => editor.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
update: editor.update,
remove: editor.remove,
})
}),
},
@@ -391,11 +392,11 @@ export const make = Effect.fn("PluginHost.make")(function* (
list: () => response(reference.list()),
reload: reference.reload,
transform: (callback) =>
reference.transform((draft) => {
reference.transform((editor) => {
callback({
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: draft.remove,
list: draft.list,
add: (name, source) => editor.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
remove: editor.remove,
list: editor.list,
})
}),
},
@@ -403,12 +404,12 @@ export const make = Effect.fn("PluginHost.make")(function* (
list: () => response(skill.list()),
reload: skill.reload,
transform: (callback) =>
skill.transform((draft) => {
skill.transform((editor) => {
callback({
list: () => mutable(draft.list()),
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: draft.update,
remove: draft.remove,
list: () => mutable(editor.list()),
add: (value) => editor.add(Schema.decodeUnknownSync(Skill.Info)(value)),
update: editor.update,
remove: editor.remove,
})
}),
},
@@ -441,18 +442,18 @@ export const make = Effect.fn("PluginHost.make")(function* (
),
reload: websearch.reload,
transform: (callback) =>
websearch.transform((draft) => {
websearch.transform((editor) => {
callback({
add: (definition) =>
draft.add({
editor.add({
id: WebSearch.ID.make(definition.id),
name: definition.name,
execute: definition.execute,
}),
default: {
get: draft.default.get,
get: editor.default.get,
set: (selection) =>
draft.default.set(
editor.default.set(
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
),
},
+4
View File
@@ -63,6 +63,7 @@ import { PatchTool } from "../tool/plugin/patch.js"
import { EditTool } from "../tool/plugin/edit.js"
import { GlobTool } from "../tool/plugin/glob.js"
import { GrepTool } from "../tool/plugin/grep.js"
import { OpenCodeTools } from "../tool/plugin/opencode.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { ReadToolFileSystem } from "../tool/read-filesystem.js"
import { ReadTool } from "../tool/plugin/read.js"
@@ -76,6 +77,7 @@ import { WebSearchTool } from "../tool/plugin/websearch.js"
import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode-ai/plugin-browser"
import { CommandPlugin } from "./command.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -187,6 +189,7 @@ export const requirements = LayerNode.group([
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
BrowserPlugin,
ConfigMcpPlugin.Plugin,
McpCodeModeExclusionPlugin.Plugin,
WellKnownPlugin.Plugin,
@@ -204,6 +207,7 @@ const pre = [
EditTool.Plugin,
GlobTool.Plugin,
GrepTool.Plugin,
OpenCodeTools.Plugin,
QuestionTool.Plugin,
ReadTool.Plugin,
ShellTool.Plugin,
@@ -9,8 +9,8 @@ const urls = [/^https:\/\/executor\.sh\/[^/]+\/mcp$/]
export const Plugin = define({
id: "opencode.mcp.codemode.exclusion",
effect: Effect.fn(function* (ctx) {
yield* ctx.mcp.transform((draft) => {
for (const [, server] of draft.list()) {
yield* ctx.mcp.transform((editor) => {
for (const [, server] of editor.list()) {
if (server.codemode !== undefined) continue
if (server.type === "local") {
if (server.command[0] === "executor" && server.command[1] === "mcp") server.codemode = false
+8 -5
View File
@@ -10,6 +10,8 @@ export const ModelsDevPlugin = define({
effect: Effect.fn(function* (ctx) {
const modelsDev = yield* ModelsDev.Service
const bus = yield* Bus.Service
// The normalized snapshot is shared by every Location and only read here; the catalog
// receives copies below, so retaining a second copy per Location is unnecessary.
const loaded = { data: snapshots(yield* modelsDev.get()) }
yield* ctx.integration.transform((integrations) => {
for (const provider of loaded.data) {
@@ -32,7 +34,7 @@ export const ModelsDevPlugin = define({
yield* ctx.catalog.transform((catalog) => {
for (const provider of loaded.data) {
catalog.provider.update(provider.info.id, (draft) => {
Object.assign(draft, provider.info)
Object.assign(draft, copy(provider.info))
draft.integrationID = Integration.ID.make(provider.info.id)
})
for (const model of provider.models) {
@@ -65,15 +67,16 @@ function environmentNames(provider: ModelsDev.Snapshot) {
}
function snapshots(data: readonly ModelsDev.Snapshot[]) {
return copy(data).filter(
return data.filter(
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
)
}
// The catalog owns and mutates its model records, so every rebuild needs fresh copies of the
// thousands of snapshot models. Snapshot data is plain JSON, and a direct copy is an order of
// magnitude faster than structuredClone's general graph walk on the startup path.
// The catalog owns and mutates its provider and model records in place, so every rebuild
// needs fresh copies of the thousands of shared snapshot records. Snapshot data is plain
// JSON, and a direct copy is an order of magnitude faster than structuredClone's general
// graph walk on the startup path.
function copy<T>(value: T): T {
if (Array.isArray(value)) return value.map(copy) as T
if (value !== null && typeof value === "object") {
+24 -43
View File
@@ -1,12 +1,12 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Host } from "@opencode-ai/plugin/host"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { stat } from "node:fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
@@ -35,21 +35,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
options?: { readonly install?: boolean },
) {
const npm = yield* Npm.Service
const local = path.isAbsolute(operation.target)
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
const npm = yield* Npm.Service
const installed = local
? undefined
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
? yield* npm.resolve(operation.target)
: yield* npm.add(operation.target)
// Legacy auto-discovery still admits standalone server sources. Configured
// local plugins always arrive here as directories.
const entrypoints: Host.Entrypoints =
local && (yield* Effect.promise(() => stat(operation.target))).isFile()
? { server: pathToFileURL(operation.target).href }
: yield* Effect.sync(() => Host.resolve(installed ?? { directory: operation.target }))
const entrypoint = entrypoints.server
if (!local && options?.install === false && !entrypoint) return { pending: true as const }
if (!entrypoint) return yield* new LoadError({ message: `Plugin entrypoint not found: ${operation.target}` })
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const target = typeof Bun !== "undefined" ? fileURLToPath(entrypoint).replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const mod = yield* Effect.promise(() => Host.load(source))
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
Effect.mapError(
(cause) =>
@@ -60,45 +66,20 @@ export const load = Effect.fn("PluginModule.load")(function* (
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
: yield* Effect.all({
tui: npm.resolve(operation.target, { subpaths: ["tui"] }),
rpc: npm.resolve(operation.target, { subpaths: ["rpc"] }),
}).pipe(
Effect.map((resolved) => ({
...(resolved.tui.entrypoint ? { tui: true as const } : {}),
...(resolved.rpc.entrypoint ? { rpc: true as const } : {}),
})),
)
return {
id: plugin.id,
features,
revision: JSON.stringify([operation, installed.revision]),
features: {
...(entrypoints.tui ? { tui: true as const } : {}),
...(entrypoints.rpc ? { rpc: true as const } : {}),
},
revision: JSON.stringify([operation, installed?.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
? { type: "local" as const, path: fileURLToPath(entrypoint) }
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
...(installed?.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Generation
})
function localFeatures(entrypoint: string) {
if (!path.basename(entrypoint).startsWith("index.")) return Effect.succeed({})
return Effect.promise(() => readdir(path.dirname(entrypoint), { withFileTypes: true })).pipe(
Effect.map((entries) => {
const names = new Set(
entries.filter((entry) => entry.isFile() || entry.isSymbolicLink()).map((entry) => entry.name),
)
const has = (name: string) =>
["ts", "tsx", "js", "jsx", "mts", "mjs", "cts", "cjs"].some((extension) => names.has(`${name}.${extension}`))
return {
...(has("tui") ? { tui: true as const } : {}),
...(has("rpc") ? { rpc: true as const } : {}),
}
}),
)
}
+3 -3
View File
@@ -12,7 +12,7 @@ import { Permission } from "../permission.js"
const plan = Agent.ID.make("plan")
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. You may optionally create or update plan documents in:
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
${directory}
Do not modify any other files or ask a subagent to do so.
@@ -30,8 +30,8 @@ export const Plugin = define({
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const enterReminder = enter(directory)
yield* ctx.agent.transform((draft) => {
draft.update(plan, (item) => {
yield* ctx.agent.transform((editor) => {
editor.update(plan, (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
item.mode = "primary"
+3 -3
View File
@@ -80,13 +80,13 @@ export const AzurePlugin = define({
])
})
yield* ctx.integration.transform((draft) => {
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
draft.method.update({
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
@@ -39,8 +39,8 @@ export const CloudflareAIGatewayPlugin = define({
if (gatewayId) return Form.Fields.make([accountIdForm])
return Form.Fields.make([accountIdForm, gatewayIdForm])
})
yield* ctx.integration.transform((draft) => {
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: providerID,
method: {
type: "key",
@@ -25,8 +25,8 @@ export const CloudflareWorkersAIPlugin = define({
},
])
})
yield* ctx.integration.transform((draft) => {
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: providerID,
method: {
type: "key",
@@ -190,9 +190,9 @@ export const GithubCopilotPlugin = define({
)
})
yield* ctx.integration.transform((draft) => {
draft.method.remove("github-copilot", { type: "key" })
draft.method.update(oauth(ctx.app))
yield* ctx.integration.transform((editor) => {
editor.method.remove("github-copilot", { type: "key" })
editor.method.update(oauth(ctx.app))
})
yield* ctx.catalog.transform((evt) => {
const item = evt.provider.get(Provider.ID.githubCopilot)
+3 -3
View File
@@ -245,9 +245,9 @@ export const OpenAIPlugin = define({
: undefined
})
yield* ctx.integration.transform((draft) => {
draft.method.update(browser(ctx.app))
draft.method.update(headless(ctx.app))
yield* ctx.integration.transform((editor) => {
editor.method.update(browser(ctx.app))
editor.method.update(headless(ctx.app))
})
yield* load()
yield* ctx.catalog.transform((evt) => {
@@ -103,12 +103,12 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
: undefined
})
yield* ctx.integration.transform((draft) => {
draft.update("opencode", (integration) => {
yield* ctx.integration.transform((editor) => {
editor.update("opencode", (integration) => {
integration.name = "OpenCode"
})
draft.method.update(oauth(http))
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
editor.method.update(oauth(http))
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
})
yield* load()
@@ -1,17 +1,16 @@
import { Effect } from "effect"
import { pathToFileURL } from "url"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { importModule, resolveModule } from "@opencode-ai/util/runtime-import"
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
const installedPath = packageName.startsWith("file://")
? packageName
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
: yield* npm.add(packageName).pipe(
Effect.orDie,
Effect.map((installed) => resolveModule(installed.name, installed.directory)),
)
const mod = (yield* Effect.promise(() =>
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
)) as Record<string, unknown>
const mod = (yield* Effect.promise(() => importModule(installedPath))) as Record<string, unknown>
const match = Object.keys(mod).find((name) => name.startsWith("create"))
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
return mod[match]
+4 -4
View File
@@ -88,12 +88,12 @@ export const XAIPlugin = define({
{ discard: true },
)
yield* ctx.integration.transform((draft) => {
draft.update("xai", (integration) => {
yield* ctx.integration.transform((editor) => {
editor.update("xai", (integration) => {
integration.name = "xAI"
})
draft.method.update(device(ctx.app))
draft.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
editor.method.update(device(ctx.app))
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
})
yield* ctx.catalog.transform((catalog) => {
const provider = catalog.provider.get(providerID)
+34
View File
@@ -0,0 +1,34 @@
export * as Plugin from "./service.js"
// The service tag lives apart from the layer so Session entry points can wait on activation without
// importing the plugin host, which itself depends on Session.
import { Plugin } from "@opencode-ai/schema/plugin"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Context, type Effect, type Exit } from "effect"
export interface Interface {
readonly activate: (plugins: readonly Generation[], failures?: readonly Failure[]) => Effect.Effect<void>
readonly list: () => Effect.Effect<Plugin.Info[]>
readonly close: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>
/** Wait for announced updates and activation to settle; failures remain in the inventory. */
readonly awaitActivation: Effect.Effect<void>
/** Keep readiness pending while preparing an update. Run the returned Effect to release it. */
readonly hold: () => Effect.Effect<Effect.Effect<void>>
}
export type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
export type Generation = PluginDefinition & {
readonly revision: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
/**
* Session entry points wait here before reading plugin-provided state such as commands, skills, hooks,
* and the model catalog. A cold Location activates its plugins asynchronously, so without the wait an
* early request observes an empty registry and admits or fails work the plugins would have shaped.
*/
export const awaitActivation = Service.use((plugins) => plugins.awaitActivation)
+3 -3
View File
@@ -24,8 +24,8 @@ export const Plugin = define({
id: "opencode.skill",
effect: Effect.fn(function* (ctx) {
const reportContent = yield* reportContentWithDiagnostics(ctx.app)
yield* ctx.skill.transform((draft) => {
draft.add(
yield* ctx.skill.transform((editor) => {
editor.add(
Skill.Info.make({
id: Skill.ID.make("opencode"),
name: Skill.Name.make("OpenCode"),
@@ -34,7 +34,7 @@ export const Plugin = define({
content: OpencodeContent,
}),
)
draft.add(
editor.add(
Skill.Info.make({
id: Skill.ID.make("report"),
name: Skill.Name.make("Report"),
+2 -21
View File
@@ -22,31 +22,12 @@ export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* entrypoint(fs, entry.target)
if (entry.type === "directory") return Option.some(entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* entrypoint(fs, entry.target)
if (yield* fs.isDir(entry.target)) return Option.some(entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
export function entrypoint(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
return yield* Effect.findFirst(
["index.ts", "index.js"].map((entry) => path.join(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
@@ -14,20 +14,6 @@ Do not spawn subagents unless the user or applicable AGENTS.md/skill instruction
Do not revert, reset, or discard changes you did not make. Never run destructive commands such as `git reset --hard`, `git checkout --`, or recursive deletes on broad paths unless the user clearly asked for that operation; if the target or scope is unclear, ask first. Prefer non-interactive git commands.
# Autonomy and persistence
# Autonomy
Adapt accordingly based on the users request type. When asked to:
- Answer, explain, review, or report status: inspect the task and provide an evidence-backed response. These requests do not authorize edits, PR changes, or other mutations unless the user also asks for a change. Reversible, non-mutating diagnostic checks are allowed when relevant.
- Diagnose: determine the cause and explain it. Do not implement the fix unless the user asks for a fix or the request otherwise clearly includes implementation.
- Change or build: implement the requested change, verify it in proportion to risk, and hand off the completed result.
Do not infer authorization for a materially different action than the users request. Act without asking when the action makes no changes, or when it is a normal step in the requested work.
Being told to finish or keep going requires persistence toward the outcome, but does not broaden the set of authorized actions.
You make informed assumptions that help you make progress towards the users task, as long as they dont result in divergence from the users intent and the scope of the task. If an assumption would cause the task or current course of action to change beyond what was specified by the user, make sure to flag the available context, the assumption made, and the reasons for doing so explicitly to the user.
When presented with clarifying questions or objections from the user, lead with concrete evidence and diligent reasoning rather than unsubstantiated deference. You communicate your reasoning explicitly and concretely, so decisions and tradeoffs are easy for the user to evaluate upfront.
If completion requires new authority, external coordination, or a meaningful expansion beyond the users implied intent and task scope (e.g. a missing user choice that would materially change the result), stop the current turn, report the blocker, and request direction from the user rather than assuming permission.
Do not infer authorization for work beyond the user's request. Assumptions that help you make progress are fine as long as they stay within the user's intent and the scope of the task.
+2 -2
View File
@@ -30,8 +30,8 @@ export const Plugin = define({
worktree: location.project.directory,
})
yield* ctx.vcs.transform((draft) => {
draft.add({
yield* ctx.vcs.transform((editor) => {
editor.add({
id: "git",
name: "Git",
info: () => adapter.info(),
+2 -2
View File
@@ -35,8 +35,8 @@ export const Plugin = define({
worktree: location.project.directory,
})
yield* ctx.vcs.transform((draft) => {
draft.add({
yield* ctx.vcs.transform((editor) => {
editor.add({
id: "hg",
name: "Mercurial",
info: () => adapter.info(),
+6 -6
View File
@@ -26,19 +26,19 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.exa",
effect: Effect.fn("WebSearchExa.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("exa", (integration) => (integration.name = "Exa"))
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.update("exa", (integration) => (integration.name = "Exa"))
editor.method.update({
integrationID: "exa",
method: { type: "key" },
})
draft.method.update({
editor.method.update({
integrationID: "exa",
method: { type: "env", names: ["EXA_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "exa",
name: "Exa",
execute: (input) =>
@@ -37,19 +37,19 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.firecrawl",
effect: Effect.fn("WebSearchFirecrawl.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.update("firecrawl", (integration) => (integration.name = "Firecrawl"))
editor.method.update({
integrationID: "firecrawl",
method: { type: "key" },
})
draft.method.update({
editor.method.update({
integrationID: "firecrawl",
method: { type: "env", names: ["FIRECRAWL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "firecrawl",
name: "Firecrawl",
execute: (input) =>
@@ -52,19 +52,19 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.parallel",
effect: Effect.fn("WebSearchParallel.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("parallel", (integration) => (integration.name = "Parallel"))
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.update("parallel", (integration) => (integration.name = "Parallel"))
editor.method.update({
integrationID: "parallel",
method: { type: "key" },
})
draft.method.update({
editor.method.update({
integrationID: "parallel",
method: { type: "env", names: ["PARALLEL_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "parallel",
name: "Parallel",
execute: (input) =>
+6 -6
View File
@@ -28,19 +28,19 @@ export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.tavily",
effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("tavily", (integration) => (integration.name = "Tavily"))
draft.method.update({
yield* ctx.integration.transform((editor) => {
editor.update("tavily", (integration) => (integration.name = "Tavily"))
editor.method.update({
integrationID: "tavily",
method: { type: "key" },
})
draft.method.update({
editor.method.update({
integrationID: "tavily",
method: { type: "env", names: ["TAVILY_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
yield* ctx.websearch.transform((editor) => {
editor.add({
id: "tavily",
name: "Tavily",
execute: (input) =>
+1 -2
View File
@@ -95,8 +95,7 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (specifie
const root = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? specifier)
const installed = yield* npm.add(root).pipe(Effect.mapError((cause) => new LoadError({ package: specifier, cause })))
const entrypoint = yield* Effect.try({
try: () =>
specifier === root && installed.entrypoint ? installed.entrypoint : resolveModule(specifier, installed.directory),
try: () => resolveModule(specifier, installed.directory),
catch: (cause) => new LoadError({ package: specifier, cause }),
})
return yield* importPackage(specifier, entrypoint)
+12 -11
View File
@@ -28,13 +28,13 @@ type Data = {
sources: Map<string, Source>
}
type Draft = {
type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<Info[]>
}
@@ -91,18 +91,19 @@ const layer = Layer.effect(
{ concurrency: 4, discard: true },
)
})
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "reference",
initial: () => ({ sources: new Map() }),
draft: (draft) => ({
add: (name, source) => draft.sources.set(name, source),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources),
}),
notify: Effect.gen(function* () {
yield* refresh().pipe(Effect.forkIn(scope))
yield* bus.publish(Reference.Event.Updated, {})
editor: (editor) => ({
add: (name, source) => editor.sources.set(name, source),
remove: (name) => editor.sources.delete(name),
list: () => Array.from(editor.sources),
}),
notify: () =>
Effect.gen(function* () {
yield* refresh().pipe(Effect.forkIn(scope))
yield* bus.publish(Reference.Event.Updated, {})
}),
})
// Check independently of session activity; the shared cache throttles Git work daily.
+2 -1
View File
@@ -6,6 +6,7 @@ import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { Effect } from "effect"
import { Command } from "../command.js"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin/service.js"
export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
session: Session.Info
@@ -17,7 +18,7 @@ export const execute = Effect.fn("SessionCommand.execute")(function* (input: {
delivery?: SessionInbox.Delivery
}) {
const instances = yield* Instance.Service
const commands = yield* Command.Service.pipe(instances.provide(input.session))
const commands = yield* Plugin.awaitActivation.pipe(Effect.andThen(Command.Service), instances.provide(input.session))
yield* commands.execute({
name: input.command,
invocation: {
+15 -35
View File
@@ -56,7 +56,7 @@ List files and directories that are important to the conversation. Include paths
- \`[exact path]\`: [why it matters]
## Additional Context
- [important facts, assumptions, unresolved questions, exact references, or other context needed to continue that does not fit above; when uncertain, preserve it here, or "(none)"]
- [facts or references needed to continue the work that are not captured above; omit this section if none]
</template>`
const SUMMARY_RULES = `Rules:
@@ -75,7 +75,7 @@ export type Settings = {
tokens: number
}
export type Draft = {
export type Editor = {
configure: (settings: Partial<Settings>) => void
}
@@ -116,7 +116,7 @@ export type Outcome =
| Pick<SessionMessage.CompactionCompleted, "status">
| Pick<SessionMessage.CompactionFailed, "status" | "error">
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly enabled: () => boolean
readonly required: (input: RequiredInput) => boolean
readonly compact: (input: AutoInput) => Effect.Effect<Outcome>
@@ -126,10 +126,8 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const estimateTokens = (input: RequiredInput) => {
const through = compactedThrough(input.messages)
const index = input.messages.findLastIndex(
(message, index) =>
index > through &&
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
@@ -257,16 +255,9 @@ const serializeRecentMessage = (message: SessionMessage.Info) => {
const splitHistory = (messages: readonly SessionMessage.Info[], keepTokens: number) => {
const tailStart = findTailStart(messages, keepTokens)
if (tailStart === undefined) return
// Messages after a delivered manual checkpoint already replay without a retained range.
const end = messages.findLastIndex((message) => message.type === "compaction" && message.status === "running")
const tail = messages
.slice(tailStart, end < 0 ? undefined : end)
.filter((message) => message.type !== "compaction" && message.type !== "system")
const first = tail.at(0)
const last = tail.at(-1)
return {
messages: messages.slice(0, tailStart),
retained: first && last ? { from: first.id, through: last.id } : undefined,
recent: messages.slice(tailStart).map(serializeRecentMessage).filter(Boolean).join("\n\n"),
}
}
@@ -300,18 +291,7 @@ const findTailStart = (messages: readonly SessionMessage.Info[], keepTokens: num
message.type === "compaction" && message.status === "completed",
)
// Without an older retained tail to summarize, summarize everything and retain nothing.
return previousSummary?.recent || previousSummary?.retained ? conversation[0].index : messages.length
}
// Usage on retained assistants still describes the larger, pre-compaction request.
const compactedThrough = (messages: readonly SessionMessage.Info[]) => {
const index = messages.findLastIndex((message) => message.type === "compaction" && message.status === "completed")
const checkpoint = messages[index]
if (checkpoint?.type !== "compaction" || checkpoint.status !== "completed" || !checkpoint.retained) return index
return Math.max(
index,
messages.findIndex((message) => message.id === checkpoint.retained?.through),
)
return previousSummary?.recent ? conversation[0].index : messages.length
}
export const buildPrompt = (update: boolean) => {
@@ -346,14 +326,14 @@ export const layer = Layer.effect(
const bus = yield* Bus.Service
const llm = yield* LLMClient.Service
const state = State.create<Settings, Draft>({
const state = State.create<Settings, Editor>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
draft: (draft) => ({
editor: (editor) => ({
configure: (settings) => {
if (settings.auto !== undefined) draft.auto = settings.auto
if (settings.buffer !== undefined) draft.buffer = settings.buffer
if (settings.tokens !== undefined) draft.tokens = settings.tokens
if (settings.auto !== undefined) editor.auto = settings.auto
if (settings.buffer !== undefined) editor.buffer = settings.buffer
if (settings.tokens !== undefined) editor.tokens = settings.tokens
},
}),
})
@@ -380,7 +360,7 @@ export const layer = Layer.effect(
yield* bus.publish(SessionEvent.Compaction.Started, {
sessionID: context.session.id,
reason: input.reason,
recent: "",
recent: history.recent,
inputID: input.inputID,
})
@@ -503,8 +483,7 @@ export const layer = Layer.effect(
sessionID: context.session.id,
reason: input.reason,
text: summary,
recent: "",
retained: history.retained,
recent: history.recent,
})
return { status: "completed" as const }
})
@@ -513,7 +492,8 @@ export const layer = Layer.effect(
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
if (input.messages.length > 0 && compactedThrough(input.messages) === input.messages.length - 1) return false
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
+2
View File
@@ -4,6 +4,7 @@ import { LLMClient, Message, type AIError } from "@opencode-ai/ai"
import { Effect } from "effect"
import { Database } from "../database/database.js"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin/service.js"
import type { Instructions } from "../instructions/index.js"
import { SessionContext } from "./context.js"
import type { AgentNotFoundError } from "./error.js"
@@ -24,6 +25,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
const llm = yield* LLMClient.Service
return yield* Effect.gen(function* () {
yield* Plugin.awaitActivation
const context = yield* SessionContext.Service
const selection = yield* context.select(input.session.id)
const model = yield* context.resolveModel(selection.session)
+5 -41
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, inArray, lte, notInArray, or, sql } from "drizzle-orm"
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -14,7 +14,7 @@ const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
@@ -42,56 +42,20 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const checkpoint = compaction ? yield* decodeMessageRow(compaction) : undefined
const retained =
checkpoint?.type === "compaction" && checkpoint.status === "completed" ? checkpoint.retained : undefined
const boundaries = retained
? yield* db
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
inArray(SessionMessageTable.id, [retained.from, retained.through]),
),
)
.all()
.pipe(Effect.orDie)
: []
const from = boundaries.find((row) => row.id === retained?.from)
const through = boundaries.find((row) => row.id === retained?.through)
if (retained && (!from || !through || from.seq > through.seq))
return yield* Effect.die(new Error(`Compaction retained history is unavailable: ${sessionID}`))
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction
? or(
gte(SessionMessageTable.seq, compaction.seq),
from && through
? and(
gte(SessionMessageTable.seq, from.seq),
lte(SessionMessageTable.seq, through.seq),
// Instruction updates are folded into the new baseline; older checkpoints are replaced.
notInArray(SessionMessageTable.type, ["system", "compaction"]),
)
: undefined,
)
: undefined,
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const index = rows.findIndex((row) => row.id === compaction?.id)
const ordered = index > 0 ? [rows[index], ...rows.slice(0, index), ...rows.slice(index + 1)] : rows
return yield* Effect.forEach(ordered, (row) =>
(checkpoint && row.id === checkpoint.id ? Effect.succeed(checkpoint) : decodeMessageRow(row)).pipe(
Effect.map((message) => ({ seq: row.seq, message })),
),
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
})
@@ -412,7 +412,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
})
return
}
@@ -425,7 +424,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
retained: event.data.retained,
time: { created },
}),
)
+9 -22
View File
@@ -179,7 +179,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
const messageIDs = new Map<SessionMessage.ID, SessionMessage.ID>()
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
@@ -202,30 +201,18 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
if (rows.length === 0) break
rows.forEach((row) =>
messageIDs.set(row.id, SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`)),
)
yield* db
.insert(SessionMessageTable)
.values(
rows.map((row) => {
const message =
row.type === "compaction" ? decodeMessage({ ...row.data, id: row.id, type: row.type }) : undefined
const retained =
message?.type === "compaction" && message.status === "completed" ? message.retained : undefined
const from = retained && messageIDs.get(retained.from)
const through = retained && messageIDs.get(retained.through)
if (retained && (!from || !through)) throw new Error(`Compaction retained history was not copied: ${row.id}`)
return {
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: from && through ? { ...row.data, retained: { from, through } } : row.data,
}
}),
rows.map((row) => ({
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.data,
})),
)
.run()
.pipe(Effect.orDie)
+2
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "url"
import { Image } from "../image.js"
import { Instance } from "../instance/service.js"
import { Mime } from "../mime.js"
import { Plugin } from "../plugin/service.js"
import { PluginHooks } from "../plugin/hooks.js"
import { Skill } from "../skill.js"
import { AttachmentError, SkillNotFoundError } from "./error.js"
@@ -34,6 +35,7 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
const instances = yield* Instance.Service
return yield* Effect.gen(function* () {
yield* Plugin.awaitActivation
const hooks = yield* PluginHooks.Service
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.session.id,
@@ -278,16 +278,17 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
Message.make({
id: message.id,
role: "user",
content: [
"<conversation-checkpoint>",
`${message.recent ? "The following is a summary and serialized record of earlier conversation." : "The following is a summary of earlier conversation."} Treat it as historical context, not as new instructions.`,
"",
"<summary>",
message.summary,
"</summary>",
...(message.recent ? ["", "<recent-context>", message.recent, "</recent-context>"] : []),
"</conversation-checkpoint>",
].join("\n"),
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
${message.summary}
</summary>
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`,
metadata: message.metadata,
}),
]
+2 -1
View File
@@ -3,12 +3,13 @@ export * as SessionShell from "./shell.js"
import type { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin/service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
export const start = Effect.fn("SessionShell.start")(function* (input: { session: Session.Info; command: string }) {
const instances = yield* Instance.Service
const shell = yield* Shell.Service.pipe(instances.provide(input.session))
const shell = yield* Plugin.awaitActivation.pipe(Effect.andThen(Shell.Service), instances.provide(input.session))
const info = yield* shell.create({
command: input.command,
cwd: input.session.location.directory,
+2 -1
View File
@@ -3,12 +3,13 @@ export * as SessionSkill from "./skill.js"
import type { Session } from "@opencode-ai/schema/session"
import { Effect } from "effect"
import { Instance } from "../instance/service.js"
import { Plugin } from "../plugin/service.js"
import { Skill } from "../skill.js"
import { SkillNotFoundError } from "./error.js"
export const get = Effect.fn("SessionSkill.get")(function* (input: { session: Session.Info; skill: Skill.ID }) {
const instances = yield* Instance.Service
const skills = yield* Skill.Service.pipe(instances.provide(input.session))
const skills = yield* Plugin.awaitActivation.pipe(Effect.andThen(Skill.Service), instances.provide(input.session))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
return skill
+5 -5
View File
@@ -37,7 +37,7 @@ type Data = {
shell?: string
}
export type Draft = {
export type Editor = {
configure: (shell: string) => void
}
@@ -45,7 +45,7 @@ export type ResolveInput = {
priority: "config" | "compat"
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly resolve: (input: ResolveInput) => Effect.Effect<string>
}
@@ -201,12 +201,12 @@ const layer = (options?: Options) =>
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "shell-select",
initial: () => ({}),
draft: (draft) => ({
editor: (editor) => ({
configure: (shell) => {
draft.shell = shell
editor.shell = shell
},
}),
})
+9 -9
View File
@@ -72,14 +72,14 @@ export type Data = {
skills: Map<ID, Types.DeepMutable<Info>>
}
export type Draft = {
export type Editor = {
list: () => readonly Types.DeepMutable<Info>[]
add: (skill: Info) => void
update: (id: string, update: (skill: Types.DeepMutable<Info>) => void) => void
remove: (id: string) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly list: () => Effect.Effect<Info[]>
}
@@ -91,25 +91,25 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const state = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "skill",
initial: () => ({ skills: new Map() }),
draft: (draft) => ({
list: () => Array.from(draft.skills.values()),
editor: (editor) => ({
list: () => Array.from(editor.skills.values()),
add: (skill) => {
draft.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
editor.skills.set(skill.id, { ...skill } as Types.DeepMutable<Info>)
},
update: (id, update) => {
const current = draft.skills.get(ID.make(id))
const current = editor.skills.get(ID.make(id))
if (!current) return
update(current)
current.id = ID.make(id)
},
remove: (id) => {
draft.skills.delete(ID.make(id))
editor.skills.delete(ID.make(id))
},
}),
notify: bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
})
return Service.of({
+5 -5
View File
@@ -36,11 +36,11 @@ export interface RestoreInput {
readonly files: ReadonlyMap<RelativePath, ID>
}
export type Draft = {
export type Editor = {
configure: (enabled: boolean) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
/**
* Capture the current Location-scoped filesystem state as a content-addressed
* tree. Returns `undefined` when snapshots are disabled, unsupported, or the
@@ -77,12 +77,12 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
const lifetime = yield* Scope.Scope
const state = State.create<{ enabled: boolean }, Draft>({
const state = State.create<{ enabled: boolean }, Editor>({
name: "snapshot",
initial: () => ({ enabled: true }),
draft: (draft) => ({
editor: (editor) => ({
configure: (enabled) => {
draft.enabled = enabled
editor.enabled = enabled
},
}),
})
+42 -69
View File
@@ -1,15 +1,15 @@
export * as State from "./state.js"
import { Cause, Clock, Context, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { Cause, Context, Effect, Exit, Fiber, Scope } from "effect"
/**
* A synchronous, replayable edit to the current domain state.
*
* Domain drafts expose readable and writable state while preserving concise
* Domain editors expose readable and writable state while preserving concise
* plugin/config code. Transforms synchronously rebuild derived state.
*/
type TransformCallback<DraftApi> = (draft: DraftApi) => void
export type MakeDraft<State, DraftApi> = (state: State) => DraftApi
type TransformCallback<Editor> = (editor: Editor) => void
export type MakeEditor<State, Editor> = (state: State) => Editor
export interface Registration {
readonly dispose: Effect.Effect<void>
@@ -19,15 +19,15 @@ export interface Registration {
* Registers a scoped transform. Reads rebuild by applying every registered transform in order.
* Closing the owning Scope removes the transform and invalidates the current value.
*/
export type Transform<DraftApi> = (
transform: TransformCallback<DraftApi>,
export type Transform<Editor> = (
transform: TransformCallback<Editor>,
) => Effect.Effect<Registration, never, Scope.Scope>
/** Invalidates the current value after captured inputs change and coalesces notifications. */
/** Invalidates the current value after captured inputs change and notifies like a registration would. */
export type Reload = () => Effect.Effect<void>
export interface Transformable<DraftApi> {
readonly transform: Transform<DraftApi>
export interface Transformable<Editor> {
readonly transform: Transform<Editor>
readonly reload: Reload
}
@@ -40,17 +40,12 @@ type Batch = {
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
defaultValue: () => undefined,
})
const reloadDebounce = 500
/** Coalesces notifications until the effect completes. Reads inside stay fresh; nothing is rolled back. */
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>) {
return run(effect, false)
}
/**
* Runs the effect as shutdown: States changed inside it close permanently and never notify again,
* including debounced reloads already waiting.
*/
/** Runs the effect as shutdown: States changed inside it close permanently and never notify again. */
export function shutdown<A, E, R>(effect: Effect.Effect<A, E, R>) {
return run(effect, true)
}
@@ -73,7 +68,7 @@ function run<A, E, R>(effect: Effect.Effect<A, E, R>, shutdown: boolean) {
}
/**
* A `notify` that runs resource reconciliation in the owning layer's FiberSet and awaits it, so work
* A `notify` body that runs resource reconciliation in the owning layer's FiberSet and awaits it, so work
* queued behind the layer's locks is interrupted with the layer. That interruption is not a failure.
*/
export function reconcile(
@@ -93,21 +88,21 @@ export const inherit = Effect.fnUntraced(function* () {
return <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.provideService(effect, CurrentBatch, batch)
})
export interface Options<State, DraftApi> {
export interface Options<State, Editor> {
readonly name?: string
/** Creates the empty base value for every rebuild. */
readonly initial: () => State
/** Wraps mutable state in a domain-specific draft API. */
readonly draft: MakeDraft<State, DraftApi>
/** Wraps mutable state in a domain-specific editor API. */
readonly editor: MakeEditor<State, Editor>
/**
* Observes current state outside the read path. Batched changes notify at
* batch completion; reloads debounce notifications. Resource reconciliation
* owns its execution scope and coordination.
* Observes the freshly rebuilt value outside the read path. Every registration, disposal, or
* reload notifies once it is applied; a batch coalesces them into one notification at its end.
* Resource reconciliation owns its execution scope and coordination.
*/
readonly notify?: Effect.Effect<void>
readonly notify?: (state: State) => Effect.Effect<void>
}
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
export interface Interface<State, Editor> extends Transformable<Editor> {
/**
* Rebuilds synchronously when transforms changed since the last read. Each rebuild produces a new
* value and never touches earlier ones, so callers may retain what they read.
@@ -115,19 +110,17 @@ export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
readonly get: () => State
}
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
export function create<State, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
let state = options.initial()
const transforms: { run: TransformCallback<DraftApi> }[] = []
const transforms: { run: TransformCallback<Editor> }[] = []
let dirty = false
let requestedAt = 0
let closed = false
let pending: Deferred.Deferred<void> | undefined
const get = () => {
if (closed || !dirty) return state
const next = options.initial()
const draft = options.draft(next)
for (const transform of transforms) transform.run(draft)
const editor = options.editor(next)
for (const transform of transforms) transform.run(editor)
// Only a complete fold becomes visible; a throwing callback leaves the previous value and stays dirty.
state = next
dirty = false
@@ -137,46 +130,26 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
// One stable value per State, so a batch's notification Set holds it at most once.
const notify: Effect.Effect<void> = Effect.gen(function* () {
if (closed) return
get()
if (options.notify) yield* options.notify
const value = get()
if (options.notify) yield* options.notify(value)
}).pipe(Effect.withSpan("State.notify"))
const changed = (debounce: boolean) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (batch.shutdown) {
closed = true
return
}
batch.notifications.add(notify)
const changed = Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (closed) return
dirty = true
const batch = yield* CurrentBatch
if (batch?.active) {
if (batch.shutdown) {
closed = true
return
}
if (!debounce) {
yield* restore(notify)
return
}
const clock = yield* Clock.Clock
requestedAt = clock.currentTimeMillisUnsafe()
if (pending) return yield* restore(Deferred.await(pending))
const done = Deferred.makeUnsafe<void>()
pending = done
yield* Effect.gen(function* () {
do {
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
if (remaining > 0) yield* Effect.sleep(remaining)
} while (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce)
// Observers can request and await another reload without joining their own notification.
pending = undefined
yield* notify.pipe(Deferred.into(done))
}).pipe(Effect.forkDetach)
yield* restore(Deferred.await(done))
}),
)
batch.notifications.add(notify)
return
}
yield* restore(notify)
}),
)
return {
get,
@@ -191,16 +164,16 @@ export function create<State, DraftApi>(options: Options<State, DraftApi>): Inte
const index = transforms.indexOf(transform)
if (index < 0) return Effect.void
transforms.splice(index, 1)
return changed(false)
return changed
}),
)
transforms.push(transform)
yield* Scope.addFinalizer(scope, dispose)
yield* changed(false)
yield* changed
return { dispose }
}),
)
}),
reload: () => changed(true),
reload: () => changed,
}
}
+6 -6
View File
@@ -22,11 +22,11 @@ type Limits = {
maxBytes: number
}
export type Draft = {
export type Editor = {
configure: (limits: Partial<Limits>) => void
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly truncate: (result: Result) => Effect.Effect<Result>
readonly cleanup: () => Effect.Effect<void>
}
@@ -51,13 +51,13 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const directory = path.join(global.data, DIRECTORY)
const state = State.create<Limits, Draft>({
const state = State.create<Limits, Editor>({
name: "tool-output",
initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }),
draft: (draft) => ({
editor: (editor) => ({
configure: (limits) => {
if (limits.maxLines !== undefined) draft.maxLines = limits.maxLines
if (limits.maxBytes !== undefined) draft.maxBytes = limits.maxBytes
if (limits.maxLines !== undefined) editor.maxLines = limits.maxLines
if (limits.maxBytes !== undefined) editor.maxBytes = limits.maxBytes
},
}),
})
+16 -18
View File
@@ -23,7 +23,7 @@ export class RegistrationError extends Schema.TaggedError<RegistrationError>()("
message: Schema.String,
}) {}
export interface Draft {
export interface Editor {
readonly list: () => readonly (Tool.Info & { readonly id: string })[]
readonly get: (id: string) => (Tool.Info & { readonly id: string }) | undefined
readonly namespace: (namespace: Tool.Namespace) => void
@@ -38,7 +38,7 @@ type Data = {
errors: { kind: "tool" | "namespace"; name: string; namespace?: string; error: RegistrationError }[]
}
export interface Interface extends State.Transformable<Draft> {
export interface Interface extends State.Transformable<Editor> {
readonly snapshot: (permissions?: Permission.Ruleset) => Effect.Effect<Snapshot>
}
@@ -149,35 +149,35 @@ const layer = Layer.effect(
}
})
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
const state = State.create<Data, Editor>({
name: "tool",
initial: () => ({
tools: new Map(),
namespaces: new Map(),
errors: [],
}),
draft: (draft) => ({
list: () => Array.from(draft.tools.values()),
get: (id) => draft.tools.get(id),
editor: (editor) => ({
list: () => Array.from(editor.tools.values()),
get: (id) => editor.tools.get(id),
namespace: (namespace) => {
const error = namespaceError(namespace.name)
if (error) {
draft.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error })
editor.errors.push({ kind: "namespace", name: namespace.name, namespace: namespace.name, error })
return
}
draft.namespaces.set(namespace.name, { ...namespace })
editor.namespaces.set(namespace.name, { ...namespace })
},
add: (tool) => {
const error = registrationError(tool)
if (error) {
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
editor.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
return
}
const id = effectiveName(tool)
draft.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
editor.tools.set(id, { ...tool, id, options: tool.options && { ...tool.options } })
},
update: (id, update) => {
const current = draft.tools.get(id)
const current = editor.tools.get(id)
if (!current) return
const tool = { ...current, options: current.options && { ...current.options } }
update(tool)
@@ -187,19 +187,18 @@ const layer = Layer.effect(
tool.options = { ...tool.options, namespace: current.options?.namespace }
const error = registrationError(tool)
if (error) {
draft.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
editor.errors.push({ kind: "tool", name: tool.name, namespace: tool.options?.namespace, error })
return
}
draft.tools.set(id, tool)
editor.tools.set(id, tool)
},
remove: (id) => {
draft.tools.delete(id)
editor.tools.delete(id)
},
}),
// Read errors when the notification runs, not when the State is created.
notify: Effect.suspend(() =>
notify: (value) =>
Effect.forEach(
state.get().errors,
value.errors,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
name,
@@ -208,7 +207,6 @@ const layer = Layer.effect(
}),
{ discard: true },
),
),
})
return Service.of({
+4 -4
View File
@@ -4,7 +4,7 @@
## Representations
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
- Plugin authors get schema-derived input types at the `ToolEditor.add` boundary through `Tool`.
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
@@ -30,11 +30,11 @@ Leaves own resolution, permission, and side-effect ordering. Translate only expe
## Registration
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the draft. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Built-ins, plugins, and MCP install tools through `Tool.Service.transform`, adding complete tool objects to the editor. A tool may provide a namespace, which flattens direct model names to `<namespace>_<tool>`, and defaults into CodeMode (`codemode` defaults true; `codemode: false` keeps the tool on the provider's native tool list).
Namespace descriptions are registered once through `draft.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description.
Namespace descriptions are registered once through `editor.namespace(...)`. Tool options continue to reference the namespace by string name; an unregistered namespace remains valid and simply has no namespace description.
The service uses shared `State` to replay synchronous transforms in registration order against a fresh draft. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
The service uses shared `State` to replay synchronous transforms in registration order against a fresh editor. `Tool.Service.reload()` rebuilds from captured source data without changing registration precedence. Registrations are scoped and return a real, idempotent `dispose` Effect:
- The latest valid active registration for the same effective name wins.
- `update` and `remove` target effective names and do nothing for missing tools. Updates preserve the name and namespace; invalid updates leave the previous definition intact. Creating a tool requires `add`.
+13 -5
View File
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
import { ToolFailure } from "@opencode-ai/ai"
import { McpEvent } from "@opencode-ai/schema/mcp-event"
import { Context, Effect, Fiber, type JsonSchema, Layer, Semaphore, Stream } from "effect"
import { Context, Effect, Fiber, type JsonSchema, Layer, PubSub, Semaphore, Stream } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
@@ -38,10 +38,10 @@ export const layer = Layer.effect(
.withPermit(
Effect.gen(function* () {
discovered = yield* mcp.tools()
yield* tools.transform((draft) => {
yield* tools.transform((editor) => {
for (const tool of discovered) {
const schema = (tool.inputSchema ?? {}) as JsonSchema.JsonSchema
draft.add({
editor.add({
name: tool.name,
options: { namespace: namespace(tool.server), codemode: tool.codemode !== false },
description: tool.description ?? "",
@@ -123,9 +123,17 @@ export const layer = Layer.effect(
}),
)
// Servers announce tools in bursts and each read loads the whole catalog, so settle and refresh
// once. The bus subscription stays eager; only the already-open sliding subscription is debounced.
const changes = yield* PubSub.sliding<void>(1)
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
// Each read loads the whole catalog, so queued notifications need only one refresh.
Stream.runForEachArray(() => reconcile),
Stream.runForEach(() => PubSub.publish(changes, undefined)),
Effect.forkScoped({ startImmediately: true }),
)
const updates = yield* PubSub.subscribe(changes)
yield* Stream.fromSubscription(updates).pipe(
Stream.debounce("100 millis"),
Stream.runForEach(() => reconcile),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
+2 -2
View File
@@ -117,8 +117,8 @@ export const Plugin = {
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false, permission: "edit" },
description:
+2 -2
View File
@@ -52,8 +52,8 @@ export const Plugin = {
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description: 'Search file paths using a glob pattern (examples: "**/*.ts", "src/**/*.tsx").',
+2 -2
View File
@@ -71,8 +71,8 @@ export const Plugin = {
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description:
+52
View File
@@ -0,0 +1,52 @@
export * as OpenCodeTools from "./opencode.js"
import { ToolFailure } from "@opencode-ai/ai"
import type { Context } from "@opencode-ai/plugin/effect/plugin"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { Effect, Schema } from "effect"
export const MoveInput = Schema.Struct({
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to move the current session." }),
directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({
description: "Destination directory, relative to the target session's directory or absolute. Supports ~.",
}),
})
const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePath })
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
yield* ctx.tool
.transform((draft) => {
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
draft.add({
name: "session_move",
description:
"Move a session to another directory, or omit sessionID to move the current session. The current session moves at the next safe boundary; do not run destination-dependent tools in the same execute call.",
input: MoveInput,
output: MoveOutput,
options: { namespace: "opencode", codemode: true, pinned: true },
execute: (input, context) =>
Effect.gen(function* () {
const sessionID = input.sessionID ?? context.sessionID
yield* ctx.session.move({
sessionID,
directory: input.directory,
delivery: "steer",
})
return {
output: { sessionID, directory: input.directory },
content: `Moved session ${sessionID} to ${input.directory}.`,
}
}).pipe(
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to move session to ${input.directory}`, error }),
),
),
})
})
.pipe(Effect.orDie)
}),
}
+2 -2
View File
@@ -74,8 +74,8 @@ export const Plugin = {
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false, permission: "edit" },
description: DESCRIPTION,
+2 -2
View File
@@ -52,8 +52,8 @@ export const Plugin = {
const permission = yield* Permission.Service
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description,
+2 -2
View File
@@ -38,8 +38,8 @@ export const Plugin = {
const location = yield* Location.Service
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description:
+2 -2
View File
@@ -196,8 +196,8 @@ export const Plugin = {
)
yield* ctx.tool
.transform((draft) =>
draft.add({
.transform((editor) =>
editor.add({
name,
options: { codemode: false },
description: description(),

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