Compare commits

...
Author SHA1 Message Date
Kit Langton e8c895a940 fix(core): normalize RPC handler failures 2026-09-02 21:14:38 -04:00
Kit Langton c992716523 fix(core): activate the initial plugin generation without the reload debounce (#46922) 2026-09-03 01:02:38 +00:00
Kit Langton e402600d92 test(core): keep supervisor reload tests offline (#46939) 2026-09-02 21:02:16 -04:00
opencode-agent[bot]andHona 1e4e9c5d85 fix(cli): disable bytecode until Bun 1.4.1 (#46933)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-03 10:43:09 +10:00
Kit Langton f34b74b1dc test(core): refuse network in the test harness (#46908) 2026-09-02 20:41:29 -04:00
Kit Langton b257d476c9 refactor(core): preserve normalized tool results (#46927) 2026-09-02 20:39:55 -04:00
Kit Langton 5e38101fba refactor(client): derive session inputs from pending items (#46926) 2026-09-02 20:24:35 -04:00
mayamikaandClaude Opus 5 4beaffbda9 fix(app): show review diffs for non-git VCS backends (#46684)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:04:24 +10:00
opencode-agent[bot]andBrendonovich 21bbcc33a1 fix(app): restore uniform new session tab width (#46919)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-03 08:01:11 +08:00
Kit Langton 0b1dbaf621 refactor(client): edit streamed message targets directly (#46924) 2026-09-02 20:00:25 -04:00
Dax Raad 0da51e9274 fix(updates): retry transient artifact publication failures 2026-09-02 19:30:11 -04:00
Dax dea1f9cb7b fix(core): update plugin reload npm fixture (#46918) 2026-09-02 19:12:00 -04:00
Dax 969055918f feat(cli): use endpoint-provided update packages (#46910) 2026-09-02 23:01:27 +00:00
Kit Langton f269025416 fix(core): keep plugin activation stable across failures and refreshes (#46899) 2026-09-02 18:56:23 -04:00
Dax Raad 4643cacb4a fix(cli): retry Windows service smoke cleanup 2026-09-02 18:55:26 -04: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
James Long 33dd4e3ba8 feat(cli): apply managed updates when idle and wire up ui (#46485) 2026-09-02 12:08:41 -04:00
James Long 955fcad647 fix(tui): remove link from background subagent completion notice (#46838) 2026-09-02 12:06:51 -04:00
288 changed files with 5463 additions and 3049 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:
+2 -4
View File
@@ -575,6 +575,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 +586,7 @@
"@opentui/solid": "catalog:",
"@tsconfig/bun": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "catalog:",
@@ -971,7 +973,6 @@
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
@@ -1123,7 +1124,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",
@@ -5171,8 +5171,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",
@@ -9,30 +9,42 @@ const sessionB = session("ses_tab_b", "Tab B session")
const sessionC = session("ses_tab_c", "Tab C session")
const unresolvedSessionID = "ses_tab_unresolved"
test("new session tab hugs its content", async ({ page }) => {
test("new session tab matches neighboring session widths", async ({ page }, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, sessionID, directory }) => {
({ server, sessionA, sessionB, directory }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "session", server, sessionId: sessionA },
{ type: "draft", server, directory, draftID: "draft_tab_width" },
{ type: "session", server, sessionId: sessionB },
]),
)
},
{ server, sessionID: sessionA.id, directory: sessionA.directory },
{ server, sessionA: sessionA.id, sessionB: sessionB.id, directory: sessionA.directory },
)
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.goto(href)
const sessionTab = page.locator(`[data-titlebar-tab-slot]:has(a[href="${href}"])`)
const draftTab = page.locator('[data-titlebar-tab-slot]:has(a[href^="/new-session?draftId="])')
await expect(draftTab).toContainText("New session")
const width = (await draftTab.boundingBox())?.width ?? 0
expect(width).toBeGreaterThan(100)
expect(width).toBeLessThan((await sessionTab.boundingBox())?.width ?? 0)
const tabs = page.locator("[data-titlebar-tab-slot]")
await expect(tabs.locator("[data-titlebar-tab-title]")).toHaveText([sessionA.title, "New session", sessionB.title])
await testInfo.attach("new-session-between-tabs", {
body: await page.locator('[data-slot="titlebar-v2"]').screenshot(),
contentType: "image/png",
})
for (const width of [1280, 800]) {
await page.setViewportSize({ width, height: 720 })
await expect
.poll(() =>
tabs.evaluateAll((tabs) => {
const widths = tabs.map((tab) => tab.getBoundingClientRect().width)
return Math.max(...widths) - Math.min(...widths)
}),
)
.toBeLessThan(1)
}
})
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
@@ -280,9 +292,7 @@ test("appearance experimental settings control vertical tab details", async ({ p
await page.reload()
const href = `/server/${base64Encode(server)}/session/${sessionA.id}`
await page.getByRole("button", { name: "Tabs", exact: true }).click()
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText([
"tab-project",
])
await expect(page.locator('[data-slot="mobile-tabs-drawer"] [data-slot="tab-project"]')).toHaveText(["tab-project"])
await expect(
page
.locator('[data-slot="mobile-tabs-drawer"]')
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { AgentListOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProviderList } from "./utils"
import type { AgentListOutput, ModelListOutput, Project, ProviderListOutput } from "@opencode-ai/client/promise"
import { directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
describe("normalizeAgentList", () => {
test("adapts current agents to the app agent shape", () => {
@@ -85,6 +85,15 @@ describe("normalizeProviderList", () => {
})
})
describe("normalizeProjectInfo", () => {
test("keeps the project VCS backend", () => {
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
})
})
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
@@ -135,6 +135,5 @@ export function normalizeProjectInfo(project: Project | CurrentProject): Project
...project,
worktree,
worktrees: "worktrees" in project ? project.worktrees : [{ directory: worktree }],
vcs: project.vcs === "git" ? "git" : undefined,
}
}
+5 -5
View File
@@ -55,9 +55,9 @@ export function createSessionReview(input: {
const options = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
const project = input.session.project()
if (project?.vcs === "git") list.push("git")
if (project?.vcs) list.push("git")
if (
project?.vcs === "git" &&
project?.vcs &&
vcs()?.branch.current &&
vcs()?.branch.default &&
vcs()?.branch.current !== vcs()?.branch.default
@@ -94,7 +94,7 @@ export function createSessionReview(input: {
const value = vcsMode()
return {
queryKey: [...vcsKey(), value] as const,
enabled: server.connection.status() === "connected" && wantsReview() && input.session.project()?.vcs === "git",
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
@@ -110,7 +110,7 @@ export function createSessionReview(input: {
})
const detailsQuery = createQuery(() => ({
queryKey: [server.scope, "session-details", input.session.workspace.directory()] as const,
enabled: state.detailsOpen && server.connection.status() === "connected" && input.session.project()?.vcs === "git",
enabled: state.detailsOpen && server.connection.status() === "connected" && !!input.session.project()?.vcs,
queryFn: () =>
server.api.vcs
.diff({ location: { directory: input.session.workspace.directory() }, mode: "working" })
@@ -413,7 +413,7 @@ export function createSessionReview(input: {
tab: () => state.mobileTab,
},
mode,
noGit: createMemo(() => !!input.session.project() && input.session.project()?.vcs !== "git"),
noGit: createMemo(() => !!input.session.project() && !input.session.project()?.vcs),
open,
openFile,
options,
+1 -1
View File
@@ -165,7 +165,7 @@ export function SessionScreen(props: { session: SessionModel }) {
baseBranch={
session.shared.data.location.vcs.info({ directory: project().worktree })?.branch.current
}
diffs={project().vcs === "git" ? review.details.diffs() : []}
diffs={project().vcs ? review.details.diffs() : []}
sessionID={session.identity.params.id ?? ""}
moveEligible={composer.workspaceMoveEligible()}
moveDismissed={store.mobileMoveDismissed}
+4 -9
View File
@@ -395,11 +395,8 @@ export function DraftTabItem(props: {
data-active={props.active}
data-dragging={props.dragging}
data-state={props.active || props.pressed ? "pressed" : undefined}
class="group relative flex h-7 min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 whitespace-nowrap"
classList={{
invisible: props.hidden,
"w-full [container-type:inline-size]": props.orientation === "vertical",
}}
class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] px-1.5 [container-type:inline-size] whitespace-nowrap"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== MIDDLE_MOUSE_BUTTON) return
event.preventDefault()
@@ -432,16 +429,14 @@ export function DraftTabItem(props: {
if (props.suppressNavigation) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
classList={{ "flex-1": props.orientation === "vertical", "flex-none pe-10": props.orientation !== "vertical" }}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<span class="flex size-4 shrink-0 items-center justify-center">
<Icon name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
classList={{ "flex-1": props.orientation === "vertical", "flex-none": props.orientation !== "vertical" }}
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
@@ -211,7 +211,7 @@ function DraftTabSlot(props: {
data-orientation={props.orientation}
class="relative flex"
classList={{
"w-max min-w-7 max-w-56 shrink-0": props.orientation === "horizontal",
"w-56 min-w-7 max-w-56 flex-shrink": props.orientation === "horizontal",
"w-full shrink-0": props.orientation === "vertical",
}}
>
+4
View File
@@ -128,6 +128,9 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
external: ["node-gyp"],
format: "esm",
minify: true,
// Bun 1.4.0 cross-compiled bytecode can crash on Windows (oven-sh/bun#40270).
// Re-enable after both the builder and embedded runtime move to Bun 1.4.1.
bytecode: false,
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
splitting: true,
compile: {
@@ -145,6 +148,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_ARTIFACT: `'cli'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
+16 -8
View File
@@ -23,7 +23,13 @@ async function publish(dir: string, name: string, version: string) {
}
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
async function publishDistribution(input: {
root: string
name: string
binary: string
packagePrefix: string
artifact: string
}) {
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
const item = await Bun.file(`${input.root}/${filepath}`).json()
@@ -72,6 +78,13 @@ async function publishDistribution(input: { root: string; name: string; binary:
),
)
await publish(`${input.root}/${input.name}`, input.name, version)
await UpdateArtifact.publish({
channel: Script.channel,
name: input.artifact,
distribution: "npm",
version,
metadata: { package: input.name },
})
}
await publishDistribution({
@@ -79,6 +92,7 @@ await publishDistribution({
name: pkg.name,
binary: "opencode2",
packagePrefix: "@opencode-ai/cli-",
artifact: "cli",
})
if (existsSync("./dist/node")) {
await publishDistribution({
@@ -86,12 +100,6 @@ if (existsSync("./dist/node")) {
name: "opencode-node",
binary: "opencode2-node",
packagePrefix: "@opencode-ai/cli-node-",
artifact: "cli-node",
})
}
await UpdateArtifact.publish({
channel: Script.channel,
name: "cli",
distribution: "npm",
version: Script.version,
metadata: {},
})
+5 -1
View File
@@ -93,7 +93,11 @@ try {
}
const output = await Promise.all(errors)
await fs.rm(root, { recursive: true, force: true })
// Windows can retain directory handles briefly after the service processes exit.
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
console.error("Failed to remove service smoke-test directory", cause)
failure ??= cause
})
if (failure)
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
cause: failure,
@@ -47,7 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.forkScoped)
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -83,13 +83,13 @@ export default Runtime.handler(Commands, (input) =>
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
},
updater: service
? {
apply: (version) => runPromise(updater.apply(version)),
}
: 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 })),
+52 -7
View File
@@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
import { spawn } from "node:child_process"
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
@@ -53,7 +54,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
const global = yield* Global.Service
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
return yield* Effect.scoped(
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
const next = yield* Effect.scoped(
Effect.gen(function* () {
const foreground = options.mode === "default"
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
@@ -64,7 +66,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
serviceOptions !== undefined && port !== undefined
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
: undefined
if (incumbent !== undefined) return
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
const environmentPassword = yield* Env.password
// Keep the lease credential out of the environment inherited by tools.
@@ -161,19 +163,62 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
}),
)
if (server === undefined) return
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
const updater = yield* Updater.Service
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
yield* updater
.monitor({
url,
password,
managed: options.mode === "service",
notify: server.updateAvailable,
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
})
.pipe(Effect.forkScoped)
return yield* options.mode === "service"
? server.shutdown
? Effect.raceFirst(
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
Deferred.await(replacement).pipe(Effect.map(Option.some)),
)
: options.mode === "stdio"
? waitForStdinClose()
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
: Effect.never
}).pipe(Effect.annotateLogs({ role: "server" })),
)
if (Option.isNone(next)) return
yield* spawnReplacement(next.value)
})
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
const options = yield* ServiceConfig.options()
const [command, ...args] = options.command
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
// We do not monitor the replacement after spawn. A managed TUI
// recovers with Service.ensure if startup fails; a future client
// restart signal could coordinate that recovery instead.
yield* Effect.tryPromise({
try: () =>
new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
env: {
...process.env,
...options.env,
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
},
})
child.once("spawn", () => {
child.unref()
resolve()
})
child.once("error", reject)
}),
catch: (cause) => new Error("Failed to start replacement server", { cause }),
})
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
@@ -56,7 +56,7 @@ function managedService(options: EnsureOptions) {
reconnect: () => Service.ensure(reconnectOptions),
restart: () =>
Effect.gen(function* () {
yield* Service.stop(options)
yield* Service.stop({ file: options.file, pty: "handoff" })
yield* Service.ensure(reconnectOptions)
}),
}
+5 -4
View File
@@ -1,4 +1,4 @@
export type Policy = boolean | "notify"
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "upgrade"
const maximumComponent = "9007199254740991"
@@ -6,13 +6,14 @@ const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
if (policy === false) return "none"
if (policy === "disable") return "none"
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
if (policy === "notify") return "notify"
// Major upgrades are never installed automatically.
if (currentVersion.major !== latestVersion.major) return "none"
return policy === "notify" ? "notify" : "upgrade"
if (currentVersion.major !== latestVersion.major) return "notify"
return "upgrade"
}
export function parseReleaseVersion(input: string) {
+33 -25
View File
@@ -3,46 +3,54 @@ import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
})
test("automatically updates patches and minors", () => {
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
})
test("reports patches and minors without automatically installing them", () => {
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("skips when autoupdate is disabled", () => {
expect(action("1.2.3", "1.2.4", false)).toBe("none")
test("skips when updates are disabled", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
test("never automatically updates majors", () => {
expect(action("1.2.3", "2.0.0", true)).toBe("none")
test("reports majors instead of automatically installing them", () => {
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
})
test("reports up-to-date only when versions match", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
})
test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
})
test("preserves strict validity", () => {
@@ -63,21 +71,21 @@ describe("updater", () => {
"0.9007199254740992.0",
"0.0.9007199254740992",
]
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
})
test("handles numeric limits without losing precision", () => {
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
})
test("preserves equality for oversized numeric prerelease identifiers", () => {
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
})
test("rejects versions longer than semver's limit before trimming", () => {
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
})
})
+337 -145
View File
@@ -1,27 +1,156 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { OpenCode } from "@opencode-ai/client"
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly monitor: (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) => Effect.Effect<void>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export type Inspection =
| { readonly action: "none" }
| { readonly action: Exclude<Action, "none">; readonly version: string }
type State =
| { readonly type: "current" }
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
| { readonly type: "ready-to-restart"; readonly version: string }
export interface MonitorInput {
readonly url: string
readonly password: string
readonly managed: boolean
readonly inspect: () => Effect.Effect<Inspection, Error>
readonly install: (version: string) => Effect.Effect<boolean, Error>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
readonly interval?: Duration.Input
readonly notificationThreshold?: Duration.Input
readonly notify: (version: string) => Effect.Effect<void>
}
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
const state = yield* Ref.make<State>({ type: "current" })
const applyLock = yield* Semaphore.make(1)
const client = OpenCode.make({
baseUrl: input.url,
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
})
const applyIfIdle = () =>
applyLock.withPermit(
Effect.gen(function* () {
const pending = yield* Ref.get(state)
if (pending.type !== "available") return
const active = yield* Effect.tryPromise({
try: () => client.session.active(),
catch: (cause) => new Error("Failed to read active sessions", { cause }),
})
if (Object.keys(active).length > 0) return
const latest = yield* input.inspect()
if (latest.action !== "upgrade") {
yield* Ref.set(state, { type: "current" })
return
}
const installed = yield* input
.install(latest.version)
.pipe(
Effect.catch((error) =>
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
),
)
if (!installed) return
const handoff = input.managed
? yield* Effect.tryPromise({
try: () => client.experimental.persistentPty.handoff(),
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
})
: undefined
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
if (handoff) yield* input.restart(handoff.handoff)
}),
)
const checkServer = Effect.gen(function* () {
const result = yield* input.inspect()
if (result.action === "notify") {
yield* input.notify(result.version)
return
}
if (result.action !== "upgrade") {
yield* Ref.update(
state,
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
)
return
}
yield* Ref.update(state, (current): State => {
if (current.type === "ready-to-restart" && current.version === result.version) return current
return {
type: "available",
version: result.version,
availableSince: current.type === "available" ? current.availableSince : Date.now(),
}
})
yield* applyIfIdle()
const pending = yield* Ref.get(state)
if (
pending.type === "available" &&
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
)
yield* input.notify(pending.version)
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
const subscribe = Effect.suspend(() =>
Stream.fromAsyncIterable(
client.event.subscribe(),
(cause) => new Error("Update event stream failed", { cause }),
).pipe(
Stream.runForEach((event) => {
if (event.type === "server.connected") return applyIfIdle()
if (
event.type !== "session.execution.succeeded" &&
event.type !== "session.execution.failed" &&
event.type !== "session.execution.interrupted"
)
return Effect.void
return Effect.tryPromise({
try: () => client.session.wait({ sessionID: event.data.sessionID }),
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
}).pipe(Effect.andThen(applyIfIdle()))
}),
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
),
).pipe(Effect.repeat(Schedule.spaced("1 second")))
return yield* Effect.all(
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
{
concurrency: "unbounded",
discard: true,
},
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
export function decodePolicy(text: string): Policy | undefined {
@@ -29,157 +158,220 @@ export function decodePolicy(text: string): Policy | undefined {
// the location-scoped server configuration graph.
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
const value = input.autoupdate
if (typeof value === "boolean" || value === "notify") return value
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify" || value === "auto") return value
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "auto"
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
const directory = path.dirname(path.dirname(executable))
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
.readFileString(path.join(directory, "package.json"))
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
return manifest.name
}).pipe(Effect.orElseSucceed(() => undefined))
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe(
Effect.map(decodePolicy),
Effect.orElseSucceed(() => undefined),
),
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
fs.readFileString(path.join(global.config, name)).pipe(
Effect.map(decodePolicy),
Effect.orElseSucceed(() => undefined),
),
)
return values.findLast((value) => value !== undefined) ?? "auto"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
.pipe(
Effect.map((result) => ({
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
return values.findLast((value) => value !== undefined) ?? true
})
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
maxOutputBytes: 100_000,
maxErrorBytes: 100_000,
})
.pipe(
Effect.map((result) => ({
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
if (!installedPackage) return
const method = Effect.fnUntraced(function* () {
const binary = path.join(
global.home,
".opencode",
"bin",
process.platform === "win32" ? "opencode2.exe" : "opencode2",
)
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
})
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
{ method: "yarn", command: ["yarn", "global", "list"] },
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
})
const latest = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
fetch(
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
{
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
}),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
return yield* Effect.fail(new Error("Update information did not include a version"))
}
return data.version
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
})
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
})
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
return { package: data.metadata.package, version: data.version }
})
const check = Effect.fn("cli.updater.check")(
function* () {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? ""))
return yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
const policy = yield* readPolicy()
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
const latest = () => release().pipe(Effect.map((data) => data.version))
return yield* Effect.gen(function* () {
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
})
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName = (yield* release()).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
}
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
"install",
"--global",
...(installedPackage && packageName !== installedPackage ? ["--force"] : []),
target,
],
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
return Service.of({ check, method, latest, upgrade })
}),
)
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
yield* Effect.logInfo("update check skipped", {
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
})
return { action: "none" }
}
const policy = yield* readPolicy()
if (policy === "disable") {
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
return { action: "none" }
}
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return { action: "none" }
}
if (next === "notify") {
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return { action: next, version }
}
return { action: next, version }
})
const install = Effect.fnUntraced(function* (version: string) {
const detected = yield* method()
if (!detected) {
yield* Effect.logWarning("automatic update skipped: installation method not found")
return false
}
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
return true
})
const apply = Effect.fn("cli.updater.apply")(function* (version: string) {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const check = Effect.fn("cli.updater.check")(
function* () {
const result = yield* inspect()
if (result.action !== "upgrade") return
yield* install(result.version)
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
readonly url: string
readonly password: string
readonly managed: boolean
readonly notify: (version: string) => Effect.Effect<void>
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
}) {
return yield* monitorServer({ ...input, inspect, install })
})
return Service.of({ check, monitor, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
export * as Updater from "./updater"
export { action, type Action, type Policy } from "./updater-action"
+3 -1
View File
@@ -1,8 +1,10 @@
declare const OPENCODE_VERSION: string
declare const OPENCODE_CHANNEL: string
declare const OPENCODE_ARTIFACT: string
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
const artifact = typeof OPENCODE_ARTIFACT === "string" ? OPENCODE_ARTIFACT : "cli"
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL, artifact as OPENCODE_ARTIFACT }
export const OPENCODE_LOCAL = channel === "local"
+1 -1
View File
@@ -179,7 +179,7 @@ function verifierConfig(llmUrl: string, skills?: string) {
limit: { context: 100_000, output: 10_000 },
}
return {
autoupdate: false,
update: "disable",
model: "test/test-model",
...(skills ? { skills: [skills] } : {}),
providers: {
+2
View File
@@ -13,6 +13,8 @@ await Effect.runPromise(
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
method: () =>
Effect.sync(() => {
record("method")
+29 -4
View File
@@ -1,7 +1,7 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, test } from "bun:test"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
@@ -17,11 +17,29 @@ function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
name = "@opencode-ai/cli",
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const executable = path.join(root, "package", "bin", "opencode")
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
yield* fs.writeFileString(
path.join(root, "package", "package.json"),
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
)
// The updater uses global fetch; scope this replacement to each install test.
yield* Effect.acquireRelease(
Effect.sync(() =>
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
preconnect: fetch.preconnect,
}),
),
),
(request) => Effect.sync(() => request.mockRestore()),
)
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
@@ -37,6 +55,10 @@ function fixture(
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
}),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
@@ -214,9 +236,12 @@ test("Node distribution honors the compile-time CLI name", async () => {
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
const test = yield* fixture(
(command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}),
"opencode-node",
)
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
+107
View File
@@ -0,0 +1,107 @@
import { expect } from "bun:test"
import { Deferred, Effect, Layer, Option } from "effect"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.live("installs and restarts after the final Session settles", () =>
Effect.gen(function* () {
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
const installed = yield* Deferred.make<string>()
const restarted = yield* Deferred.make<void>()
yield* Updater.monitorServer({
url: fixture.url,
password: "test",
managed: true,
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
notify: () => Effect.void,
}).pipe(Effect.forkScoped)
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
fixture.settle()
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
expect(
yield* Effect.raceFirst(
Deferred.await(installed),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
),
).toBe("1.1.0")
yield* Effect.raceFirst(
Deferred.await(restarted),
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
)
}),
)
const wait = (promise: Promise<unknown>, message: () => string) =>
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
function makeServer() {
const encoder = new TextEncoder()
const activeRead = Promise.withResolvers<void>()
const eventOpened = Promise.withResolvers<void>()
const waited = Promise.withResolvers<void>()
let active = true
let events: ReadableStreamDefaultController<Uint8Array> | undefined
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url)
if (url.pathname === "/api/session/active") {
activeRead.resolve()
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
}
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
waited.resolve()
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
return Response.json({ handoff: null })
}
if (url.pathname === "/api/event") {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
events = controller
eventOpened.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
)
}
return new Response("Not found", { status: 404 })
},
})
return {
url: server.url.origin,
activeRead: activeRead.promise,
eventOpened: eventOpened.promise,
waited: waited.promise,
settle() {
active = false
events?.enqueue(
encoder.encode(
`data: ${JSON.stringify({
id: "evt_settled",
created: Date.now(),
type: "session.execution.succeeded",
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
data: { sessionID: "ses_test" },
})}\n\n`,
),
)
events?.close()
events = undefined
},
stop() {
server.stop(true)
},
}
}
+1
View File
@@ -268,6 +268,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_ARTIFACT: JSON.stringify("cli-node"),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
+1 -11
View File
@@ -985,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
@@ -1892,7 +1883,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
autoupdate?: boolean | "notify"
update?: "disable" | "notify" | "auto"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
@@ -2299,7 +2290,6 @@ export type V2Event =
| ReferenceUpdated
| PermissionAsked
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
+218 -427
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[]
@@ -109,7 +107,6 @@ type Store = {
messageCursor: Record<string, string | undefined>
messageLoading: Record<string, boolean>
pending: Record<string, SessionInboxInfo[]>
input: Record<string, string[]>
permission: Record<string, PermissionRequest[]>
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
form: Record<string, FormWithLocation[]>
@@ -125,8 +122,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) {
@@ -200,7 +197,6 @@ export function createData(config: CreateDataInput) {
messageCursor: {},
messageLoading: {},
pending: {},
input: {},
permission: {},
form: {},
},
@@ -233,13 +229,6 @@ export function createData(config: CreateDataInput) {
sessionID,
(store.session.pending[sessionID] ?? []).filter((item) => item.id !== inboxID),
)
if (store.session.input[sessionID]?.includes(inboxID))
setStore(
"session",
"input",
sessionID,
(store.session.input[sessionID] ?? []).filter((id) => id !== inboxID),
)
}
function removePermission(sessionID: string, requestID: string) {
@@ -337,8 +326,8 @@ export function createData(config: CreateDataInput) {
return request
}
// Upsert an admitted inbox item into pending, input, and (for user and
// synthetic items) the visible transcript. Used by the inbox.enqueued
// Upsert an admitted inbox item into pending and (for user and synthetic
// items) the visible transcript. Used by the inbox.enqueued
// handler and by optimistic admission; the upsert is what reconciles
// the durable echo with an optimistic placeholder — the durable payload and
// times replace the client's guess.
@@ -353,8 +342,6 @@ export function createData(config: CreateDataInput) {
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
)
if (item.type === "compaction") return
const input = store.session.input[item.sessionID] ?? []
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
materializeInboxMessage(item)
})
}
@@ -403,15 +390,44 @@ export function createData(config: CreateDataInput) {
index.set(item.id, messages.length)
messages.push(item)
},
insert(sessionID: string, item: SessionMessageInfo) {
message.update(sessionID, (draft, index) => message.append(draft, index, item))
},
// Streaming events target one assistant message and, within it, the latest part of a kind.
// A missing target means the row was never loaded or was evicted; the event is dropped.
editAssistant(sessionID: string, messageID: string, fn: (assistant: SessionMessageAssistant) => void) {
message.update(sessionID, (draft, index) => {
const position = index.get(messageID)
const item = position === undefined ? undefined : draft[position]
if (item?.type === "assistant") fn(item)
})
},
editTool(sessionID: string, messageID: string, toolID: string, fn: (tool: SessionMessageAssistantTool) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const tool = assistant.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && item.id === toolID,
)
if (tool) fn(tool)
})
},
editText(sessionID: string, messageID: string, fn: (text: SessionMessageAssistantText) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const text = assistant.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
if (text) fn(text)
})
},
editReasoning(sessionID: string, messageID: string, fn: (reasoning: SessionMessageAssistantReasoning) => void) {
message.editAssistant(sessionID, messageID, (assistant) => {
const reasoning = assistant.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
if (reasoning) fn(reasoning)
})
},
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
},
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
const position = index.get(messageID)
const item = position === undefined ? undefined : messages[position]
return item?.type === "assistant" ? item : undefined
},
shell(messages: SessionMessageInfo[], shellID: string) {
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
return item?.type === "shell" ? item : undefined
@@ -420,19 +436,6 @@ export function createData(config: CreateDataInput) {
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined
},
latestTool(assistant: SessionMessageAssistant | undefined, id?: string) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantTool => item.type === "tool" && (id === undefined || item.id === id),
)
},
latestText(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
},
latestReasoning(assistant: SessionMessageAssistant | undefined) {
return assistant?.content.findLast(
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
@@ -510,12 +513,8 @@ export function createData(config: CreateDataInput) {
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
if (messages.length) draft.message[sessionID] = messages
if (pending.length) {
draft.pending[sessionID] = pending
draft.input[sessionID] = pending.filter((item) => item.type !== "compaction").map((item) => item.id)
}
if (pending.length) draft.pending[sessionID] = pending
}),
)
}
@@ -539,7 +538,6 @@ export function createData(config: CreateDataInput) {
delete draft.messageCursor[sessionID]
delete draft.messageLoading[sessionID]
delete draft.pending[sessionID]
delete draft.input[sessionID]
delete draft.permission[sessionID]
delete draft.form[sessionID]
for (const [rootID, family] of Object.entries(draft.family)) {
@@ -611,14 +609,12 @@ export function createData(config: CreateDataInput) {
const previous = store.session.info[event.data.sessionID]?.agent
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "agent", event.data.agent)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "agent-switched",
agent: event.data.agent,
previous,
time: { created: event.created },
})
return
}
@@ -626,13 +622,11 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "model", event.data.model)
if (!store.session.message[event.data.sessionID]) return
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "model-switched",
model: event.data.model,
time: { created: event.created },
})
void api()
.session.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
@@ -667,16 +661,14 @@ export function createData(config: CreateDataInput) {
setStore("session", "info", event.data.sessionID, "location", event.data.location)
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "location-switched",
location: event.data.location,
projectID: event.data.projectID,
subpath: event.data.subpath,
previous,
time: { created: event.created },
})
}
return
@@ -706,7 +698,7 @@ export function createData(config: CreateDataInput) {
return
}
case "session.inbox.delivered": {
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inboxID) ?? false
const admitted = result.session.input.has(event.data.sessionID, event.data.inboxID)
removePending(event.data.sessionID, event.data.inboxID)
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inboxID)
@@ -750,42 +742,36 @@ export function createData(config: CreateDataInput) {
// and produce no transcript message.
const updateText = event.data.text
if (updateText === undefined) return
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "system",
text: updateText,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
})
return
case "session.synthetic":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "synthetic",
text: event.data.text,
description: event.data.description,
metadata: event.data.metadata,
time: { created: event.created },
})
return
case "session.shell.started":
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: messageIDFromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "shell",
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
exit: event.data.shell.exit,
metadata:
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
time: { created: event.created },
})
return
case "session.shell.ended":
@@ -800,9 +786,8 @@ export function createData(config: CreateDataInput) {
return
case "session.message.content.updated": {
if (store.session.message[event.data.sessionID])
message.update(event.data.sessionID, (draft, index) => {
const assistant = message.assistant(draft, index, event.data.messageID)
if (assistant) assistant.content = [...event.data.content]
message.editAssistant(event.data.sessionID, event.data.messageID, (assistant) => {
assistant.content = [...event.data.content]
})
if (!sync.pending(`session.message:${event.data.sessionID}`)) return
result.session.message.invalidate(event.data.sessionID)
@@ -844,65 +829,54 @@ export function createData(config: CreateDataInput) {
})
return
case "session.step.streamed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (currentAssistant) currentAssistant.time.streamed = event.created
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.streamed = event.created
})
return
case "session.step.ended": {
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
if (event.data.snapshot)
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
})
return
}
case "session.step.failed":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.time.completed = event.created
currentAssistant.finish = event.data.finish ?? "error"
currentAssistant.rawFinish = event.data.rawFinish
currentAssistant.providerState = event.data.providerState
currentAssistant.error = event.data.error
currentAssistant.retry = undefined
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
currentAssistant.cost = event.data.cost
currentAssistant.tokens = event.data.tokens
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
}
})
return
case "session.text.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
type: "text",
text: "",
})
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({ type: "text", text: "" })
})
return
case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text += event.data.delta
})
return
case "session.text.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text = event.data.text
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
})
return
case "session.tool.input.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({
type: "tool",
id: event.data.id,
name: event.data.name,
@@ -912,86 +886,60 @@ export function createData(config: CreateDataInput) {
})
return
case "session.tool.input.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "streaming") match.state.input += event.data.delta
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "streaming") tool.state.input += event.data.delta
})
return
case "session.tool.input.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status === "streaming") match.state.input = event.data.text
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "streaming") tool.state.input = event.data.text
})
return
case "session.tool.called":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (!match) return
match.time.ran = event.created
match.executed = event.data.executed
match.providerState = event.data.state
match.state = { status: "running", input: event.data.input, metadata: {} }
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
tool.time.ran = event.created
tool.executed = event.data.executed
tool.providerState = event.data.state
tool.state = { status: "running", input: event.data.input, metadata: {} }
})
return
case "session.tool.progress":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status !== "running") return
match.state.metadata = event.data.metadata
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status === "running") tool.state.metadata = event.data.metadata
})
return
case "session.tool.success":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (match?.state.status !== "running") return
match.state = {
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status !== "running") return
tool.state = {
status: "completed",
input: match.state.input,
input: tool.state.input,
metadata: event.data.metadata,
content: [...event.data.content],
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
tool.executed = event.data.executed || tool.executed === true
tool.providerResultState = event.data.resultState
tool.time.completed = event.created
})
return
case "session.tool.failed":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestTool(
message.assistant(draft, index, event.data.assistantMessageID),
event.data.id,
)
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
match.state = {
message.editTool(event.data.sessionID, event.data.assistantMessageID, event.data.id, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return
tool.state = {
status: "error",
error: event.data.error,
input: typeof match.state.input === "string" ? {} : match.state.input,
input: typeof tool.state.input === "string" ? {} : tool.state.input,
metadata: event.data.metadata,
content: event.data.content,
}
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
tool.executed = event.data.executed || tool.executed === true
tool.providerResultState = event.data.resultState
tool.time.completed = event.created
})
return
case "session.reasoning.started":
message.update(event.data.sessionID, (draft, index) => {
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
@@ -1000,30 +948,20 @@ export function createData(config: CreateDataInput) {
})
return
case "session.reasoning.delta":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) match.text += event.data.delta
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text += event.data.delta
})
return
case "session.reasoning.ended":
message.update(event.data.sessionID, (draft, index) => {
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) match.state = event.data.state
}
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.state = event.data.state
})
return
case "session.retry.scheduled":
message.update(event.data.sessionID, (draft, index) => {
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
if (!currentAssistant) return
currentAssistant.retry = {
attempt: event.data.attempt,
at: event.data.at,
error: event.data.error,
}
message.editAssistant(event.data.sessionID, event.data.assistantMessageID, (assistant) => {
assistant.retry = { attempt: event.data.attempt, at: event.data.at, error: event.data.error }
})
return
case "session.execution.started":
@@ -1031,16 +969,14 @@ export function createData(config: CreateDataInput) {
return
case "session.compaction.started":
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
message.update(event.data.sessionID, (draft, index) => {
message.append(draft, index, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
})
message.insert(event.data.sessionID, {
id: event.data.inputID ?? messageIDFromEvent(event.id),
type: "compaction",
status: "running",
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
})
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
return
@@ -1075,11 +1011,12 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
setStore(
"session",
"input",
"pending",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id < event.data.to),
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
)
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
@@ -1273,6 +1210,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,
@@ -1321,12 +1293,17 @@ export function createData(config: CreateDataInput) {
status(sessionID: string) {
return store.session.active[sessionID] ?? "idle"
},
// Inputs are the pending user and synthetic items; compactions are control items.
input: {
list(sessionID: string) {
return store.session.input[sessionID] ?? []
return (store.session.pending[sessionID] ?? []).flatMap((item) =>
item.type === "compaction" ? [] : [item.id],
)
},
has(sessionID: string, inboxID: string) {
return store.session.input[sessionID]?.includes(inboxID) ?? false
return (
store.session.pending[sessionID]?.some((item) => item.id === inboxID && item.type !== "compaction") ?? false
)
},
},
pending: {
@@ -1350,12 +1327,6 @@ export function createData(config: CreateDataInput) {
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
batch(() => {
setStore("session", "pending", sessionID, reconcile(merged))
setStore(
"session",
"input",
sessionID,
reconcile(merged.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
merged.forEach(materializeInboxMessage)
})
})
@@ -1740,7 +1711,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)
@@ -1752,31 +1723,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) {
@@ -1831,162 +1779,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
@@ -2001,22 +1807,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 })),
},
}
@@ -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"
},
+1
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",
@@ -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([
+6 -1
View File
@@ -24,6 +24,7 @@ import { ConfigMCPV1 } from "../v1/config/mcp.js"
import { ConfigPermissionV1 } from "../v1/config/permission.js"
import { ConfigPluginV1 } from "../v1/config/plugin.js"
import { ConfigProviderV1 } from "../v1/config/provider.js"
import { ConfigV1 } from "../v1/config/config.js"
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
import { PositiveInt } from "../schema.js"
@@ -69,6 +70,9 @@ export function normalize(input: unknown): Result {
const legacySnapshots = own(input, "snapshot")
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyUpdate = own(input, "autoupdate")
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
@@ -82,6 +86,7 @@ export function normalize(input: unknown): Result {
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
@@ -191,7 +196,7 @@ export function normalize(input: unknown): Result {
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
autoupdate: Info.fields.autoupdate,
update: Info.fields.update,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
+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.
+56 -64
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,
@@ -38,7 +20,9 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
// One slot per requested definition in activation order, including ones whose setup failed, so
// the prefix diff below stays index-aligned and a failed revision is not retried until it changes.
const active = new Map<Plugin.ID, Slot>()
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
@@ -70,7 +54,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,18 +81,18 @@ 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 nextInventory = [...definitions.map(activeInfo), ...failures]
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 = [...Array.from(active.values()).map(slotInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
@@ -118,29 +101,37 @@ 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, slot]) =>
Effect.gen(function* () {
active.delete(id)
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
}),
{ discard: true },
)
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 })
nextInventory.push(activeInfo(definition))
active.set(definition.id, {
plugin: definition,
loaded: { plugin: definition, scope: loaded.scope },
})
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
active.set(definition.id, { plugin: definition, error: loaded.error })
if (!previous) continue
const restored = yield* load(previous.plugin)
const fallback = previous.get(definition.id)?.loaded
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: definition,
loaded: { plugin: fallback.plugin, scope: restored.scope },
error: loaded.error,
})
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
@@ -148,14 +139,7 @@ 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]
inventory = [...Array.from(active.values()).map(slotInfo), ...failures]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
@@ -184,12 +168,20 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Generation): Plugin.Info {
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
// running, which stays an older fallback while the requested revision keeps failing setup.
type Slot = {
readonly plugin: Generation
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
readonly error?: string
}
function slotInfo(slot: Slot): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
state: { status: "active" },
features: { server: true, ...plugin.features },
id: Plugin.ID.make(slot.plugin.id),
source: slot.plugin.source ?? { type: "builtin" },
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
features: { server: true, ...slot.plugin.features },
}
}
+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),
),
},
+2
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"
@@ -204,6 +205,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),
),
),
)
})
}
+36 -33
View File
@@ -1,7 +1,7 @@
export * as PluginSupervisor from "./supervisor.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Layer, Stream } from "effect"
import { Cause, Effect, Layer, Queue, Stream } from "effect"
import path from "path"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -19,6 +19,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
post: readonly Plugin.Generation[],
operations: readonly ConfigPluginSource.Operation[],
install: boolean,
running: ReadonlyMap<string, Plugin.Generation>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
@@ -71,6 +72,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
state: { status: "failed", error: plugin.error, ref: plugin.ref },
features: { server: true },
})
// The new revision never became a generation, so the one already running keeps its place.
const retained = packages.get(operation.target) ?? running.get(operation.target)
if (!retained) continue
packages.set(operation.target, retained)
enabled.add(retained.id)
continue
}
failures.delete(operation.target)
@@ -91,6 +97,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
ordered.findIndex((other) => other.id === plugin.id) !== index
return {
plugins: ordered.filter((plugin, index) => !duplicate(plugin, index)),
packages: new Map([...packages].filter(([, plugin]) => enabled.has(plugin.id))),
failures: [
...failures.values(),
...ordered.filter(duplicate).map((plugin) => ({
@@ -118,6 +125,8 @@ export const layer = Layer.effectDiscard(
// Built-ins capture services from this layer; unload them before those services close.
yield* Effect.addFinalizer(registry.close)
let packages = new Set<string>()
// Last generation handed to the registry per target; a failed reload keeps it in place.
let running = new Map<string, Plugin.Generation>()
let outdated = new Set<string>()
const updating = new Set<string>()
let generation = 0
@@ -140,7 +149,7 @@ export const layer = Layer.effectDiscard(
}))
const operations = yield* sources.operations()
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const immediate = yield* resolve(pre, post, operations, false, running)
const source = (source: Plugin.Source) =>
source.type === "package"
? {
@@ -155,16 +164,17 @@ export const layer = Layer.effectDiscard(
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true, running) : immediate
if (resolved !== immediate) yield* apply(resolved)
const loaded = new Set(
running = resolved.packages
const targets = new Set(
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
plugin.source?.type === "package" ? [plugin.source.target] : [],
),
)
packages = loaded
packages = targets
yield* Effect.forEach(
loaded,
targets,
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
{ concurrency: "unbounded" },
).pipe(
@@ -178,8 +188,24 @@ export const layer = Layer.effectDiscard(
Effect.forkScoped({ startImmediately: true }),
)
})
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
// Start source consumers before activation, without an extra merge/debounce boundary delaying them.
// Each source owns its upstream subscriptions; the queue retains the latest observed request.
const triggers = yield* Queue.sliding<number>(1)
// Make accepted work visible to awaitActivation before coalescing the burst.
const notify = Effect.gen(function* () {
observed++
if (!release) release = yield* registry.hold()
yield* Queue.offer(triggers, observed)
})
const watch = <A>(stream: Stream.Stream<A>) =>
stream.pipe(
Stream.runForEach(() => notify),
Effect.forkScoped({ startImmediately: true }),
)
yield* watch(sources.changes())
yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours")))
yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated]))
yield* watch(
updates.changes().pipe(
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
@@ -188,22 +214,10 @@ export const layer = Layer.effectDiscard(
update.updating ? updating.add(update.target) : updating.delete(update.target)
}),
),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to awaitActivation before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
if (!release) release = yield* registry.hold()
return observed
}),
),
)
yield* Stream.concat(Stream.succeed(0), reloads).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
Stream.buffer({ capacity: 1, strategy: "sliding" }),
Stream.debounce("100 millis"),
// Run initial activation immediately; debounce only later requests. One consumer serializes both.
yield* Stream.concat(Stream.succeed(0), Stream.fromQueue(triggers).pipe(Stream.debounce("100 millis"))).pipe(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
@@ -215,17 +229,6 @@ export const layer = Layer.effectDiscard(
),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("24 hours").pipe(
Effect.andThen(
Effect.acquireUseRelease(
registry.hold(),
() => activate(),
(release) => release,
),
),
Effect.forever,
Effect.forkScoped,
)
}),
)
@@ -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.
+9 -1
View File
@@ -121,7 +121,15 @@ const layer = Layer.effect(
// The heterogeneous registry erases handlers after their selected schema validates input.
const execution: Effect.Effect<unknown, unknown> = Reflect.apply(handler, undefined, [parsed, callContext])
return execution
}).pipe(Effect.catch((error) => encodeError(method, error)))
}).pipe(
Effect.catch((error) => encodeError(method, error)),
// Normalize handler bugs here so direct callers can recover just like HTTP callers.
Effect.catchDefect((defect) =>
Effect.logError("rpc handler failed", { rpc: rpcID, method: name, defect }).pipe(
Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))),
),
),
)
return yield* encode(method.output, result).pipe(
Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))),
)
+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: {
+8 -8
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>
@@ -326,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
},
}),
})
+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)
+3 -1
View File
@@ -53,7 +53,9 @@ export interface Prepared {
* One request-scoped execution operation. Unknown and hook-removed calls
* fail individually through the same seam.
*/
readonly executeTool: (input: Parameters<Tool.Snapshot["execute"]>[0]) => Effect.Effect<Tool.Result, ExecuteError>
readonly executeTool: (
input: Parameters<Tool.Snapshot["execute"]>[0],
) => Effect.Effect<Tool.NormalizedResult, ExecuteError>
}
interface PrepareInput {
+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,

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