Compare commits

...
Author SHA1 Message Date
Dax Raad 571699654a refactor(core): move session command and generation to instances
Remove command and generation entry points from the global Session facade. Use the selected instance services in HTTP handlers and PluginHost while preserving cross-instance plugin calls and session validation.
2026-09-01 15:10:36 -04:00
Dax Raad 9a101931d9 refactor(core): decouple plugins from the config loader
Let Plugin construct plugin contexts and own readiness independently of configuration loading. Make the supervisor an optional boot task, remove readiness-only service and node shims, and keep Session provider wiring outside the standalone runtime facade.
2026-09-01 14:24:05 -04:00
Dax Raad 9961f207f2 refactor(core): prepare plugins outside the runner
Move context and service binding into preparation while preserving activation, readiness, and cleanup behavior. Replace broad plugin-system test coverage with four Core behavioral contracts, retaining built-in feature tests.
2026-09-01 13:41:36 -04:00
Shoubhit Dash 9f01e2b548 fix(plugin): make load failures easier to diagnose (#46594) 2026-09-01 22:29:35 +05:30
Shoubhit Dash 7856515140 feat(tui): add shareable stats poster (#46563) 2026-09-01 22:21:52 +05:30
Dax Raad 818804e181 chore: refresh bun lockfile 2026-09-01 12:19:40 -04:00
Aiden Cline ce6247bd2f fix(core): estimate context growth before compaction (#46543) 2026-09-01 10:48:34 -05:00
opencode-agent[bot]andrekram1-node 0465328297 test(tui): use real session id in home fixture (#46618)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-01 10:39:04 -05:00
Dax Raad 43d09b9d75 fix(server): await plugin activation when checking updates 2026-09-01 11:16:02 -04:00
Dax Raad 7730cec123 fix(tui): limit cached non-tab session families 2026-09-01 11:14:01 -04:00
opencode-agent[bot]andJames Long 367ee47d7e fix(tui): tone down patch failure details (#46470)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-01 11:07:03 -04:00
David Hillandjlongster c596a8f11d fix(tui): simplify narrow interrupt footer (#46533)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-01 11:06:25 -04:00
Kit Langton 30f998c5b9 feat(tui): make session preview tabs the default (#46497) 2026-09-01 11:02:11 -04:00
Kit Langton d4b4dd17cc refactor(core): rename plugin flush to awaitActivation 2026-09-01 10:48:08 -04:00
Dax Raad f330f3e02b feat(cli): check and update plugins with flat inventory 2026-09-01 10:46:47 -04:00
opencode-agent[bot]andnexxeln a9c27209bb fix(tui): open rename dialog from tab menu (#46603)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-01 19:58:36 +05:30
Kit Langton 831f8f6cd1 fix(ai): reconcile final response calls by call id (#46084) 2026-09-01 09:47:10 -04:00
Victor Navarro cff1d0fe01 fix(core): preserve legacy Console reasoning variants (#46586) 2026-09-01 13:45:26 +02:00
Simon Klee e297da82ae fix(tui): mini defer prompt echo until delivery (#46578) 2026-09-01 13:29:18 +02:00
Victor Navarro 77eac47493 feat(core): support canonical provider config (#46134) 2026-09-01 12:19:02 +02:00
Victor NavarroandAiden Cline aadc0c1b4b fix: stabilize cross-platform unit tests (#46569)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-01 11:38:55 +02:00
Victor Navarro 94caa36fd4 test(server): wait for plugin readiness (#46567) 2026-09-01 10:52:45 +02:00
Brendan Allan a6f75d483a fix(app): follow file tree order in review navigation (#46557) 2026-09-01 07:25:08 +00:00
Aiden Cline 000b42d204 refactor(core): nest code mode catalog (#46541) 2026-09-01 00:01:31 -05:00
Aiden Cline 02440f6715 feat(codemode): label item and value schema comments (#46542) 2026-08-31 23:54:58 -05:00
Brendan AllanandDavid Hill d46ed9e9db Session message style (#46538)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2026-09-01 04:49:16 +00:00
opencode-agent[bot]andBrendonovich 71779ae5de fix(app): scope pane visibility to tabs (#46508)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 12:21:42 +08:00
Aiden Cline 4a3e25beab feat(codemode): compact schema constraint comments (#46521) 2026-08-31 23:06:49 -05:00
opencode-agent[bot]andrekram1-node 23fde448ec test(cli): isolate Bun define cache (#46536)
Co-authored-by: rekram1-node <63023139+rekram1-node@users.noreply.github.com>
2026-08-31 23:01:02 -05:00
opencode-agent[bot] 1137861188 chore: update nix node_modules hashes 2026-09-01 04:00:15 +00:00
Dax Raad 815d4ab9b4 fix(tui): scope connect dialog to location 2026-08-31 23:42:35 -04:00
Dax Raad 5d73a5789f feat(plugin): support live package updates 2026-08-31 23:35:21 -04:00
Aiden Cline df05945042 chore(ci): align Bun with the release runtime (#46524) 2026-08-31 21:48:35 -05:00
Aiden Cline 6a99898ef7 feat(core): register tool namespaces (#46487) 2026-08-31 21:29:19 -05:00
a40a87276a fix(tui): pin diff highlights query (#46518)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Andreas Holt <6665487+AndreasHolt@users.noreply.github.com>
2026-08-31 21:06:43 -05:00
Luke Parker a20cbc394e feat(session-ui): preview images in read tool results (#46513) 2026-09-01 01:44:39 +00:00
Aiden Cline 8fda87614f feat(codemode): document numeric string and array constraints (#46510) 2026-08-31 20:44:07 -05:00
Kit Langton dffd95ce7c fix(codemode): reject Object.assign cycles (#46076) 2026-08-31 20:38:02 -04:00
opencode-agent[bot]andDavid Hill b0402f5a34 fix(session-ui): reduce inline code height (#46500)
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 08:26:46 +08:00
Kit Langton 54b00ec5fe test(tui): capture flushed Mini scrollback output (#46505) 2026-08-31 20:26:36 -04:00
286 changed files with 7324 additions and 11336 deletions
+3 -13
View File
@@ -969,6 +969,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"pacote": "21.5.1",
"resolve.exports": "catalog:",
},
"devDependencies": {
@@ -978,6 +979,7 @@
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/pacote": "11.1.8",
"@typescript/native-preview": "catalog:",
},
},
@@ -3211,8 +3213,6 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -5451,8 +5451,6 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -5667,8 +5665,6 @@
"uuid": ["uuid@14.0.2", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ=="],
"valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="],
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
@@ -5817,9 +5813,7 @@
"xml-naming": ["xml-naming@0.3.0", "", {}, "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ=="],
"xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
@@ -5851,8 +5845,6 @@
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
@@ -6607,8 +6599,6 @@
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"postcss-css-variables/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"postcss-css-variables/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JStMvgtXBA5GrhyBJ5FtdqD8LWkcaPA9NXef+c2xUzw=",
"aarch64-linux": "sha256-WQxF+yS0ZImW0KW620XnZUBsKAJDoJ1jLDOMBaXI2/E=",
"aarch64-darwin": "sha256-km7G6s45dfFdW3Z6lFrs4NohD+vmwN1vR8PmEL0WCto=",
"x86_64-darwin": "sha256-YFbkcHpuspgTp+B+th3IJ3cnu5C2gEUSiy3NDXpD8UA="
"x86_64-linux": "sha256-nV2bI91uUqHJugt1mERgYNCn/zqWJ21YXc5YozwQ+Ss=",
"aarch64-linux": "sha256-s/0PghIWeRHsMw9Re84sQ8qW5IZwyhXrhmYlUy2xkt4=",
"aarch64-darwin": "sha256-FAm7Bk3NPikHYmmJFwB+0V3saRZqlyxDHuM8gasN3zA=",
"x86_64-darwin": "sha256-kdYwYQhubvO9CtVsuHsGnIJ9/byWQ5FMFCLRoScQ614="
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"version": "0.0.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
+6 -3
View File
@@ -1257,9 +1257,12 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
const events: LLMEvent[] = []
if (event.type === "response.completed") {
for (const item of event.response?.output ?? []) {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (id === undefined) continue
if (item.type !== "function_call" || !current.tools[id]) continue
if (
item.type !== "function_call" ||
!item.call_id ||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
)
continue
const [next, emitted] = yield* onOutputItemDone(current, item)
current = next
events.push(...emitted)
@@ -4017,7 +4017,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
it.effect("uses completed response output when item completion and its terminal item id are missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -4032,7 +4032,6 @@ describe("OpenAI Responses route", () => {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
@@ -123,7 +123,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -131,10 +131,8 @@ async function setup(page: Page) {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -144,6 +142,6 @@ async function setup(page: Page) {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
}
@@ -107,8 +107,6 @@ for (const position of ["top", "bottom"] as const) {
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
const status = page.getByRole("dialog", { name: "Status", exact: true })
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
await status.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(status.getByText("opencode.json", { exact: true })).toBeVisible()
await status.getByRole("button", { name: "Close", exact: true }).click()
await expect(status).toBeHidden()
await expect(more).toBeFocused()
@@ -80,7 +80,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -88,10 +88,8 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
@@ -101,7 +99,7 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -1,103 +0,0 @@
import { expect, test } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/Projects/extensions-demo"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const session = {
id: "ses_project_extensions",
title: "Existing session",
directory,
projectID: "proj_extensions_demo",
time: { created: 1700000000000, updated: 1700000000000 },
}
test.use({ viewport: { width: 1440, height: 1000 }, colorScheme: "dark" })
test("project Extensions stays inside settings while plugins load", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: session.projectID,
canonical: directory,
name: "Extensions demo",
vcs: "git",
time: session.time,
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ server, sessionID, directory }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ server, sessionID: session.id, directory },
)
const href = `/server/${base64Encode(server)}/session/${session.id}`
await page.goto(href)
await expect(page.getByRole("heading", { name: session.title, exact: true })).toBeVisible()
await page.keyboard.press("Control+,")
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
await settings.getByText("Extensions demo", { exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("textbox", { name: "Name", exact: true })).toBeFocused()
const globalPlugins = Promise.withResolvers<void>()
const projectPlugins = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === "/api/plugin",
async (route) => {
const project = new URL(route.request().url()).searchParams.get("location[directory]")
await (project ? projectPlugins : globalPlugins).promise
await route.fulfill({
json: {
location: project ? { directory: project } : {},
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
state: { status: "active" },
features: { server: true },
})),
},
})
},
)
const requested = page.waitForRequest((request) => {
const url = new URL(request.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(page).toHaveURL(href)
await expect(dialog.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings).toBeVisible()
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
globalPlugins.resolve()
await dialog.getByRole("tab", { name: "Scripts", exact: true }).click()
await expect(dialog.getByRole("heading", { name: "Scripts", exact: true })).toBeVisible()
await dialog.getByRole("tab", { name: "Extensions", exact: true }).click()
projectPlugins.resolve()
await dialog.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(dialog.getByText("project-plugin", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Shared with all projects 1", exact: true }).click()
await expect(dialog.getByText("shared-plugin", { exact: true })).toBeVisible()
await expect(page).toHaveURL(href)
await page.keyboard.press("Escape")
await expect(dialog).toBeHidden()
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toHaveAttribute("aria-selected", "true")
await expect(page.getByRole("heading", { name: session.title, exact: true, includeHidden: true })).toBeHidden()
})
@@ -61,7 +61,7 @@ test("opens and searches project files inline", async ({ page }) => {
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ directory, server, sessionID }) => {
({ directory, server, sessionID, tabKey }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -69,10 +69,8 @@ test("opens and searches project files inline", async ({ page }) => {
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.global.dat:review-panel-v2",
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
@@ -82,7 +80,7 @@ test("opens and searches project files inline", async ({ page }) => {
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ directory, server, sessionID },
{ directory, server, sessionID, tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}` },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
@@ -22,6 +22,7 @@ test("restores review mode and selected file per session", async ({ page }) => {
await selectFile(page, "alpha.ts")
await switchSession(page, titleB)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts")
@@ -18,37 +18,72 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// The v2 review pane's diff data is workspace-scoped: switching between session
// tabs in the same workspace must update its parameters reactively instead of
// tearing the pane down and remounting it (which flickers).
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
// The review pane's data is workspace-scoped, but visibility belongs to each
// session tab. Switching tabs must keep the pane mounted without opening it.
test("keeps review visibility per tab and the pane mounted across tab switches", async ({ page }) => {
await setup(page)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toContain("0.24s")
const reviewTab = page.locator("#session-side-panel-review-tab")
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
const chatPanel = page.locator('[data-slot="session-chat-panel"]')
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
const review = page.locator('#review-panel [data-component="session-review-v2"]')
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await writeProbe(page)
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expectAppVisible(review)
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
await expect(review).toBeHidden()
await expect
.poll(() =>
page
.locator('[data-slot="session-chat-panel"]')
.evaluate((element) => getComputedStyle(element).transitionDuration),
)
.toBe("0s")
expect(await readProbe(page)).toBe(PROBE)
await page.getByRole("button", { name: "Toggle review" }).click()
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "520px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
expect(await readProbe(page)).toBe(PROBE)
await expect
.poll(() =>
page.evaluate(
({ key }) => {
const panes = JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")
return panes[key]?.review
},
{ key: `${server}\n${sessionHref(sessionA)}` },
),
)
.toBe(true)
await page.reload()
await expectSessionTitle(page, titleA)
await expectAppVisible(review)
await expect(chatPanel).toHaveCSS("width", "580px")
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
await viewport.hover()
await page.mouse.wheel(0, 100_000)
@@ -102,7 +137,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -114,8 +149,19 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { sessionWidth: 580 },
[`${server}\n${sessionHref(sessionB)}`]: { sessionWidth: 520 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
}
@@ -123,12 +123,17 @@ test("uses side placement by default and supports the terminal across the bottom
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:layout",
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
)
})
await page.addInitScript(
({ tabKey, server, sessionID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split" } }))
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify({ [tabKey]: { review: true } }))
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
},
{ tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`, server, sessionID },
)
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await expectSessionReady(page, { server, sessionID, title })
@@ -68,41 +68,20 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
refresh.resolve()
})
test("extensions opens without waiting for MCPs or plugins", async ({ page }) => {
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
await mcps.promise
await route.fulfill({
json: { location: { directory }, data: [{ name: "demo-mcp", status: { status: "connected" } }] },
})
})
await page.route("**/api/plugin", async (route) => {
await plugins.promise
await route.fulfill({
json: {
location: { directory },
data: [
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
],
},
})
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/mcp")
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Extensions", exact: true })).toBeVisible()
await expect(settings.getByRole("button", { name: "Back to app" })).toBeVisible()
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(settings.getByRole("tab", { name: "Plugins", exact: true })).toHaveAttribute("aria-selected", "true")
plugins.resolve()
await expect(settings.getByText("demo-plugin", { exact: true })).toBeVisible()
mcps.resolve()
await settings.getByRole("tab", { name: "MCPs", exact: true }).click()
await expect(settings.getByRole("switch", { name: "demo-mcp" })).toBeChecked()
@@ -252,8 +252,15 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
function seedCachedTerminal(page: Page) {
return page.addInitScript(
({ terminalKey, ptyID }) => {
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
({ terminalKey, ptyID, tabKey, server, sessionID }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs.panes",
JSON.stringify({ [tabKey]: { terminal: true, terminalHeight: 320 } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
)
localStorage.setItem(
terminalKey,
JSON.stringify({
@@ -262,7 +269,13 @@ function seedCachedTerminal(page: Page) {
}),
)
},
{ terminalKey: terminalStorageKey(), ptyID },
{
terminalKey: terminalStorageKey(),
ptyID,
tabKey: `${server}\n/server/${base64Encode(server)}/session/${sessionID}`,
server,
sessionID,
},
)
}
@@ -16,10 +16,9 @@ const PROBE = "original"
test.use({ viewport: { width: 1440, height: 900 } })
// Terminals are workspace-scoped: switching between session tabs in the same
// workspace must keep the terminal mounted and its PTY connection open instead
// of tearing it down and reconnecting.
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
// Terminal processes are workspace-scoped, but panel visibility belongs to each
// session tab. Switching tabs must keep the PTY alive without opening its panel.
test("keeps terminal visibility per tab and the PTY alive across tab switches", async ({ page }) => {
const connections = await setup(page)
await page.goto(sessionHref(sessionA))
@@ -27,7 +26,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await page.keyboard.press("Control+Backquote")
const terminal = page.locator('[data-component="terminal"]')
const terminalPanel = page.locator('[data-component="terminal-panel"]')
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "true")
await expect(terminalPanel).toHaveCSS("height", "300px")
await expect.poll(() => connections.length).toBe(1)
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
@@ -37,15 +39,26 @@ test("keeps the terminal session alive when switching session tabs in a workspac
await switchTab(page, titleB)
await expectSessionTitle(page, titleB)
await expect(terminal).toBeVisible()
await expect(terminal).toBeHidden()
await expect(terminalPanel).toHaveAttribute("data-size-animated", "false")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "180px")
await switchTab(page, titleA)
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
expect(await readProbe(page)).toBe(PROBE)
expect(connections.length).toBe(1)
await page.reload()
await expectSessionTitle(page, titleA)
await expect(terminal).toBeVisible()
await expect(terminalPanel).toHaveCSS("height", "300px")
})
type Probed = HTMLElement & { __e2eProbe?: string }
@@ -120,7 +133,7 @@ async function setup(page: Page) {
})
await page.addInitScript(
({ directory, server, sessions }) => {
({ directory, server, sessions, panes }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
@@ -132,8 +145,20 @@ async function setup(page: Page) {
"opencode.window.browser.dat:tabs",
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
)
if (!localStorage.getItem("opencode.window.browser.dat:tabs.panes")) {
localStorage.setItem("opencode.window.browser.dat:tabs.panes", JSON.stringify(panes))
}
localStorage.setItem("settings.v3", JSON.stringify({ general: { terminalPlacement: "bottom" } }))
},
{
directory,
server,
sessions: [sessionA, sessionB],
panes: {
[`${server}\n${sessionHref(sessionA)}`]: { terminalHeight: 300 },
[`${server}\n${sessionHref(sessionB)}`]: { terminalHeight: 180 },
},
},
{ directory, server, sessions: [sessionA, sessionB] },
)
return connections
}
@@ -75,11 +75,12 @@ for (const theme of ["light", "dark"] as const) {
await expectBackground(view.send, "contrast")
const message = page.locator('[data-slot="user-message-text"]')
await expect(message).toHaveText("Check this fixture workspace.")
await expectBackground(
await expectToken(
message,
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
"background-color",
scenario.accent ? "--v2-background-bg-accent" : "--v2-state-bg-info",
)
await expectToken(message, "color", scenario.accent ? "--v2-text-text-contrast" : "--v2-text-text-accent")
})
}
@@ -245,3 +246,16 @@ async function expectBackground(element: Locator, token: string, property = "bac
}, token)
await expect(element).toHaveCSS(property, new RegExp(color.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
}
async function expectToken(element: Locator, property: string, token: string) {
const color = await element.evaluate((element, token) => {
const probe = document.createElement("span")
probe.hidden = true
probe.style.color = `var(${token})`
element.append(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
}, token)
await expect(element).toHaveCSS(property, color)
}
@@ -1,26 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PluginInfo } from "@opencode-ai/client"
import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
]
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PluginInfo } from "@opencode-ai/client"
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "package") return plugin.source.target
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
}
+19 -10
View File
@@ -1,16 +1,25 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
export function createAnimatedPresence<T>(
value: Accessor<T | undefined>,
element: Accessor<HTMLElement | null>,
identity?: Accessor<unknown>,
) {
const animation = createMemo<{ identity?: unknown; show: boolean; animate: boolean; value: T | undefined }>(
(previous) => {
const currentIdentity = identity?.()
const current = value()
const show = current !== undefined
const same = !identity || previous?.identity === currentIdentity
return {
identity: currentIdentity,
show,
animate: previous !== undefined && same && (previous.animate || previous.show !== show),
value: current ?? (same ? previous?.value : undefined),
}
},
)
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
@@ -155,6 +155,16 @@ describe("isSessionNotFoundError", () => {
expect(isSessionNotFoundError(new Error(body.message, { cause: { body, status: 404 } }), body.sessionID)).toBe(true)
})
test("matches a structured error stored directly as the cause", () => {
const body = {
_tag: "SessionNotFoundError",
sessionID: "ses_missing",
message: "Session not found",
} satisfies SessionNotFoundError
expect(isSessionNotFoundError(new Error("Unknown error", { cause: body }), body.sessionID)).toBe(true)
})
test("rejects errors for other sessions and other 404 responses", () => {
const body = {
_tag: "SessionNotFoundError",
+3 -4
View File
@@ -36,10 +36,9 @@ export function formatServerError(error: unknown, translate?: Translator, fallba
}
function unwrapNamedError(error: unknown): unknown {
if (error instanceof Error && error.cause && typeof error.cause === "object" && "body" in error.cause) {
return (error.cause as Record<string, unknown>).body
}
return error
if (!(error instanceof Error) || !error.cause || typeof error.cause !== "object") return error
if ("body" in error.cause) return (error.cause as Record<string, unknown>).body
return error.cause
}
// Client-synthesized session not-found errors share one constructor and
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2, sortFileTreeV2Paths } from "./file-tree-v2-model"
import type { FileNode } from "@/runtime/server/types"
describe("buildFileTreeV2Model", () => {
@@ -45,6 +45,33 @@ describe("buildFileTreeV2Model", () => {
})
})
describe("sortFileTreeV2Paths", () => {
test("orders navigation depth-first with directories before sibling files", () => {
const paths = ["README.md", "src/a.ts", "src/lib/z.ts", "docs/guide.md", "src/lib/b.ts"]
expect(sortFileTreeV2Paths(paths)).toEqual([
"docs/guide.md",
"src/lib/b.ts",
"src/lib/z.ts",
"src/a.ts",
"README.md",
])
expect(paths[0]).toBe("README.md")
})
test("preserves original paths for selection", () => {
expect(sortFileTreeV2Paths(["README.md", "src\\lib\\a.ts", "/docs//guide.md/"])).toEqual([
"/docs//guide.md/",
"src\\lib\\a.ts",
"README.md",
])
})
test("handles an empty file list", () => {
expect(sortFileTreeV2Paths([])).toEqual([])
})
})
describe("flattenLiveFileTreeV2", () => {
test("flattens live children using original paths for nested lookups", () => {
const nodes: Record<string, FileNode[]> = {
@@ -76,6 +76,12 @@ export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: strin
return rows
}
export function sortFileTreeV2Paths(paths: readonly string[]) {
return flattenFileTreeV2(buildFileTreeV2Model(paths), () => true)
.filter((row) => row.node.type === "file")
.map((row) => row.node.originalPath)
}
export function flattenLiveFileTreeV2(
children: (path: string) => readonly FileNode[],
expanded: (path: string) => boolean,
+1
View File
@@ -129,6 +129,7 @@ export function useSessionModel() {
layout: {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+6 -2
View File
@@ -18,6 +18,7 @@ import type {
SessionReviewLineComment,
} from "@opencode-ai/session-ui/session-review"
import FileTreeV2 from "@/session/files/file-tree-v2"
import { sortFileTreeV2Paths } from "@/session/files/file-tree-v2-model"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -83,6 +84,9 @@ export function ReviewPanelView(
),
)
const searching = createMemo(() => props.state.filter().trim().length > 0)
const navigationFiles = createMemo(() =>
searching() || props.fileList === "flat" ? filteredFiles() : sortFileTreeV2Paths(filteredFiles()),
)
const kinds = createMemo(() => reviewDiffKinds(diffs()))
// Changes-only trees omit "M" — every row is already a change; A/D stay visible.
const treeKinds = createMemo(() => new Map([...kinds()].filter(([, kind]) => kind !== "mix")))
@@ -93,7 +97,7 @@ export function ReviewPanelView(
if (focus && diffs().some((diff) => diff.file === focus.file)) return focus.file
const active = props.activeFile
if (searching()) return active
const files = filteredFiles()
const files = navigationFiles()
if (active && files.includes(active)) return active
return files[0]
})
@@ -141,7 +145,7 @@ export function ReviewPanelView(
/>
}
activeFile={activeDiff()}
files={filteredFiles()}
files={navigationFiles()}
onSelectFile={props.onSelectFile}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
+3 -2
View File
@@ -12,6 +12,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const layout = useLayout()
const settings = useSettings()
const size = createSizing()
const view = session.layout.view
const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened())
const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id)
const terminalOpen = createMemo(() => session.layout.view().terminal.opened())
@@ -42,7 +43,7 @@ export function createSessionScreenLayout(session: SessionModel) {
const splitReview = createMemo(() => reviewPanelOpen() && layout.review.diffStyle() === "split")
const resizedWidth = createMemo(() =>
clampSessionPanelWidth({
width: layout.session.width(),
width: view().reviewPanel.width(),
available: available(),
split: splitReview(),
}),
@@ -72,7 +73,7 @@ export function createSessionScreenLayout(session: SessionModel) {
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen())
const terminalPane = createMemo(() =>
Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
Math.min(view().terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6),
)
const terminalPaneHeight = createMemo(() => `${terminalPane()}px`)
const sideHeight = createMemo(() => rowSize.height)
+57 -26
View File
@@ -11,10 +11,8 @@ import {
on,
} from "solid-js"
import { createStore } from "solid-js/store"
import createPresence from "solid-presence"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { SessionHeader } from "@/session/header/session-header"
import { useLayout } from "@/shell/state/layout"
import { MessageTimeline, SessionSummaryPanel } from "@/session/timeline/message-timeline"
import { useServer } from "@/runtime/server/current"
import { projectForSession } from "@/shell/layout/helpers"
@@ -31,6 +29,7 @@ import { SessionContextTab } from "./files/session-context-tab"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
import { createAnimatedPresence } from "@/runtime/animated-presence"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -39,7 +38,6 @@ const SessionMobileFiles = lazy(async () => {
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
const layout = useLayout()
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
@@ -66,14 +64,40 @@ export function SessionScreen(props: { session: SessionModel }) {
const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible)
const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open())
const bottomTerminalVisible = createMemo(() => isDesktop() && screen.terminal.open() && screen.terminal.bottom())
const sidePresence = createPresence({
show: sideVisible,
element: () => elements.side ?? null,
})
const bottomTerminalPresence = createPresence({
show: bottomTerminalVisible,
element: () => elements.bottomTerminal ?? null,
const sidePresence = createAnimatedPresence(
() => sideVisible() || undefined,
() => elements.side ?? null,
session.layout.tabKey,
)
const bottomTerminalPresence = createAnimatedPresence(
() => bottomTerminalVisible() || undefined,
() => elements.bottomTerminal ?? null,
session.layout.tabKey,
)
const sideMotion = createMemo<{
key?: string
region: boolean
terminal: boolean
animateRegion: boolean
animateTerminal: boolean
}>((previous) => {
const key = session.layout.tabKey()
const region = screen.side.region.open()
const terminal = sideTerminalVisible()
const sameTab = previous?.key === key
return {
key,
region,
terminal,
animateRegion: !!previous && sameTab && previous.region !== region,
animateTerminal: !!previous && sameTab && previous.terminal !== terminal,
}
})
const paneAnimating = () =>
sidePresence.animate() ||
sideMotion().animateRegion ||
sideMotion().animateTerminal ||
bottomTerminalPresence.animate()
createEffect(() => {
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
@@ -256,7 +280,8 @@ export function SessionScreen(props: { session: SessionModel }) {
classList={{
"@container relative z-10 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!screen.size.active(),
!screen.size.active() && sidePresence.animate(),
"transition-none": screen.size.active() || !sidePresence.animate(),
}}
data-slot="session-chat-panel"
style={{
@@ -279,18 +304,18 @@ export function SessionScreen(props: { session: SessionModel }) {
max={screen.panel.max()}
onResize={(width) => {
screen.size.touch()
layout.session.resize(width)
session.layout.view().reviewPanel.resize(width)
}}
/>
</div>
</Show>
</div>
<Show when={sidePresence.present() || store.sideTerminalPresent}>
<Show when={sidePresence.present() || store.sideReviewPresent || store.sideTerminalPresent}>
<div
ref={(element) => setElements("side", element)}
data-slot="session-side-panel-presence"
data-opened={sideVisible()}
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
@@ -311,15 +336,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-region"
classList={{
"absolute inset-x-0 top-0 min-h-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.region.height() }}
>
<Show when={store.sideRegionPresent}>
<div
data-slot="session-side-region-presence"
data-opened={screen.side.region.open()}
data-opened={sideMotion().animateRegion ? sideMotion().region : undefined}
class="absolute inset-0"
onAnimationEnd={(event) => {
if (event.currentTarget !== event.target) return
@@ -341,6 +366,7 @@ export function SessionScreen(props: { session: SessionModel }) {
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !paneAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -349,13 +375,13 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
@@ -365,15 +391,15 @@ export function SessionScreen(props: { session: SessionModel }) {
data-slot="session-side-terminal-region"
classList={{
"relative z-10 min-h-0 shrink-0 overflow-visible transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion,
"transition-none": screen.size.active() || !store.sideHeightMotion,
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
}}
style={{ height: screen.side.terminal.height() }}
>
<Show when={store.sideTerminalPresent}>
<div
data-slot="side-terminal-panel-presence"
data-opened={sideTerminalVisible()}
data-opened={sideMotion().animateTerminal ? sideMotion().terminal : undefined}
class="absolute inset-0 rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]"
>
<div data-slot="side-terminal-panel-clip" class="size-full overflow-clip rounded-[10px]">
@@ -381,6 +407,7 @@ export function SessionScreen(props: { session: SessionModel }) {
fill
framed={false}
present={store.sideTerminalPresent}
animate={sidePresence.animate() || sideMotion().animateTerminal}
contentHeight={screen.side.terminal.contentHeight()}
/>
</div>
@@ -397,7 +424,7 @@ export function SessionScreen(props: { session: SessionModel }) {
<div
ref={(element) => setElements("bottomTerminal", element)}
data-slot="terminal-panel-presence"
data-opened={bottomTerminalVisible()}
data-opened={bottomTerminalPresence.animate() ? bottomTerminalPresence.show() : undefined}
classList={{
hidden: !bottomTerminalPresence.present(),
"relative min-h-0 shrink-0": isDesktop(),
@@ -408,19 +435,23 @@ export function SessionScreen(props: { session: SessionModel }) {
<ResizeHandle
class="!relative !inset-auto !h-full !w-full !transform-none"
direction="vertical"
size={layout.terminal.height()}
size={session.layout.view().terminal.height()}
min={100}
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={(height) => {
screen.size.touch()
layout.terminal.resize(height)
session.layout.view().terminal.resize(height)
}}
onCollapse={() => session.layout.view().terminal.close()}
/>
</div>
</Show>
<TerminalPanel stacked={isDesktop()} present={store.bottomTerminalCached} />
<TerminalPanel
stacked={isDesktop()}
present={store.bottomTerminalCached}
animate={bottomTerminalPresence.animate()}
/>
</div>
</Show>
</div>
+23 -1
View File
@@ -5,6 +5,8 @@ import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { base64Encode } from "@opencode-ai/util/encode"
import { ServerConnection } from "@/runtime/server/registry"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
export const useSessionKey = () => {
const params = useParams()
@@ -19,12 +21,32 @@ export const useSessionKey = () => {
export const useSessionLayout = () => {
const layout = useLayout()
const tabs = useTabs()
const { params, sessionKey, workspaceKey } = useSessionKey()
const serverSDK = useServerSDK()
const currentTab = createMemo(() => {
if (!params.id) return
return findSessionTab(tabs.store, ServerConnection.key(serverSDK.server), params.id)
})
const panes = {
terminalOpened: () => tabs.pane(currentTab(), "terminal"),
setTerminalOpened: (opened: boolean) => tabs.setPane(currentTab(), "terminal", opened),
terminalHeight: () => tabs.paneSize(currentTab(), "terminalHeight"),
setTerminalHeight: (height: number) => tabs.setPaneSize(currentTab(), "terminalHeight", height),
reviewOpened: () => tabs.pane(currentTab(), "review"),
setReviewOpened: (opened: boolean) => tabs.setPane(currentTab(), "review", opened),
sessionWidth: () => tabs.paneSize(currentTab(), "sessionWidth"),
setSessionWidth: (width: number) => tabs.setPaneSize(currentTab(), "sessionWidth", width),
}
return {
params,
sessionKey,
workspaceKey,
tabKey: createMemo(() => {
const tab = currentTab()
return tab && tabKey(tab)
}),
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
view: createMemo(() => layout.view(sessionKey, panes)),
}
}
+4 -4
View File
@@ -17,7 +17,6 @@ import { SortableTerminalTab } from "@/session/terminal/tab"
import { Terminal } from "@/session/terminal/terminal"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { useTerminal, type LocalPTY } from "@/session/terminal/context"
import { useWorkspaceLocation } from "@/workspaces/location"
import { terminalTabLabel } from "@/session/terminal/terminal-label"
@@ -45,9 +44,9 @@ export function TerminalPanel(
present?: boolean
contentHeight?: string
embedded?: boolean
animate?: boolean
} = {},
) {
const layout = useLayout()
const terminal = useTerminal()
const sdk = useWorkspaceLocation()
const language = useLanguage()
@@ -57,7 +56,7 @@ export function TerminalPanel(
const isDesktop = createMediaQuery("(min-width: 768px)")
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const height = createMemo(() => view().terminal.height())
const close = () => view().terminal.close()
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
@@ -238,10 +237,11 @@ export function TerminalPanel(
pane={pane()}
max={max()}
resizing={size.active()}
animate={props.animate}
onResizeStart={size.start}
onResize={(next) => {
size.touch()
layout.terminal.resize(next)
view().terminal.resize(next)
}}
onCollapse={close}
>
@@ -15,6 +15,7 @@ export function TerminalSurface(
pane: number
max: number
resizing: boolean
animate?: boolean
onResizeStart: () => void
onResize: (height: number) => void
onCollapse: () => void
@@ -27,7 +28,9 @@ export function TerminalSurface(
id="terminal-panel"
data-component="terminal-panel"
data-opened={props.opened}
data-size-animated={!props.embedded && !props.resizing && (!props.desktop || props.stacked)}
data-size-animated={
props.animate !== false && !props.embedded && !props.resizing && (!props.desktop || props.stacked)
}
role="region"
aria-label={props.label}
aria-hidden={!props.opened}
@@ -478,7 +478,11 @@ export function createTimelineVirtualizer(input: Input) {
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={props.workspaceSession() ? "" : undefined}>
<div
class="relative w-full h-full min-w-0"
data-workspace-session={props.workspaceSession() ? "" : undefined}
data-local-session={!props.workspaceSession() ? "" : undefined}
>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
+49 -3
View File
@@ -59,6 +59,16 @@ export type HomeProjectSelection = { server: ServerConnection.Key; directory?: s
export type ReviewDiffStyle = "unified" | "split"
export type ReviewChangeMode = "git" | "branch" | "turn"
export type ReviewPanelSource = "context-button" | "other"
export type TabPanes = {
terminalOpened: Accessor<boolean>
setTerminalOpened(opened: boolean): void
terminalHeight: Accessor<number | undefined>
setTerminalHeight(height: number): void
reviewOpened: Accessor<boolean>
setReviewOpened(opened: boolean): void
sessionWidth: Accessor<number | undefined>
setSessionWidth(width: number): void
}
export type LayoutRoute =
| { type: "home" }
@@ -508,7 +518,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return message
},
},
view(sessionKey: string | Accessor<string>) {
view(sessionKey: string | Accessor<string>, panes?: TabPanes) {
const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const reviewMode = createMemo(() => {
@@ -519,11 +529,24 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const file = s().reviewFile
if (typeof file === "string") return file
})
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const terminalOpened = panes?.terminalOpened ?? createMemo(() => store.terminal?.opened ?? false)
const terminalHeight = createMemo(() =>
panes
? (panes.terminalHeight() ?? DEFAULT_TERMINAL_HEIGHT)
: (store.terminal?.height ?? DEFAULT_TERMINAL_HEIGHT),
)
const reviewPanelOpened =
panes?.reviewOpened ?? createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED)
const sessionWidth = createMemo(() =>
panes ? (panes.sessionWidth() ?? DEFAULT_SESSION_WIDTH) : store.session.width,
)
const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other"))
function setTerminalOpened(next: boolean) {
if (panes) {
panes.setTerminalOpened(next)
return
}
const current = store.terminal
if (!current) {
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
@@ -537,6 +560,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) {
const nextSource = next ? source : "other"
if (panes) {
batch(() => {
panes.setReviewOpened(next)
setEphemeral("reviewPanelSource", nextSource)
})
return
}
const current = store.review
if (!current) {
batch(() => {
@@ -566,6 +596,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
opened: terminalOpened,
height: terminalHeight,
resize(height: number) {
if (panes) {
panes.setTerminalHeight(height)
return
}
setStore("terminal", "height", height)
},
open() {
setTerminalOpened(true)
},
@@ -579,6 +617,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
reviewPanel: {
opened: reviewPanelOpened,
source: reviewPanelSource,
width: sessionWidth,
resize(width: number) {
if (panes) {
panes.setSessionWidth(width)
return
}
setStore("session", "width", width)
},
open(source: ReviewPanelSource = "other") {
setReviewPanelOpened(true, source)
},
+6 -2
View File
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { migrateTabs } from "./migration"
import type { ServerConnection } from "@/runtime/server/registry"
@@ -48,8 +48,12 @@ test("session tab identity stays rooted while its href follows the child route",
})
test("finds open root and routed session tabs", () => {
const tabs = [{ ...sessionTab("root"), routeSessionId: "child" }]
const tab = { ...sessionTab("root"), routeSessionId: "child" }
const tabs = [tab]
expect(findSessionTab(tabs, server, "root")).toBe(tab)
expect(findSessionTab(tabs, server, "child")).toBe(tab)
expect(findSessionTab(tabs, server, "closed")).toBeUndefined()
expect(sessionIDHasOpenTab(tabs, server, "root")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "child")).toBe(true)
expect(sessionIDHasOpenTab(tabs, server, "closed")).toBe(false)
+60 -3
View File
@@ -46,6 +46,10 @@ export type TabInfo = {
directory?: string
}
export type TabPane = "terminal" | "review"
export type TabPaneSize = "terminalHeight" | "sessionWidth"
type TabPaneState = Partial<Record<TabPane, boolean> & Record<TabPaneSize, number>>
type RecentTab = {
key?: string
}
@@ -62,8 +66,8 @@ export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, ses
return sessionIDHasOpenTab(tabs, server, session.id)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.some(
export function findSessionTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return tabs.find(
(tab) =>
tab.type === "session" &&
tab.server === server &&
@@ -71,6 +75,10 @@ export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, s
)
}
export function sessionIDHasOpenTab(tabs: Tab[], server: ServerConnection.Key, sessionID: string) {
return !!findSessionTab(tabs, server, sessionID)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
@@ -89,6 +97,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
Persist.window("tabs.info"),
createStore<Record<string, TabInfo>>({}),
)
const [panes, setPanes, , panesReady] = persisted(
Persist.window("tabs.panes"),
createStore<Record<string, TabPaneState>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
@@ -141,6 +153,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
}
const removePanes = (key: string) => {
if (!panes[key]) return
setPanes(
produce((draft) => {
delete draft[key]
}),
)
}
onCleanup(memory.dispose)
createEffect(() => {
@@ -153,6 +174,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
removePanes(key)
}
}
setStore(() => next)
@@ -162,6 +184,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of Object.keys(info)) {
if (!keys.has(key)) removeInfo(key)
}
if (!panesReady()) return
for (const key of Object.keys(panes)) {
if (!keys.has(key)) removePanes(key)
}
})
createEffect(() => {
@@ -198,6 +224,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}).finally(() => closing.delete(key))
memory.remove(key)
removeInfo(key)
removePanes(key)
if (draftID) removeDraftPersisted(draftID)
}
@@ -491,8 +518,38 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
pane(tab: Tab | undefined, pane: TabPane) {
if (!tab) return false
return panes[tabKey(tab)]?.[pane] ?? false
},
setPane(tab: Tab | undefined, pane: TabPane, opened: boolean) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[pane] === opened) return
if (!current) {
setPanes(key, { [pane]: opened })
return
}
setPanes(key, pane, opened)
},
paneSize(tab: Tab | undefined, size: TabPaneSize) {
if (!tab) return
return panes[tabKey(tab)]?.[size]
},
setPaneSize(tab: Tab | undefined, size: TabPaneSize, value: number) {
if (!tab) return
const key = tabKey(tab)
const current = panes[key]
if (current?.[size] === value) return
if (!current) {
setPanes(key, { [size]: value })
return
}
setPanes(key, size, value)
},
}
return { ...actions, store, info, ready, infoReady, recentReady }
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
},
})
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
import { batch, createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
@@ -47,3 +47,22 @@ test("animates the first appearance when initially hidden", () => {
dispose()
})
})
test("does not animate visibility changes across identities", () => {
createRoot((dispose) => {
const [identity, setIdentity] = createSignal("a")
const [value, setValue] = createSignal<string | undefined>("visible")
const presence = createAnimatedPresence(value, () => null, identity)
expect(presence.animate()).toBe(false)
batch(() => {
setIdentity("b")
setValue(undefined)
})
expect(presence.animate()).toBe(false)
setValue("visible")
expect(presence.animate()).toBe(true)
dispose()
})
})
+18
View File
@@ -199,6 +199,24 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
},
}),
Spec.make("check", {
description: "Check package plugins for updates",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target"),
Argument.optional,
),
},
}),
Spec.make("update", {
description: "Update package plugins",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
Argument.optional,
),
},
}),
Spec.make("remove", {
description: "Remove a plugin from global configuration",
params: {
@@ -0,0 +1,14 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { format, inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.check,
Effect.fn("cli.plugin.check")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
process.stdout.write((format(result.items) || "No package plugins found") + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
}),
)
@@ -0,0 +1,101 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { Npm } from "@opencode-ai/util/npm"
import { Config } from "../../../config"
import { ServiceConfig } from "../../../services/service-config"
export interface Item {
readonly runtime: "Server" | "TUI"
readonly target: string
readonly name: string
readonly version?: string
readonly outdated: boolean
readonly error?: string
}
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
const location = { directory: process.cwd() }
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
const serverTargets = new Set(
listed.data.flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
)
const server =
selected === undefined || serverTargets.has(selected)
? yield* Effect.promise(() => client.plugin.check({ location, ...(selected ? { target: selected } : {}) }))
: listed
const serverItems = server.data.flatMap((plugin): Item[] => {
if (plugin.source.type !== "package") return []
if (selected !== undefined && plugin.source.target !== selected) return []
return [
{
runtime: "Server",
target: plugin.source.target,
name: plugin.id ?? plugin.source.target,
...(plugin.source.version ? { version: displayVersion(plugin.source.version) } : {}),
outdated: plugin.source.outdated === true,
...(plugin.state.status === "failed" ? { error: plugin.state.error } : {}),
},
]
})
const config = yield* Config.Service
const info = yield* config.get()
const configured = [
...new Set(
(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) return []
return [target]
}),
),
]
const tuiTargets = yield* Effect.promise(async () => {
const installable = await Promise.all(configured.map((target) => Npm.isInstallablePackage(target)))
return configured.filter((target, index) => installable[index] && (selected === undefined || target === selected))
})
const npm = yield* Npm.Service
const tuiItems = yield* Effect.forEach(
tuiTargets,
(target) =>
Effect.gen(function* () {
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
const outdated = yield* npm.check(target).pipe(Effect.exit)
return {
runtime: "TUI" as const,
target,
name: target,
...(installed.version ? { version: displayVersion(installed.version) } : {}),
outdated: Exit.isSuccess(outdated) && outdated.value,
...(Exit.isFailure(outdated) ? { error: Cause.pretty(outdated.cause) } : {}),
}
}),
{ concurrency: "unbounded" },
)
const items = [...serverItems, ...tuiItems]
if (selected !== undefined && !items.length) return yield* Effect.fail(new Error(`Plugin is not configured: ${selected}`))
return { client, location, items }
})
export function displayVersion(version: string) {
return /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(version) ? version.slice(0, 7) : version
}
export function format(items: readonly Item[]) {
return ["Server", "TUI"]
.flatMap((runtime) => {
const rows = items.filter((item) => item.runtime === runtime)
if (!rows.length) return []
return [
runtime,
...rows.map(
(item) =>
` ${item.name}${item.version ? ` ${item.version}` : ""} (${item.error ? "check failed" : item.outdated ? "update available" : "current"})`,
),
]
})
.join(EOL)
}
@@ -8,7 +8,9 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
import { Npm } from "@opencode-ai/util/npm"
import { fileURLToPath } from "node:url"
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
export default Runtime.handler(
Commands.commands.plugin.commands.list,
@@ -22,15 +24,23 @@ export default Runtime.handler(
const discovered = yield* Effect.promise(() =>
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const npm = yield* Npm.Service
const configured = yield* Effect.forEach(info.plugins ?? [], (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 (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
if (!installed.entrypoint) return []
return [{ target, version: installed.version }]
}),
)
const output = format(
response.data,
[
...(info.plugins ?? []).flatMap((entry) => {
const target = typeof entry === "string" ? entry : entry.package
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
}),
...discovered.map((target) => ({ target, source: "discovered" as const })),
],
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
input.builtin,
)
if (!output) {
@@ -43,37 +53,50 @@ export default Runtime.handler(
export function format(
plugins: readonly PluginInfo[],
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
tui: ReadonlyArray<{ readonly target: string; readonly version?: string }>,
builtin = false,
) {
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.map((plugin) => ({
id: plugin.id ?? "-",
version:
plugin.source.type === "package"
? (plugin.source.version ?? "-")
: plugin.source.type === "local"
? "local"
: "-",
target:
plugin.source.type === "package"
? plugin.source.target
: plugin.source.type === "local"
? plugin.source.path
: plugin.source.type,
}))
const targets = tui
.filter(
(item) =>
!plugins.some((plugin) =>
plugin.source.type === "package"
? 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))),
),
)
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
.toSorted((a, b) => a.target.localeCompare(b.target))
.map((plugin) => `${plugin.target} (${plugin.source})`)
return [
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
server.length ? ["Server", ...server].join(EOL) : undefined,
]
.filter((section) => section !== undefined)
.join(EOL + EOL)
}
function name(plugin: PluginInfo) {
if (plugin.id) return plugin.id
if (plugin.source.type === "package") return plugin.source.package
if (plugin.source.type === "local") return plugin.source.path
return plugin.source.type
.map((plugin) => ({ id: "-", version: plugin.version ?? "-", target: plugin.target }))
const rows = [...server, ...targets]
.toSorted((a, b) => a.id.localeCompare(b.id) || a.target.localeCompare(b.target))
.map((item) => [
item.id,
/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(item.version) ? item.version.slice(0, 7) : item.version,
item.target,
])
if (!rows.length) return ""
const table = [["ID", "VERSION", "SOURCE"], ...rows]
const widths = [0, 1].map((index) => Math.max(...table.map((row) => row[index].length)))
return table.map((row) => `${row[0].padEnd(widths[0])} ${row[1].padEnd(widths[1])} ${row[2]}`).join(EOL)
}
@@ -0,0 +1,44 @@
import { EOL } from "node:os"
import { Cause, Effect, Exit, Option } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { inspect } from "./inventory"
export default Runtime.handler(
Commands.commands.plugin.commands.update,
Effect.fn("cli.plugin.update")(function* (input) {
const result = yield* inspect(Option.getOrUndefined(input.target))
for (const item of result.items) {
if (!item.error) continue
process.stderr.write(`Failed to check ${item.runtime} plugin "${item.name}": ${item.error}${EOL}`)
}
const selected = result.items.filter((item) => item.outdated)
if (!selected.length) {
process.stdout.write("No plugin updates available" + EOL)
if (result.items.some((item) => item.error)) process.exitCode = 1
return
}
const npm = yield* Npm.Service
const updated = yield* Effect.forEach(
selected,
(item) =>
(item.runtime === "Server"
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
{ concurrency: "unbounded" },
)
for (const item of updated) {
if (Exit.isSuccess(item.result)) {
process.stdout.write(`Updated ${item.item.runtime} plugin "${item.item.name}"${EOL}`)
continue
}
process.stderr.write(
`Failed to update ${item.item.runtime} plugin "${item.item.name}": ${Cause.pretty(item.result.cause)}${EOL}`,
)
process.exitCode = 1
}
}),
)
+19 -84
View File
@@ -1,5 +1,7 @@
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
import { TokenUsage } from "@opencode-ai/schema/token-usage"
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
import { Effect, Option } from "effect"
import { EOL } from "node:os"
import { Commands } from "../commands"
@@ -91,7 +93,7 @@ type RenderOptions = {
const colors = terminalPalette()
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
const totalTokens = tokenTotal(stats.tokens)
const totalTokens = TokenUsage.total(stats.tokens)
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
@@ -167,43 +169,23 @@ function renderActivity(
color: boolean,
width: number,
) {
const values = new Map(activity.map((day) => [day.date, day.steps]))
const rangeStart = dateOrdinal(new Date(from))
const rangeEnd = dateOrdinal(new Date(to - 1))
const end = new Date(to - 1)
end.setHours(12, 0, 0, 0)
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
const start = new Date(from)
start.setHours(12, 0, 0, 0)
start.setDate(start.getDate() - mondayIndex(start))
const maxWeeks = Math.max(1, Math.min(53, width - 4))
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
const latest = new Date(end)
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
if (start < latest) start.setTime(latest.getTime())
const active = [...values.values()].filter((value) => value > 0)
const levels = [...new Set(active)].sort((a, b) => a - b)
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
const date = new Date(start)
date.setDate(date.getDate() + week * 7)
return date
})
const weeks = weekStarts.map((week) =>
Array.from({ length: 7 }, (_, day) => {
const date = new Date(week)
date.setDate(date.getDate() + day)
const ordinal = dateOrdinal(date)
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
}),
)
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
const months = calendar.months
.map((month, index) =>
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
)
.join("")
.trimEnd()
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
return [
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
` ${style(monthLabels(weekStarts), "2", color)}`,
style(
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
`1;${colors.primary}`,
color,
),
` ${style(months, "2", color)}`,
...weekdays.flatMap((label, day) => [
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
...(day === weekdays.length - 1 ? [] : [""]),
]),
"",
@@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
width,
),
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
]),
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
]
@@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
...models.map((item) =>
tableRow(
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
formatNumber(tokenTotal(item.tokens)),
formatNumber(TokenUsage.total(item.tokens)),
formatNumber(item.steps),
`$${item.cost.toFixed(2)}`,
),
@@ -310,10 +292,6 @@ function truncate(value: string, width: number) {
return value.length <= width ? value : value.slice(0, width - 1) + "…"
}
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}
function formatNumber(value: number) {
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
@@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) {
return color ? `\x1b[${code}m${value}\x1b[0m` : value
}
function activityGlyph(value: number, levels: number[], color: boolean) {
if (value === 0) return paintActivity(0, color)
const index = levels.indexOf(value)
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
return paintActivity(level, color)
}
function paintActivity(level: number, color: boolean) {
const glyph = ["·", "░", "▒", "▓", "█"][level]
if (!color) return glyph
@@ -370,39 +341,3 @@ function terminalPalette() {
}
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
}
function monthLabels(weeks: Date[]) {
const line: string[] = []
weeks.reduce((previous, week, index) => {
const middle = new Date(week)
middle.setDate(middle.getDate() + 3)
const month = middle.getMonth()
if (month === previous) return previous
Intl.DateTimeFormat("en-US", { month: "short" })
.format(middle)
.split("")
.forEach((character, offset) => {
line[index + offset] = character
})
return month
}, -1)
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
.join("")
.trimEnd()
}
function mondayIndex(date: Date) {
return (date.getDay() + 6) % 7
}
function dateKey(date: Date) {
return [
date.getFullYear(),
String(date.getMonth() + 1).padStart(2, "0"),
String(date.getDate()).padStart(2, "0"),
].join("-")
}
function dateOrdinal(date: Date) {
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
}
+2
View File
@@ -42,6 +42,8 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
add: () => import("./commands/handlers/plugin/add"),
check: () => import("./commands/handlers/plugin/check"),
update: () => import("./commands/handlers/plugin/update"),
remove: () => import("./commands/handlers/plugin/remove"),
},
models: () => import("./commands/handlers/models"),
-3
View File
@@ -85,8 +85,6 @@ test("migrates tui and kv config into cli.json", async () => {
session_delete: false,
"dialog.select.next": "ctrl+n",
},
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
scroll_speed: 2,
scroll_acceleration: { enabled: true },
@@ -135,7 +133,6 @@ test("migrates tui and kv config into cli.json", async () => {
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
attention: { sound_pack: "custom.pack" },
-27
View File
@@ -1,27 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
import { tmpdir } from "./fixture/tmpdir"
test("routes packages according to their exported runtimes", () => {
expect(configurationTarget("server.js", "tui.js")).toBe("server")
expect(configurationTarget("server.js", undefined)).toBe("server")
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
expect(configurationTarget(undefined, undefined)).toBeUndefined()
})
test("adds a package to global plugin config without replacing unrelated settings", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "opencode.jsonc")
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({
model: "provider/model",
plugins: ["first", "second@1.0.0"],
})
})
-64
View File
@@ -1,64 +0,0 @@
import { expect, test } from "bun:test"
import { EOL } from "node:os"
import { format } from "../src/commands/handlers/plugin/list"
test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", package: "broken-plugin" },
state: { status: "failed", error: "broken" },
features: { server: true },
},
{
id: "local.dual",
source: { type: "local", path: "/tmp/local/index.ts" },
state: { status: "active" },
features: { server: true, tui: true },
},
],
[
{ target: "tui-only", source: "configured" },
{ target: "/tmp/local.ts", source: "discovered" },
],
),
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
})
test("includes builtins when requested", () => {
expect(
format(
[
{
id: "opencode.agent",
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
],
[],
true,
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
-20
View File
@@ -1,20 +0,0 @@
import { expect, test } from "bun:test"
import path from "node:path"
import { parse } from "jsonc-parser"
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
import { tmpdir } from "./fixture/tmpdir"
test("removes string and object package entries without replacing unrelated settings", async () => {
await using directory = await tmpdir()
const file = path.join(directory.path, "opencode.jsonc")
await Bun.write(
file,
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
)
expect(await removePluginConfig(file, "remove-me")).toBe(true)
expect(await removePluginConfig(file, "remove-me")).toBe(false)
const text = await Bun.file(file).text()
expect(text).toContain("// retained")
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
})
+14
View File
@@ -48,6 +48,20 @@ describe("stats rendering", () => {
expect(output).not.toContain("$12.34")
})
test("renders shared intensity levels and partial month labels", () => {
const output = renderStats(
{
...stats,
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
activity: [{ date: "2026-04-29", steps: 1 }],
},
options(),
)
expect(output).toContain(" May")
expect(output).not.toContain("Apr")
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
})
test("renders only requested detail tables", () => {
const output = renderStats(stats, options({ tools: true, cost: true }))
expect(output).toContain("COST & TOKENS")
+7 -1
View File
@@ -198,7 +198,13 @@ test("Node distribution honors the compile-time CLI name", async () => {
"--test-name-pattern",
"^Node distribution resolves the published npm package$",
],
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
{
cwd: path.join(import.meta.dir, ".."),
stdout: "ignore",
stderr: "pipe",
// Bun 1.4 can reuse cached modules compiled with different --define values.
env: { ...process.env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: "0" },
},
)
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(code, stderr).toBe(0)
+16
View File
@@ -88,8 +88,24 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target?: string | undefined
}
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
export type PluginUpdateInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target: string
}
export type PluginUpdateOutput = void
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
}
export type SessionListInput = {
+23 -1
View File
@@ -15,6 +15,10 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -322,7 +326,25 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
preserveEffect<PluginUpdateOutput>()(
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
})
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
preserveEffect<SessionListOutput>()(
@@ -9,6 +9,10 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
PluginUpdateOutput,
SessionListInput,
SessionListOutput,
SessionStatsInput,
@@ -466,6 +470,32 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{
method: "POST",
path: `/api/plugin/check`,
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
request<PluginUpdateOutput>(
{
method: "POST",
path: `/api/plugin/update`,
query: { location: input["location"] },
body: { target: input["target"] },
successStatus: 204,
declaredStatuses: [400, 503, 401],
empty: true,
},
requestOptions,
),
},
session: {
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
+34 -10
View File
@@ -12,13 +12,13 @@ export type PermissionEffect = "allow" | "deny" | "ask"
export type PluginSource =
| { type: "builtin" }
| { type: "package"; package: string }
| { type: "package"; target: string; version?: string; outdated?: true }
| { type: "local"; path: string }
| { type: "sdk" }
export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
export type PluginState = { status: "active" } | { status: "failed"; error: string }
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
@@ -214,6 +214,7 @@ export type GenerateTextResponse = { data: { text: string } }
export type ProviderInfo = {
id: string
canonical?: string
integrationID?: string
name: string
activation: "auto" | "enabled" | "disabled"
@@ -1816,6 +1817,7 @@ export type ModelInfo = {
id: string
modelID: string
providerID: string
canonical?: string
family?: string
name: string
compatibility?: ModelCompatibility
@@ -1993,6 +1995,7 @@ export type ConfigEntry =
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
canonical?: string
name?: string
env?: Array<string>
package?: string
@@ -2354,6 +2357,14 @@ export type AgentNotFoundError = {
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
@@ -2415,14 +2426,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
readonly service?: string | undefined
}
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
@@ -2582,6 +2585,27 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target?: { readonly target?: string | undefined }["target"]
}
export type PluginCheckOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PluginInfo>
}
export type PluginUpdateInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly target: { readonly target: string }["target"]
}
export type PluginUpdateOutput = void
export type SessionListInput = {
readonly workspace?: {
readonly workspace?: string | undefined
+53 -15
View File
@@ -147,7 +147,7 @@ function createSync() {
state.set(key, entry)
entry.promise = (wait ? wait.catch(() => undefined).then(load) : load())
.then(() => {
if (state.get(key) === entry) state.set(key, true)
if (state.get(key) === entry && !entry.invalidated) state.set(key, true)
})
.finally(() => {
if (state.get(key) === entry) state.delete(key)
@@ -493,6 +493,33 @@ export function createData(config: CreateDataInput) {
)
}
function evictSession(sessionID: string) {
if (sessionOutbox.has(sessionID)) return
sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`)
messageLoads.delete(sessionID)
// Keep unacknowledged submissions until their echo or rollback settles them.
const pending = store.session.pending[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
const messages = store.session.message[sessionID]?.filter((item) => outbox.has(item.id)) ?? []
messageIndex.delete(sessionID)
if (messages.length) messageIndex.set(sessionID, new Map(messages.map((item, index) => [item.id, index])))
setStore(
"session",
produce((draft) => {
delete draft.message[sessionID]
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)
}
}),
)
}
function removeSession(sessionID: string) {
activeUpdates?.set(sessionID, undefined)
store.session.pending[sessionID]?.forEach((item) => outbox.delete(item.id))
@@ -1260,9 +1287,11 @@ export function createData(config: CreateDataInput) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
batch(() => {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
registerSession(info.id)
})
},
setStatus(sessionID: string, status: DataSessionStatus) {
setSessionActive(sessionID, status)
@@ -1273,6 +1302,13 @@ export function createData(config: CreateDataInput) {
family(sessionID: string) {
return store.session.family[resolveRoot(sessionID)] ?? []
},
/** Clear heavy cached data for the root and all known descendants. */
evict(sessionID: string) {
const root = resolveRoot(sessionID)
batch(() => {
for (const id of new Set([root, sessionID, ...(store.session.family[root] ?? [])])) evictSession(id)
})
},
cost(sessionID: string) {
const session = store.session.info[sessionID]
if (!session) return 0
@@ -1484,17 +1520,19 @@ export function createData(config: CreateDataInput) {
: [],
])
const sessions = [info, ...children]
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
})
},
invalidate(sessionID: string) {
-2
View File
@@ -45,7 +45,6 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
sessionID: "ses_test",
key: "review-notes",
})
const emptyRpcOutput: Awaited<ReturnType<typeof promiseClient.rpc.call>> = {}
void [
effectSession,
@@ -55,7 +54,6 @@ void [
promiseList,
promisePut,
promiseRemove,
emptyRpcOutput,
exactVersion,
compatibleVersion,
]
+23 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { DateTime, Effect, Stream } from "effect"
import { Context, DateTime, Effect, Stream } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import {
AbsolutePath,
@@ -135,6 +135,28 @@ test("event.subscribe exposes and decodes the native Effect event stream", async
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("shared event source runs with the Effect context captured by make", async () => {
const connected = { id: "evt_connected", type: "server.connected", data: {} }
const Token = Context.Reference("test/effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("event.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
-495
View File
@@ -1,495 +0,0 @@
import { expect, test } from "bun:test"
import { Rpc } from "@opencode-ai/schema/rpc"
import { Cause, Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { OpenCode } from "../src/effect/index"
const definition = Rpc.define({
id: "example",
methods: {
count: {
input: Schema.Struct({ count: Schema.FiniteFromString }),
output: Schema.FiniteFromString,
errors: { too_large: Schema.Struct({ limit: Schema.FiniteFromString }) },
},
echo: { input: Schema.Json, output: Schema.Json },
empty: { input: Schema.Undefined, output: Schema.Undefined },
raw: { input: { type: "string" }, output: { type: "number" } },
},
events: {
progress: { schema: Schema.Struct({ count: Schema.FiniteFromString }) },
message: { schema: Schema.Struct({ text: Schema.String }) },
},
})
const connected = { id: "evt_connected", type: "server.connected", data: {} }
function rpcEvent(count: unknown, directory = "/project/one", rpcID = "example", name = "progress") {
return {
id: "evt_progress",
created: 123,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { origin: "test" },
data: { count },
}
}
function eventSource() {
const requests: HttpClientRequest.HttpClientRequest[] = []
const opened = Promise.withResolvers<{
controller: ReadableStreamDefaultController<Uint8Array>
signal: AbortSignal
}>()
const cancelled = Promise.withResolvers<void>()
return {
requests,
opened: opened.promise,
cancelled: cancelled.promise,
async push(event: unknown) {
const source = await opened.promise
source.controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`))
},
httpClient: HttpClient.make((request, _url, signal) => {
requests.push(request)
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
opened.resolve({ controller, signal })
},
cancel() {
cancelled.resolve()
},
}),
{ headers: { "content-type": "text/event-stream" } },
),
),
)
}),
}
}
test("Effect RPC calls retain encoded inputs, decode outputs, and preserve raw native RPC calls", async () => {
const requests: Array<{ url: string; body: unknown }> = []
const httpClient = HttpClient.make((request) => {
const body = request.body._tag === "Uint8Array" ? JSON.parse(new TextDecoder().decode(request.body.body)) : {}
requests.push({ url: request.url, body })
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json({
output: request.url.endsWith("/count") ? "42" : request.url.endsWith("/raw") ? 7 : body.input,
}),
),
)
})
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: new URL("http://localhost:3000") })
const rpc = client.rpc(definition)
const count = yield* rpc.count({ count: "2" })
const primitives = yield* Effect.forEach([null, false, 0, "hello", [1, "two"]], (value) => rpc.echo(value))
const empty = yield* rpc.empty()
const raw = yield* rpc.raw("input")
const native = yield* client.rpc.call({ rpcID: "example", method: "count", input: null })
expect(Object.keys(rpc.events)).toEqual(["subscribe"])
return { count, primitives, empty, raw, native }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result).toEqual({
count: 42,
primitives: [null, false, 0, "hello", [1, "two"]],
empty: undefined,
raw: 7,
native: { output: "42" },
})
expect(requests[0]).toEqual({ url: "http://localhost:3000/api/rpc/example/count", body: { input: { count: "2" } } })
expect(requests.find((request) => request.url.endsWith("/empty"))?.body).toEqual({})
})
test("Effect RPC trusts server-side Standard Schema transforms for outputs and events", async () => {
const validations: unknown[] = []
const standard = {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: String(value) + " transformed" }
},
},
}
const service = Rpc.define({
id: "standard",
methods: { transform: { input: standard, output: standard } },
events: {
transformed: {
schema: {
"~standard": {
version: 1 as const,
vendor: "fixture",
validate(value: unknown) {
validations.push(value)
return { value: { text: String(value) + " transformed" } }
},
},
},
},
},
})
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
request.url.endsWith("/api/event")
? new Response(
`data: ${JSON.stringify({ ...rpcEvent(1), type: "rpc.standard.transformed", data: { text: "done" } })}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
)
: Response.json({ output: "done" }),
),
),
)
const result = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const rpc = client.rpc(service)
return {
output: yield* rpc.transform("input"),
events: yield* Stream.runCollect(rpc.events.subscribe("transformed")),
}
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(result.output).toBe("done")
expect(result.events[0].data).toEqual({ text: "done" })
expect(validations).toEqual([])
})
test("Effect RPC validates decoded outputs in the failure channel", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ output: "not a number" })))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* Effect.flip(client.rpc(definition).count({ count: "1" }))
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Schema.isSchemaError(error)).toBe(true)
expect(requests).toEqual(["http://localhost:3000/api/rpc/example/count"])
})
test("Effect RPC decodes declared errors and removes the generic transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcError", type: "too_large", message: "Too large", data: { limit: "3" } },
{ status: 400 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "too_large", message: "Too large", data: { limit: 3 } })
})
test("Effect RPC removes the internal transport wrapper", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" },
{ status: 500 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.rpc(definition).count({ count: "4" }).pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual({ type: "rpc.internal", message: "Failed" })
})
test("Effect RPC isolates per-call location and headers while preserving configured defaults and native behavior", async () => {
const requests: Array<{ url: URL; headers: HttpClientRequest.HttpClientRequest["headers"] }> = []
const release = Promise.withResolvers<void>()
const started = Promise.withResolvers<void>()
const httpClient = HttpClient.make((request, url) => {
requests.push({ url, headers: request.headers })
if (requests.length === 1) started.resolve()
return Effect.promise(() => release.promise).pipe(
Effect.as(
HttpClientResponse.fromWeb(
request,
url.pathname.endsWith("/health")
? Response.json({ healthy: true, version: "test", pid: 1 })
: Response.json({ output: "3" }),
),
),
)
}).pipe(HttpClient.mapRequest(HttpClientRequest.setHeaders({ authorization: "Bearer base", "x-default": "base" })))
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const first = Effect.runPromise(
rpc.count(
{ count: "1" },
{ location: { directory: "/project/one", workspace: "one" }, headers: { "x-call": "one" } },
),
)
await started.promise
const second = Effect.runPromise(
rpc.count(
{ count: "2" },
{ location: { directory: "/project/two" }, headers: new Headers({ "x-call": "two", "x-default": "override" }) },
),
)
const native = Effect.runPromise(client.health.get())
release.resolve()
expect(await Promise.all([first, second])).toEqual([3, 3])
expect(await native).toEqual({ healthy: true, version: "test", pid: 1 })
expect(requests.map((request) => request.headers.authorization)).toEqual([
"Bearer base",
"Bearer base",
"Bearer base",
])
expect(requests.map((request) => request.headers["x-call"])).toEqual(["one", "two", undefined])
expect(requests.map((request) => request.headers["x-default"])).toEqual(["base", "override", "base"])
expect(requests.map((request) => request.url.searchParams.get("location[directory]"))).toEqual([
"/project/one",
"/project/two",
null,
])
expect(requests.map((request) => request.url.searchParams.get("location[workspace]"))).toEqual(["one", null, null])
})
test("RPC signals and consumer interruption abort only their own HTTP calls", async () => {
const started: Array<ReturnType<typeof Promise.withResolvers<AbortSignal>>> = [
Promise.withResolvers<AbortSignal>(),
Promise.withResolvers<AbortSignal>(),
]
const signals: AbortSignal[] = []
const finalized: number[] = []
const httpClient = HttpClient.make((_request, _url, signal) => {
const index = signals.length
signals.push(signal)
started[index].resolve(signal)
return Effect.never.pipe(Effect.ensuring(Effect.sync(() => finalized.push(index))))
})
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)),
)
const rpc = client.rpc(definition)
const abort = new AbortController()
const first = Effect.runFork(rpc.count({ count: "1" }, { signal: abort.signal }))
const second = Effect.runFork(rpc.count({ count: "2" }))
await Promise.all(started.map((entry) => entry.promise))
abort.abort()
const exit = await Effect.runPromise(Fiber.await(first))
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(signals.map((signal) => signal.aborted)).toEqual([true, false])
expect(finalized).toEqual([0])
await Effect.runPromise(Fiber.interrupt(second))
expect(signals[1].aborted).toBe(true)
expect(finalized).toEqual([0, 1])
const preAborted = await Effect.runPromiseExit(rpc.count({ count: "3" }, { signal: abort.signal }))
expect(Exit.isFailure(preAborted) && Cause.hasInterruptsOnly(preAborted.cause)).toBe(true)
expect(signals).toHaveLength(2)
})
test("native and RPC Effect streams share one lazy source, cache connected, and filter across all locations", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const rpc = client.rpc(definition)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const progress = Stream.toAsyncIterable(rpc.events.subscribe("progress"))[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const marker = native.next()
await source.push(connected)
expect((await marker).value).toEqual(connected)
const first = progress.next()
const late = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
expect((await late.next()).value).toEqual(connected)
await native.return?.()
await late.return?.()
await source.push(rpcEvent("ignored", "/project/one", "other"))
await source.push(rpcEvent("ignored", "/project/one", "example", "message"))
await source.push(rpcEvent("1"))
expect((await first).value).toEqual({
id: "evt_progress",
created: 123,
type: "rpc.example.progress",
metadata: { origin: "test" },
data: { count: 1 },
location: { directory: "/project/one" },
})
const second = progress.next()
await source.push(rpcEvent("2", "/project/two"))
expect((await second).value).toEqual(
expect.objectContaining({ data: { count: 2 }, location: { directory: "/project/two" } }),
)
expect(source.requests).toHaveLength(1)
expect((await source.opened).signal.aborted).toBe(false)
const third = progress.next()
await source.push(rpcEvent("3"))
expect((await third).value.data).toEqual({ count: 3 })
const pending = progress.next()
await progress.return?.()
expect((await pending).done).toBe(true)
await source.cancelled
expect((await source.opened).signal.aborted).toBe(true)
})
test("interrupting a native Effect stream leaves an active RPC consumer running", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runFork(Stream.runCollect(client.event.subscribe()))
const progress = Stream.toAsyncIterable(client.rpc(definition).events.subscribe("progress"))[Symbol.asyncIterator]()
const first = progress.next()
await source.push(rpcEvent("1"))
expect((await first).value.data).toEqual({ count: 1 })
await Effect.runPromise(Fiber.interrupt(native))
expect((await source.opened).signal.aborted).toBe(false)
const second = progress.next()
await source.push(rpcEvent("2"))
expect((await second).value.data).toEqual({ count: 2 })
await progress.return?.()
await source.cancelled
})
test("shared Effect streams preserve EOF without reconnecting", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Stream.runCollect(client.event.subscribe()))
const progress = Effect.runPromise(Stream.runCollect(client.rpc(definition).events.subscribe("progress")))
await source.push(connected)
await source.push(rpcEvent("1"))
const connection = await source.opened
connection.controller.close()
expect((await native).map((event) => event.type)).toEqual(["server.connected", "rpc.example.progress"])
expect((await progress).map((event) => event.data)).toEqual([{ count: 1 }])
expect(source.requests).toHaveLength(1)
})
test("native protocol failures reach both native and RPC streams as ClientError", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push({ type: "server.connected" })
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("HTTP source failures reach every Effect consumer", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Effect.runPromise(Effect.flip(Stream.runCollect(client.event.subscribe())))
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(connected)
const connection = await source.opened
connection.controller.error(new Error("connection lost"))
expect((await native)._tag).toBe("ClientError")
expect(await progress).toBe(await native)
expect(source.requests).toHaveLength(1)
})
test("RPC payload decoding fails only the matching consumer, not the native event stream", async () => {
const source = eventSource()
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, source.httpClient),
),
)
const native = Stream.toAsyncIterable(client.event.subscribe())[Symbol.asyncIterator]()
const raw = native.next()
const progress = Effect.runPromise(
Effect.flip(Stream.runCollect(client.rpc(definition).events.subscribe("progress"))),
)
await source.push(rpcEvent("not a number"))
expect((await raw).value.type).toBe("rpc.example.progress")
expect(Schema.isSchemaError(await progress)).toBe(true)
expect((await source.opened).signal.aborted).toBe(false)
const next = native.next()
await source.push(connected)
expect((await next).value.type).toBe("server.connected")
await native.return?.()
await source.cancelled
})
test("shared event source runs with the Effect context captured by make", async () => {
const Token = Context.Reference("test/rpc-effect/token", { defaultValue: () => "missing" })
const httpClient = HttpClient.make((request) =>
Effect.gen(function* () {
const token = yield* Token
expect(token).toBe("captured")
return HttpClientResponse.fromWeb(
request,
new Response(`data: ${JSON.stringify(connected)}\n\n`, { headers: { "content-type": "text/event-stream" } }),
)
}),
)
const client = await Effect.runPromise(
OpenCode.make({ baseUrl: "http://localhost:3000" }).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
Effect.provideService(Token, "captured"),
),
)
expect((await Effect.runPromise(Stream.runCollect(client.event.subscribe())))[0]).toEqual(connected)
})
test("Effect RPC rejects inherited event names without opening the source", async () => {
const requests: string[] = []
const httpClient = HttpClient.make((request) => {
requests.push(request.url)
return Effect.die(new Error("Unexpected request"))
})
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
const broad: Rpc.Definition = definition
return yield* client.rpc(broad).events.subscribe("toString").pipe(Stream.runDrain, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error).toEqual(new Error("Unknown RPC event: rpc.example.toString"))
expect(requests).toEqual([])
})
-361
View File
@@ -1,361 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import type { StandardSchemaV1 } from "@standard-schema/spec"
import { Rpc } from "@opencode-ai/schema/rpc"
import { z } from "zod"
import { OpenCode } from "../src/promise/index"
const cleanup = new Set<() => void>()
afterEach(() => {
cleanup.forEach((close) => close())
cleanup.clear()
})
const Echo = Rpc.define({
id: "acme/jobs",
methods: {
echo: {
input: z.string(),
output: z.string(),
errors: { rejected: z.object({ reason: z.string() }) },
},
raw: { input: z.unknown(), output: z.unknown() },
ping: { input: z.undefined(), output: z.undefined() },
},
events: {
updated: { schema: z.object({ count: z.number() }) },
},
})
const connected = { id: "evt_connected", created: 0, type: "server.connected", data: {} }
const rpcEvent = (data: unknown, directory = "/first", rpcID = Echo.id, name = "updated") => ({
id: "evt_rpc",
created: 10,
type: `rpc.${rpcID}.${name}`,
location: { directory },
metadata: { source: "test" },
data,
})
function http(fetch: (request: Request) => Response | Promise<Response>) {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch })
cleanup.add(() => server.stop(true))
return OpenCode.make({ baseUrl: server.url.href, headers: { authorization: "Bearer default", "x-base": "base" } })
}
function events() {
const requests: Request[] = []
const opened = Promise.withResolvers<ReadableStreamDefaultController<Uint8Array>>()
const cancelled = Promise.withResolvers<void>()
const encoder = new TextEncoder()
let stopped = false
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
headers: { authorization: "Bearer events" },
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const abort = () => {
if (stopped) return
stopped = true
controller.error(request.signal.reason)
cancelled.resolve()
}
request.signal.addEventListener("abort", abort, { once: true })
cleanup.add(abort)
opened.resolve(controller)
controller.enqueue(encoder.encode(`data: ${JSON.stringify(connected)}\n\n`))
},
cancel() {
stopped = true
cancelled.resolve()
},
})
return new Response(stream, { headers: { "content-type": "text/event-stream" } })
},
})
return {
client,
requests,
cancelled: cancelled.promise,
async send(value: unknown) {
return (await opened.promise).enqueue(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
},
async end() {
stopped = true
return (await opened.promise).close()
},
async fail(error: Error) {
stopped = true
return (await opened.promise).error(error)
},
}
}
test("rpc is callable, retains raw call, and routes method location, headers, and JSON body", async () => {
const requests: Array<{ url: string; method: string; headers: Headers; body: unknown }> = []
const client = http(async (request) => {
const body = await request.json()
requests.push({ url: request.url, method: request.method, headers: request.headers, body })
return Response.json({ output: body.input })
})
expect(typeof client.rpc).toBe("function")
expect(typeof client.rpc.call).toBe("function")
expect(
await client.rpc(Echo).echo("hello", {
location: { directory: "/project with spaces", workspace: "wrk_test" },
headers: { authorization: "Bearer override", "x-call": "call" },
}),
).toBe("hello")
const url = new URL(requests[0].url)
expect(url.pathname).toBe("/api/rpc/acme%2Fjobs/echo")
expect(url.searchParams.get("location[directory]")).toBe("/project with spaces")
expect(url.searchParams.get("location[workspace]")).toBe("wrk_test")
expect(requests[0].body).toEqual({ input: "hello" })
expect(requests[0].method).toBe("POST")
expect(requests[0].headers.get("authorization")).toBe("Bearer override")
expect(requests[0].headers.get("x-base")).toBe("base")
expect(requests[0].headers.get("x-call")).toBe("call")
expect(await client.rpc.call({ rpcID: Echo.id, method: "echo", input: "raw" })).toEqual({ output: "raw" })
expect(new URL(requests[1].url).search).toBe("")
expect(requests[1].headers.get("authorization")).toBe("Bearer default")
})
test("no-input RPC methods and absent output use empty wrappers", async () => {
const client = http(async (request) => {
expect(await request.json()).toEqual({})
return Response.json({})
})
expect(await client.rpc(Echo).ping()).toBeUndefined()
expect(await client.rpc(Echo).ping(undefined, { location: { directory: "/project" } })).toBeUndefined()
})
test("RPC Standard Schema results are already parsed and are not transformed again", async () => {
const calls = { input: 0, output: 0 }
const input: StandardSchemaV1<string, number> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.input++
return { value: Number(value) }
},
},
}
const output: StandardSchemaV1<number, string> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
calls.output++
return { value: String(value) }
},
},
}
const eventOutput: StandardSchemaV1<{ count: number }, { text: string }> = {
"~standard": {
version: 1,
vendor: "test",
validate: (value) => {
if (typeof value !== "object" || value === null || !("count" in value) || typeof value.count !== "number")
return { issues: [{ message: "Expected count" }] }
return { value: { text: String(value.count) } }
},
},
}
const definition = Rpc.define({
id: "standard",
methods: { count: { input, output } },
events: { counted: { schema: eventOutput } },
})
const client = http(async (request) => {
expect(await request.json()).toEqual({ input: "41" })
return Response.json({ output: "42" })
})
expect(await client.rpc(definition).count("41")).toBe("42")
const source = events()
const iterator = source.client.rpc(definition).events.subscribe("counted")[Symbol.asyncIterator]()
const next = iterator.next()
await source.send(rpcEvent({ text: "42" }, "/project", definition.id, "counted"))
expect((await next).value?.data).toEqual({ text: "42" })
await iterator.return?.()
expect(calls).toEqual({ input: 0, output: 0 })
})
test("RPC method signals cancel an in-flight HTTP request", async () => {
const received = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const client = http(() => {
received.resolve()
return response.promise
})
const controller = new AbortController()
const result = client
.rpc(Echo)
.echo("hello", { signal: controller.signal })
.catch((error: unknown) => error)
await received.promise
controller.abort()
expect(await result).toMatchObject({ name: "ClientError", reason: "Transport" })
response.resolve(Response.json({ output: "late" }))
})
test("RPC pre-aborted methods do not issue HTTP requests", async () => {
let requests = 0
const client = http(() => {
requests++
return Response.json({ output: "hello" })
})
await expect(client.rpc(Echo).echo("hello", { signal: AbortSignal.abort() })).rejects.toBeDefined()
expect(requests).toBe(0)
})
test("RPC declared HTTP failures propagate", async () => {
await expect(
http(() => Response.json({ _tag: "UnauthorizedError", message: "Denied" }, { status: 401 }))
.rpc(Echo)
.echo("hello"),
).rejects.toMatchObject({ _tag: "UnauthorizedError", message: "Denied" })
})
test("RPC method failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcError", type: "rejected", message: "Rejected", data: { reason: "busy" } }
const client = http(() => Response.json(response, { status: 400 }))
const error = await client.rpc(Echo).echo("hello").catch((error: unknown) => error)
expect(error).toEqual({ type: "rejected", message: "Rejected", data: { reason: "busy" } })
await expect(client.rpc.call({ rpcID: Echo.id, method: "echo", input: "hello" })).rejects.toEqual(response)
})
test("RPC transport failures remove the generic transport wrapper", async () => {
const response = { _tag: "RpcInternalError", type: "rpc.internal", message: "Failed" }
await expect(http(() => Response.json(response, { status: 500 })).rpc(Echo).echo("hello")).rejects.toEqual({
type: "rpc.internal",
message: "Failed",
})
})
test("native events and multiple RPC clients share one lazy source across locations", async () => {
const source = events()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const second = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const otherDefinition = Rpc.define({ ...Echo, id: "other" })
const other = source.client.rpc(otherDefinition).events.subscribe("updated")[Symbol.asyncIterator]()
expect(source.requests).toHaveLength(0)
const firstNext = first.next()
const secondNext = second.next()
const otherNext = other.next()
expect(await native.next()).toEqual({ done: false, value: connected })
expect(source.requests).toHaveLength(1)
expect(source.requests[0].headers.get("authorization")).toBe("Bearer events")
const late = source.client.event.subscribe()[Symbol.asyncIterator]()
expect(await late.next()).toEqual({ done: false, value: connected })
await Promise.all([native.return?.(), late.return?.()])
await source.send(rpcEvent({ ignored: true }, "/first", Echo.id, "unknown"))
await source.send(rpcEvent({ count: 9 }, "/other", otherDefinition.id))
expect((await otherNext).value).toMatchObject({
type: "rpc.other.updated",
location: { directory: "/other" },
data: { count: 9 },
})
await other.return?.()
await source.send(rpcEvent({ count: 42 }))
const expected = {
id: "evt_rpc",
created: 10,
type: `rpc.${Echo.id}.updated`,
location: { directory: "/first" },
metadata: { source: "test" },
data: { count: 42 },
}
expect(await firstNext).toEqual({ done: false, value: expected })
expect(await secondNext).toEqual({ done: false, value: expected })
const next = first.next()
await source.send(rpcEvent({ count: 43 }, "/second"))
expect((await next).value).toMatchObject({ location: { directory: "/second" }, data: { count: 43 } })
await Promise.all([first.return?.(), second.return?.()])
await source.cancelled
expect(source.requests[0].signal.aborted).toBe(true)
expect(source.requests).toHaveLength(1)
})
test("RPC iterator return and abort cancel only their pending subscribers", async () => {
const source = events()
const controller = new AbortController()
const first = source.client.rpc(Echo).events.subscribe("updated")[Symbol.asyncIterator]()
const secondEvents = source.client.rpc(Echo).events.subscribe("updated", { signal: controller.signal })
const second = secondEvents[Symbol.asyncIterator]()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
const firstNext = first.next()
const secondNext = second.next()
await native.next()
expect((await first.return?.())?.done).toBe(true)
expect((await firstNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
controller.abort()
expect((await secondNext).done).toBe(true)
expect(source.requests[0].signal.aborted).toBe(false)
const nativeNext = native.next()
const event = rpcEvent({ count: 42 })
await source.send(event)
expect(await nativeNext).toEqual({ done: false, value: event })
await native.return?.()
await source.cancelled
})
test("RPC callback subscriptions unsubscribe independently", async () => {
const source = events()
const received = Promise.withResolvers<unknown>()
const native = source.client.event.subscribe()[Symbol.asyncIterator]()
await native.next()
const unsubscribe = source.client.rpc(Echo).events.on("updated", received.resolve)
await source.send(rpcEvent({ count: 42 }))
expect(await received.promise).toMatchObject({ data: { count: 42 }, type: `rpc.${Echo.id}.updated` })
unsubscribe()
unsubscribe()
expect(source.requests[0].signal.aborted).toBe(false)
await native.return?.()
await source.cancelled
})
test("RPC async callback failures stop only that listener and are not unhandled", async () => {
const source = events()
const client = source.client.rpc(Echo)
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const failed: number[] = []
cleanup.add(release.resolve)
cleanup.add(
client.events.on("updated", async (event) => {
failed.push(event.data.count)
started.resolve()
await release.promise
throw new Error("Expected async RPC callback failure")
}),
)
const healthy = client.events.subscribe("updated")[Symbol.asyncIterator]()
const first = healthy.next()
await source.send(rpcEvent({ count: 1 }))
await started.promise
expect((await first).value.data.count).toBe(1)
const second = healthy.next()
await source.send(rpcEvent({ count: 2 }))
expect((await second).value.data.count).toBe(2)
expect(failed).toEqual([1])
release.resolve()
await healthy.return?.()
await source.cancelled
expect(failed).toEqual([1])
})
test("RPC checks unknown event names and pre-aborted subscriptions remain lazy", async () => {
const source = events()
const broad: Rpc.PortableDefinition = Echo
expect(() => source.client.rpc(broad).events.subscribe("unknown")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.subscribe("toString")).toThrow("Unknown RPC event")
expect(() => source.client.rpc(broad).events.on("unknown", () => {})).toThrow("Unknown RPC event")
const aborted = source.client.rpc(Echo).events.subscribe("updated", { signal: AbortSignal.abort() })
const iterator = aborted[Symbol.asyncIterator]()
expect((await iterator.next()).done).toBe(true)
expect(source.requests).toHaveLength(0)
})
+177
View File
@@ -0,0 +1,177 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("evicts a whole family while preserving metadata and attention", async () => {
const setup = fixture()
try {
for (const id of ["ses_parent", "ses_child", "ses_grandchild", "ses_other"]) {
await setup.data.session.message.sync(id)
await setup.data.session.pending.sync(id)
}
setup.data.session.setStatus("ses_child", "running")
setup.emit({
id: "evt_permission",
created: 1,
type: "permission.asked",
data: { id: "per_test", sessionID: "ses_child", action: "bash", resources: ["bun test"] },
})
setup.data.session.evict("ses_parent")
for (const id of ["ses_parent", "ses_child", "ses_grandchild"]) {
expect(setup.data.session.message.list(id)).toEqual([])
expect(setup.data.session.message.get(id, "msg_page")).toBeUndefined()
expect(setup.data.session.message.more(id)).toBe(false)
expect(setup.data.session.message.loading(id)).toBe(false)
expect(setup.data.session.pending.list(id)).toEqual([])
expect(setup.data.session.input.list(id)).toEqual([])
expect(setup.data.session.get(id)?.id).toBe(id)
}
expect(setup.data.session.family("ses_parent")).toEqual(["ses_parent", "ses_child", "ses_grandchild"])
expect(setup.data.session.status("ses_child")).toBe("running")
expect(setup.data.session.permission.list("ses_child")?.[0]?.id).toBe("per_test")
expect(setup.data.session.message.list("ses_other")).toHaveLength(2)
setup.emit({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "ses_parent", seq: 1, version: 1 },
data: { sessionID: "ses_parent", agent: "build" },
})
expect(setup.data.session.message.list("ses_parent")).toMatchObject([{ type: "agent-switched" }])
await setup.data.session.message.sync("ses_parent")
expect(setup.data.session.message.list("ses_parent").map((item) => item.id)).toEqual(["msg_page"])
await setup.data.session.message.sync("ses_grandchild")
await setup.data.session.pending.sync("ses_grandchild")
expect(setup.data.session.message.list("ses_grandchild")).toHaveLength(2)
expect(setup.data.session.message.list("ses_child")).toEqual([])
} finally {
setup.dispose()
}
})
test("evicting an in-flight read leaves the next sync invalidated", async () => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let requests = 0
const setup = fixture(async () => {
if (++requests !== 1) return
requested.resolve()
await release.promise
})
try {
const initial = setup.data.session.message.sync("ses_child")
await requested.promise
setup.data.session.evict("ses_parent")
release.resolve()
await initial
await setup.data.session.message.sync("ses_child")
expect(requests).toBe(2)
expect(setup.data.session.message.list("ses_child")).toHaveLength(1)
} finally {
release.resolve()
setup.dispose()
}
})
test.each(["echo", "reject"] as const)("eviction preserves optimistic submissions until %s", async (mode) => {
const release = Promise.withResolvers<void>()
const setup = fixture(async (url) => {
if (!url.pathname.endsWith("/prompt")) return undefined
await release.promise
return mode === "reject"
? Response.json({ message: "rejected" }, { status: 400 })
: Response.json({ id: "msg_local" })
})
try {
const request = setup.data.session.prompt({ sessionID: "ses_child", id: "msg_local", text: "local" })
const settled = Promise.allSettled([request])
setup.data.session.evict("ses_parent")
expect(setup.data.session.message.list("ses_child")).toMatchObject([{ id: "msg_local", text: "local" }])
if (mode === "echo") setup.emit(enqueued("ses_child", "msg_local"))
release.resolve()
await settled
expect(setup.data.session.message.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
expect(setup.data.session.pending.list("ses_child")).toHaveLength(mode === "echo" ? 1 : 0)
} finally {
release.resolve()
setup.dispose()
}
})
function info(id: string, parentID?: string): SessionInfo {
return {
id,
parentID,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
location: { directory: "/project" },
}
}
function enqueued(sessionID: string, inboxID = "msg_pending"): OpenCodeEvent {
return {
id: `evt_${inboxID}`,
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: 1, version: 1 },
data: { sessionID, inboxID, item: { type: "user", delivery: "steer", payload: { text: "pending" } } },
}
}
function fixture(read?: (url: URL) => Promise<Response | void>) {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input) => {
const url = new URL(input instanceof Request ? input.url : String(input))
const response = await read?.(url)
if (response) return response
const sessionID = url.pathname.split("/")[3]
if (url.pathname.endsWith("/inbox"))
return Response.json({
data: [
{
id: "msg_pending",
sessionID,
type: "user",
delivery: "steer",
payload: { text: "pending" },
timeCreated: 1,
},
],
})
return Response.json({
data: [{ id: "msg_page", type: "user", text: "page", time: { created: 0 } }],
cursor: url.searchParams.has("cursor") ? {} : { next: "older" },
})
},
})
return createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
})
data.session.remember(info("ses_parent"))
data.session.remember(info("ses_child", "ses_parent"))
data.session.remember(info("ses_grandchild", "ses_child"))
data.session.remember(info("ses_other"))
return {
data,
dispose,
emit: (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details })),
}
})
}
@@ -1,5 +1,6 @@
import {
type AstNode,
AsyncIteratorSymbol,
CodeModeFunction,
CodeModeGenerator,
CoercionFunction,
@@ -9,6 +10,7 @@ import {
GeneratorMethodReference,
InterpreterRuntimeError,
IntrinsicReference,
IteratorSymbol,
JsonMethodReference,
PromiseCapabilityFunction,
PromiseInstanceMethodReference,
@@ -42,13 +44,12 @@ export const isRuntimeReference = (value: unknown): boolean =>
value instanceof SymbolNamespace ||
isCodeModeValue(value)
function* childValues(value: object): Generator<unknown> {
if (Array.isArray(value)) {
const length = value.length
for (let index = 0; index < length; index++) yield value[index]
return
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
yield Reflect.get(value, key)
}
yield* Object.values(value)
}
export const containsRuntimeReference = (value: unknown): boolean => {
@@ -90,9 +91,14 @@ export const containsOpaqueReference = (value: unknown): boolean => {
}
// Reject cycles before mutation so later boundary walks remain safe.
export const rejectCircularInsertion = (container: object, value: unknown, label: string, node: AstNode): void => {
export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
const pending: Array<Iterator<unknown>> = [[value].values()]
const seen = new Set<object>()
while (pending.length > 0) {
const next = pending.at(-1)!.next()
if (next.done) {
@@ -104,7 +110,7 @@ export const rejectCircularInsertion = (container: object, value: unknown, label
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
if (current === null || typeof current !== "object" || isRuntimeReference(current) || seen.has(current)) continue
seen.add(current)
pending.push(Array.isArray(current) ? current[Symbol.iterator]() : childValues(current))
pending.push(childValues(current))
}
}
+20 -15
View File
@@ -1,12 +1,6 @@
import { Effect } from "effect"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
IteratorSymbol,
IteratorSymbols,
} from "../interpreter/model.js"
import { containsOpaqueReference } from "../interpreter/references.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { containsOpaqueReference, rejectCircularInsertion } from "../interpreter/references.js"
import { isBlockedMember } from "../tool-runtime.js"
import { isCodeModeValue, CodeModePromise } from "../values.js"
import { boundedData, coerceToString } from "./value.js"
@@ -37,10 +31,6 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
}
return input as Record<string, unknown>
}
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = item
}
switch (name) {
case "keys":
return Object.keys(requireObject())
@@ -64,14 +54,29 @@ export const invokeObjectMethod = (name: string, args: Array<unknown>, node: Ast
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
"TypeError",
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined || isCodeModeValue(source)) continue
if (typeof source !== "object" || Array.isArray(source)) {
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
}
for (const [key, item] of Object.entries(source)) guardedSet(out, key, item)
for (const symbol of IteratorSymbols) {
if (Object.hasOwn(source, symbol)) Reflect.set(out, symbol, Reflect.get(source, symbol))
for (const key of Reflect.ownKeys(source)) {
if (typeof key === "string") {
if (Object.prototype.propertyIsEnumerable.call(source, key)) guardedSet(key, Reflect.get(source, key))
continue
}
if (key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue
guardedSet(key, Reflect.get(source, key))
}
}
return out
+46 -11
View File
@@ -63,21 +63,58 @@ const docTags = (schema: JsonSchema): Array<string> => {
} catch {}
}
if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
if (schema.type === "integer") tags.push("@integer")
if (typeof schema.minimum === "number") tags.push(`@minimum ${schema.minimum}`)
if (typeof schema.maximum === "number") tags.push(`@maximum ${schema.maximum}`)
if (typeof schema.exclusiveMinimum === "number") tags.push(`@exclusiveMinimum ${schema.exclusiveMinimum}`)
if (typeof schema.exclusiveMaximum === "number") tags.push(`@exclusiveMaximum ${schema.exclusiveMaximum}`)
if (typeof schema.multipleOf === "number") tags.push(`@multipleOf ${schema.multipleOf}`)
if (typeof schema.minLength === "number") tags.push(`@minLength ${schema.minLength}`)
if (typeof schema.maxLength === "number") tags.push(`@maxLength ${schema.maxLength}`)
if (typeof schema.pattern === "string") tags.push(`@pattern ${schema.pattern}`)
if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
if (schema.uniqueItems === true) tags.push("@uniqueItems true")
return tags
}
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
line.replaceAll("*/", "* /").replace(/\s+$/, ""),
)
const docLines = (schema: JsonSchema, width: number): Array<string> => {
const summary = docTags(schema).join(" ")
const lines = (schema.description ?? "").split("\n").map((line) => line.replace(/\s+$/, ""))
while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
if (lines.length === 0) return ""
if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
const inline = lines.length === 1 ? `${lines[0]}${lines[0].endsWith(".") ? "" : "."} ${summary}` : summary
return summary && lines.length === 1 && !summary.includes("\n") && width + inline.length + 7 <= 120
? [inline]
: [...lines, ...(summary ? summary.split("\n") : [])]
}
// Neutralize `*\/` so model-provided schema text cannot terminate generated documentation.
const jsdoc = (schema: JsonSchema, pad: string): string => {
const content = docLines(schema, pad.length)
const types = typeof schema.type === "string" ? [schema.type] : (schema.type ?? [])
const append = (label: string, child: JsonSchema) => {
docLines(child, pad.length + label.length + 2).forEach((line, index) => {
if (index === 0) {
content.push(`${label}: ${line}`)
return
}
content.push(line ? ` ${line}` : "")
})
}
// Document only the immediate contents; recursive labels obscure which level a constraint belongs to.
if (types.includes("array") && schema.items) append("Each item", schema.items)
if ((types.includes("object") || schema.properties) && typeof schema.additionalProperties === "object") {
const label = Object.keys(schema.properties ?? {}).length > 0 ? "Each additional value" : "Each value"
append(label, schema.additionalProperties)
}
if (content.length === 0) return ""
const escaped = content.map((line) => line.replaceAll("*/", "* /"))
if (escaped.length === 1 && pad.length + escaped[0].length + 7 <= 120) return `${pad}/** ${escaped[0]} */\n`
const body = escaped.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
return `${pad}/**\n${body}\n${pad} */\n`
}
@@ -156,9 +193,7 @@ const renderSchema = (
if (properties.length === 0 && indexType === undefined) return "{}"
const pad = " ".repeat(depth + 1)
const lines = properties.map(
(entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
)
const lines = properties.map((entry) => `${jsdoc(entry[1], pad)}${pad}${field(entry)},`)
if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
return `{\n${lines.join("\n")}\n${" ".repeat(depth)}}`
}
+9
View File
@@ -21,8 +21,17 @@ export type JsonSchema = {
readonly default?: unknown
readonly format?: string
readonly deprecated?: boolean
readonly minimum?: number
readonly maximum?: number
readonly exclusiveMinimum?: number
readonly exclusiveMaximum?: number
readonly multipleOf?: number
readonly minLength?: number
readonly maxLength?: number
readonly pattern?: string
readonly minItems?: number
readonly maxItems?: number
readonly uniqueItems?: boolean
readonly $ref?: string
readonly $defs?: Readonly<Record<string, JsonSchema>>
readonly definitions?: Readonly<Record<string, JsonSchema>>
+395 -32
View File
@@ -36,7 +36,7 @@ const lookupOrder = Tool.make({
})
describe("pretty signature rendering", () => {
test("described fields get JSDoc comments; undescribed and untagged fields get none", () => {
test("described fields get compact JSDoc; undescribed and unconstrained fields get none", () => {
expect(inputTypeScript(listIssues, true)).toBe(
[
"{",
@@ -44,16 +44,9 @@ describe("pretty signature rendering", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" /** Results per page. @default 30 */",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}",
@@ -105,7 +98,7 @@ describe("pretty signature rendering", () => {
)
})
test("constraints TypeScript cannot express surface as JSDoc tags", () => {
test("constraints and annotations share compact tagged JSDoc", () => {
const pretty = jsonSchemaToTypeScript(
{
type: "object",
@@ -119,19 +112,10 @@ describe("pretty signature rendering", () => {
)
expect(pretty).toContain(" /** @deprecated */\n legacy?: string")
expect(pretty).toContain(" /** @format uri */\n homepage?: string")
expect(pretty).toContain(
[
" /**",
' * @default ["a","b"]',
" * @minItems 2",
" * @maxItems 5",
" */",
" tags?: Array<string>",
].join("\n"),
)
expect(pretty).toContain(' /** @default ["a","b"] @minItems 2 @maxItems 5 */\n tags?: Array<string>')
})
test("skips an unserializable default rather than emitting a broken tag", () => {
test("skips an unserializable default rather than emitting a broken summary", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { size: { type: "number", default: 1n } } },
true,
@@ -139,6 +123,248 @@ describe("pretty signature rendering", () => {
expect(pretty).toBe(["{", " size?: number,", "}"].join("\n"))
})
test("labels immediate item and dictionary value metadata above the field", () => {
const schema = {
properties: {
recipients: {
type: "array",
description: "People to notify",
minItems: 1,
items: { type: "string", description: "Email address", format: "email" },
},
scores: { type: "object", additionalProperties: { type: "number", minimum: 0 } },
names: { type: "array", items: { type: "string", description: "Display name" } },
plain: { type: "object", additionalProperties: { type: "string" } },
},
}
expect(jsonSchemaToTypeScript(schema, true)).toBe(
[
"{",
" /**",
" * People to notify. @minItems 1",
" * Each item: Email address. @format email",
" */",
" recipients?: Array<string>,",
" /** Each value: @minimum 0 */",
" scores?: {",
" [key: string]: number,",
" },",
" /** Each item: Display name */",
" names?: Array<string>,",
" plain?: {",
" [key: string]: string,",
" },",
"}",
].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(
"{ recipients?: Array<string>; scores?: { [key: string]: number }; names?: Array<string>; plain?: { [key: string]: string } }",
)
})
test("does not apply additional property metadata to named properties", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
scores: {
type: "object",
properties: { title: { type: "string", description: "Score title" } },
additionalProperties: { type: "number", minimum: 0 },
},
},
},
true,
),
).toBe(
[
"{",
" /** Each additional value: @minimum 0 */",
" scores?: {",
" /** Score title */",
" title?: string,",
" [key: string]: number,",
" },",
"}",
].join("\n"),
)
})
test("labels array and dictionary contents in type-array unions", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
recipients: { type: ["array", "null"], items: { type: "string", format: "email" } },
scores: { type: ["object", "null"], additionalProperties: { type: "number", minimum: 0 } },
either: {
type: ["array", "object"],
items: { type: "string", minLength: 1 },
additionalProperties: { type: "number", minimum: 0 },
},
},
},
true,
),
).toBe(
[
"{",
" /** Each item: @format email */",
" recipients?: Array<string> | null,",
" /** Each value: @minimum 0 */",
" scores?: {",
" [key: string]: number,",
" } | null,",
" /**",
" * Each item: @minLength 1",
" * Each value: @minimum 0",
" */",
" either?: Array<string> | {",
" [key: string]: number,",
" },",
"}",
].join("\n"),
)
})
test("keeps multiline item metadata indented under its label and escapes terminators", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
values: {
type: "array",
minItems: 1,
items: { type: "string", description: "\nFirst */ line\n\nSecond line\n", pattern: "^\\d+\n*/$" },
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * @minItems 1",
" * Each item: First * / line",
" *",
" * Second line",
" * @pattern ^\\d+",
" * * /$",
" */",
" values?: Array<string>,",
"}",
].join("\n"),
)
})
test("does not flatten nested containers, reference targets, or branches into item metadata", () => {
expect(
jsonSchemaToTypeScript(
{
$defs: { Email: { type: "string", format: "email" } },
properties: {
matrix: { type: "array", items: { type: "array", minItems: 2, items: { type: "integer", minimum: 0 } } },
refs: { type: "array", items: { $ref: "#/$defs/Email", description: "Recipient" } },
choices: {
type: "array",
items: {
anyOf: [
{ type: "string", minLength: 1 },
{ type: "number", minimum: 0 },
],
},
},
},
},
true,
),
).toBe(
[
"{",
" /** Each item: @minItems 2 */",
" matrix?: Array<Array<number>>,",
" /** Each item: Recipient */",
" refs?: Array<string>,",
" choices?: Array<string | number>,",
"}",
].join("\n"),
)
})
test.each([
[{ type: "number", minimum: 0 }, "@minimum 0", "number"],
[{ type: "number", maximum: 0 }, "@maximum 0", "number"],
[{ type: "number", exclusiveMinimum: 0 }, "@exclusiveMinimum 0", "number"],
[{ type: "number", exclusiveMaximum: 0 }, "@exclusiveMaximum 0", "number"],
[{ type: "number", multipleOf: 0.25 }, "@multipleOf 0.25", "number"],
[{ type: "string", minLength: 0 }, "@minLength 0", "string"],
[{ type: "string", maxLength: 0 }, "@maxLength 0", "string"],
[{ type: "string", pattern: "^[a-z]+$" }, "@pattern ^[a-z]+$", "string"],
[{ type: "array", minItems: 0 }, "@minItems 0", "Array<unknown>"],
[{ type: "array", maxItems: 0 }, "@maxItems 0", "Array<unknown>"],
[{ type: "array", uniqueItems: true }, "@uniqueItems true", "Array<unknown>"],
[{ type: "string", minLength: 0, maxLength: 0 }, "@minLength 0 @maxLength 0", "string"],
[{ type: "array", minItems: 0, maxItems: 0 }, "@minItems 0 @maxItems 0", "Array<unknown>"],
[
{ type: "array", minItems: 1, maxItems: 10, uniqueItems: true },
"@minItems 1 @maxItems 10 @uniqueItems true",
"Array<unknown>",
],
] as const)("renders constraint %j without changing the compact type", (value, summary, type) => {
const schema = { type: "object", properties: { value } }
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", ` /** ${summary} */`, ` value?: ${type},`, "}"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe(`{ value?: ${type} }`)
})
test("documents integer numbers without adding redundant types or requiring uniqueness when false", () => {
expect(
jsonSchemaToTypeScript(
{
type: "object",
properties: {
count: { type: "integer" },
amount: { type: "number" },
name: { type: "string" },
enabled: { type: "boolean" },
values: { type: "array", uniqueItems: false },
choice: { type: ["integer", "string"] },
},
},
true,
),
).toBe(
[
"{",
" /** @integer */",
" count?: number,",
" amount?: number,",
" name?: string,",
" enabled?: boolean,",
" values?: Array<unknown>,",
" choice?: number | string,",
"}",
].join("\n"),
)
})
test.each([false, null, ""])("preserves default %j alongside constraints", (value) => {
expect(jsonSchemaToTypeScript({ properties: { value: { default: value, minLength: 0 } } }, true)).toContain(
` /** @default ${JSON.stringify(value)} @minLength 0 */\n`,
)
})
test("escapes comment terminators in summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", default: "*/", format: "*/", pattern: "^a*/b$" } } },
true,
),
).toBe(["{", ' /** @default "* /" @format * / @pattern ^a* /b$ */', " value?: string,", "}"].join("\n"))
})
test("neutralizes */ inside descriptions so nothing closes the comment early", () => {
const pretty = jsonSchemaToTypeScript(
{ type: "object", properties: { note: { type: "string", description: "Ends */ early" } } },
@@ -161,6 +387,88 @@ describe("pretty signature rendering", () => {
)
})
test("preserves inclusive and exclusive numeric bounds together", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
value: {
type: "integer",
minimum: -10,
maximum: 10,
exclusiveMinimum: -5,
exclusiveMaximum: 5,
multipleOf: 2,
},
},
},
true,
),
).toContain(
" /** @integer @minimum -10 @maximum 10 @exclusiveMinimum -5 @exclusiveMaximum 5 @multipleOf 2 */\n value?: number,",
)
})
test.each([
["Maximum attempts", "Maximum attempts."],
["Maximum attempts.", "Maximum attempts."],
["Maximum attempts!", "Maximum attempts!."],
])("combines a short description (%s) with its summary", (description, expected) => {
expect(
jsonSchemaToTypeScript(
{ properties: { attempts: { description, type: "integer", minimum: 1, default: 3 } } },
true,
),
).toContain(` /** ${expected} @default 3 @integer @minimum 1 */\n`)
})
test("keeps multiline descriptions intact and appends a compact summary", () => {
expect(
jsonSchemaToTypeScript(
{
properties: {
attempts: {
description: "\nMaximum attempts\n\nIncludes the initial request.\n",
type: "integer",
minimum: 1,
},
},
},
true,
),
).toBe(
[
"{",
" /**",
" * Maximum attempts",
" *",
" * Includes the initial request.",
" * @integer @minimum 1",
" */",
" attempts?: number,",
"}",
].join("\n"),
)
})
test("uses a block for long descriptions without truncating or rewriting them", () => {
const description = "A detailed description. ".repeat(8).trim()
expect(jsonSchemaToTypeScript({ properties: { name: { type: "string", description, minLength: 1 } } }, true)).toBe(
["{", " /**", ` * ${description}`, " * @minLength 1", " */", " name?: string,", "}"].join("\n"),
)
})
test("preserves pattern backslashes and prefixes every line of multiline summary values", () => {
expect(
jsonSchemaToTypeScript(
{ properties: { value: { type: "string", pattern: "^\\d+\n*/$", default: "a\nb" } } },
true,
),
).toBe(
["{", " /**", ' * @default "a\\nb" @pattern ^\\d+', " * * /$", " */", " value?: string,", "}"].join("\n"),
)
})
test("stays total on cyclic $refs and pathological nesting in both modes", () => {
const cyclic = {
$ref: "#/$defs/Node",
@@ -377,6 +685,68 @@ describe("union schemas render every alternative", () => {
})
describe("JSDoc signatures in catalogs and search results", () => {
test.each([
{
source: "JSON Schema",
schema: {
type: "object",
properties: {
count: { type: "integer", minimum: 0, maximum: 10 },
name: { type: "string", minLength: 1, maxLength: 20, pattern: "^[a-z]+$" },
labels: { type: "array", items: { type: "string", minLength: 1 }, minItems: 1, maxItems: 5 },
scores: { type: "object", additionalProperties: { type: "integer", minimum: 0 } },
},
required: ["count", "name", "labels", "scores"],
},
},
{
source: "Effect",
schema: Schema.Struct({
count: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(10)),
name: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(20), Schema.isPattern(/^[a-z]+$/)),
labels: Schema.Array(Schema.String.check(Schema.isMinLength(1))).check(
Schema.isMinLength(1),
Schema.isMaxLength(5),
),
scores: Schema.Record(Schema.String, Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
}),
},
])("$source constraints survive input/output catalog and search signatures", async ({ schema }) => {
const runtime = CodeMode.make({
tools: {
constrained: Tool.make({
description: "Constrained tool",
input: schema,
output: schema,
execute: () => Effect.succeed({ count: 1, name: "test", labels: ["test"], scores: { test: 1 } }),
}),
},
})
const type = [
"{",
" /** @integer @minimum 0 @maximum 10 */",
" count: number,",
" /** @minLength 1 @maxLength 20 @pattern ^[a-z]+$ */",
" name: string,",
" /**",
" * @minItems 1 @maxItems 5",
" * Each item: @minLength 1",
" */",
" labels: Array<string>,",
" /** Each value: @integer @minimum 0 */",
" scores: {",
" [key: string]: number,",
" },",
"}",
].join("\n")
const signature = `tools.constrained(input: ${type}): Promise<${type}>`
expect(runtime.catalog()[0]?.signature).toBe(signature)
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.constrained" })'))
expect(result.ok).toBe(true)
if (!result.ok) throw new Error("search failed")
expect(result.value).toMatchObject({ items: [{ signature }] })
})
const runtime = CodeMode.make({ tools: { github: { list_issues: listIssues }, orders: { lookup: lookupOrder } } })
const search = async (query: string) => {
@@ -386,7 +756,7 @@ describe("JSDoc signatures in catalogs and search results", () => {
return result.value as { items: Array<{ path: string; signature: string }>; remaining: number }
}
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and tags", async () => {
test("a raw JSON Schema (MCP-style) tool's result signature carries field JSDoc and summaries", async () => {
const { items } = await search("list issues repository")
const item = items.find(({ path }) => path === "tools.github.list_issues")!
expect(item.signature).toBe(
@@ -396,16 +766,9 @@ describe("JSDoc signatures in catalogs and search results", () => {
" owner: string,",
" /** Cursor from the previous response's pageInfo */",
" after?: string,",
" /**",
" * Results per page",
" * @default 30",
" */",
" /** Results per page. @default 30 */",
" perPage?: number,",
" /**",
" * Filter by labels",
" * @minItems 1",
" * @maxItems 10",
" */",
" /** Filter by labels. @minItems 1 @maxItems 10 */",
" labels?: Array<string>,",
' state?: "open" | "closed",',
"}): Promise<unknown>",
+170
View File
@@ -17,6 +17,8 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
import { invokeObjectMethod } from "../src/stdlib/object.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
@@ -824,6 +826,174 @@ describe("stdlib integration", () => {
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
})
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
const target = {}
const reads: Array<boolean> = []
const source = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
})
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toBe(target)
expect(reads).toEqual([])
expect(target).toEqual({ nested })
})
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
const target = {}
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(Object.hasOwn(target, "nested")).toBe(false)
})
test("Object.assign cycle checks traverse sparse keys lazily", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperties([], {
4294967294: { enumerable: true, value: target },
later: {
enumerable: true,
get() {
reads.push(true)
return null
},
},
})
expect(() => invokeObjectMethod("assign", [target, { nested }], { type: "CallExpression" })).toThrow(
"Object.assign result contains a circular value.",
)
expect(reads).toEqual([])
})
test("Object.assign stops after a supported symbol write fails", () => {
const previous = () => ({ done: true })
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
const reads: Array<boolean> = []
const source = Object.defineProperties(
{},
{
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
[AsyncIteratorSymbol]: {
enumerable: true,
get() {
reads.push(true)
return () => ({ done: true })
},
},
},
)
expect(() => invokeObjectMethod("assign", [target, source], { type: "CallExpression" })).toThrow(
"Object.assign could not assign property",
)
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
expect(reads).toEqual([])
})
test("Object.assign rejects direct and nested cycles", async () => {
expect(
await value(`
const target = { kept: true }
try { Object.assign(target, { self: target }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = { kept: true }
const nested = { target }
try { Object.assign(target, { nested }) } catch { return target }
return null
`),
).toEqual({ kept: true })
expect(
await value(`
const target = {}
const source = {}
source[Symbol.iterator] = target
try { Object.assign(target, source) } catch { return Object.hasOwn(target, Symbol.iterator) }
return true
`),
).toBe(false)
expect(
await value(`
const target = {}
const nested = {}
nested[Symbol.iterator] = target
try { Object.assign(target, { nested }) } catch { return Object.hasOwn(target, "nested") }
return true
`),
).toBe(false)
})
test("Object.assign preserves mutations before a circular field", async () => {
expect(
await value(`
const target = {}
try { Object.assign(target, { before: 1, cycle: { target }, after: 2 }) } catch { return target }
return null
`),
).toEqual({ before: 1 })
expect(
await value(`
const target = {}
const marker = {}
const source = {}
source[Symbol.iterator] = marker
source[Symbol.asyncIterator] = target
try { Object.assign(target, source) } catch {
return [target[Symbol.iterator] === marker, Object.hasOwn(target, Symbol.asyncIterator)]
}
return null
`),
).toEqual([true, false])
})
test("Object.assign preserves target identity and acyclic shared aliases", async () => {
expect(
await value(`
const shared = { count: 1 }
const target = {}
const result = Object.assign(target, { left: shared, right: shared })
result.left.count = 2
return [result === target, result.left === shared, result.left === result.right, shared.count]
`),
).toEqual([true, true, true, 2])
})
test("Object.assign traverses shared aliases once", () => {
const reads: Array<boolean> = []
const shared = Object.defineProperty({}, "value", {
enumerable: true,
get() {
reads.push(true)
return 1
},
})
const target = {}
expect(invokeObjectMethod("assign", [target, { left: shared, right: shared }], { type: "CallExpression" })).toBe(
target,
)
expect(target).toEqual({ left: shared, right: shared })
expect(reads).toEqual([true])
})
test("assignment resolves and reads its left side before evaluating the right side", async () => {
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
+8 -5
View File
@@ -119,7 +119,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
name: model.canonical ?? model.providerID,
...(model.settings ?? {}),
headers: model.headers,
body: projected.body,
@@ -249,6 +249,7 @@ export const locationLayer = Layer.effect(
language: Effect.fn("AISDK.language")(function* (model) {
const key = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
id: model.id,
modelID: model.modelID,
package: model.package,
@@ -269,6 +270,7 @@ export const locationLayer = Layer.effect(
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
canonical: model.canonical,
package: packageName,
settings: model.settings,
headers: model.headers,
@@ -301,10 +303,11 @@ export const locationLayer = Layer.effect(
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
const providerID = info.canonical ?? info.providerID
const optionKey = providerOptionKey(packageName, providerID)
const route: AnyRoute = {
id: `ai-sdk:${packageName}`,
provider: ProviderID.make(info.providerID),
provider: ProviderID.make(providerID),
providerMetadataKey: optionKey,
protocol: "ai-sdk",
endpoint: Endpoint.path("/", { baseURL: "https://ai-sdk.local" }),
@@ -331,13 +334,13 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
},
with: () => route,
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
provider: info.providerID,
provider: providerID,
route,
compatibility: info.compatibility,
})
+1
View File
@@ -74,6 +74,7 @@ const layer = Layer.effect(
const projectModel = (model: Model.Info, provider: Provider.Info) => {
return {
...model,
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider.package,
settings: Provider.mergeOverlay(provider.settings, model.settings),
headers: Provider.mergeHeaders(provider.headers, model.headers),
+85 -11
View File
@@ -2,21 +2,34 @@ export * as CodeModeCatalog from "./catalog.js"
import { Schema } from "effect"
export const Entry = Schema.Struct({
path: Schema.String,
export const Tool = Schema.Struct({
type: Schema.Literal("tool"),
name: Schema.String,
description: Schema.String,
signature: Schema.String,
pinned: Schema.optionalKey(Schema.Boolean),
})
export type Entry = typeof Entry.Type
export type Tool = typeof Tool.Type
export type Namespace = {
readonly type: "namespace"
readonly name: string
readonly description?: string
readonly tools: ReadonlyArray<Tool | Namespace>
}
export type Inventory = {
readonly tools: ReadonlyArray<Tool | Namespace>
}
const Listing = Schema.Struct({
path: Schema.String,
line: Schema.String,
})
const Namespace = Schema.Struct({
const NamespaceSummary = Schema.Struct({
name: Schema.String,
description: Schema.optionalKey(Schema.String),
count: Schema.Number,
entries: Schema.Array(Listing),
})
@@ -24,24 +37,31 @@ const Namespace = Schema.Struct({
export const Summary = Schema.Struct({
total: Schema.Number,
shown: Schema.Number,
namespaces: Schema.Array(Namespace),
namespaces: Schema.Array(NamespaceSummary),
})
export type Summary = typeof Summary.Type
export type Options = {
readonly budget?: number
}
const DESCRIPTION_LIMIT = 120
const CHARACTERS_PER_TOKEN = 4
const INLINE_BUDGET = 2_000
// Keep every namespace searchable, then select full listings one per namespace per round,
// Keep every namespace visible, then select full listings one per namespace per round,
// considering shorter listings first until the inline budget is exhausted.
export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET): Summary {
const namespaces = [...Map.groupBy(entries, (entry) => entry.path.split(".", 1)[0] ?? entry.path)]
export function summarize(inventory: Inventory, options: Options = {}): Summary {
const budget = options.budget ?? INLINE_BUDGET
const flattened = flatten(inventory.tools)
const namespaces = [...Map.groupBy(flattened.tools, (tool) => tool.path.split(".", 1)[0] ?? tool.path)]
.sort(([left], [right]) => {
if (left < right) return -1
if (left > right) return 1
return 0
})
.map(([name, namespaceEntries]) => {
const description = flattened.namespaces.get(name)?.description
const listings = namespaceEntries
.map((entry) => {
const firstLine = entry.description.split("\n", 1)[0]?.trim() ?? ""
@@ -64,6 +84,7 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
)
return {
name,
...(description === undefined ? {} : { description }),
listings,
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
selectedListings: pinned,
@@ -72,11 +93,25 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
})
const active = new Set(namespaces)
// TODO: Bound namespace discovery once large namespace inventories and descriptions can no longer stay inline.
let remaining =
budget -
namespaces.reduce(
(total, namespace) =>
total +
cost(
namespaceLine({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: [],
}),
),
0,
) -
namespaces
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
.reduce((total, listing) => total + cost(listing.line), 0)
while (active.size > 0) {
for (const namespace of active) {
const candidate = namespace.selectionOrder[namespace.selectionIndex]
@@ -93,19 +128,54 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
const namespaceSummaries = namespaces.map((namespace) => ({
name: namespace.name,
...(namespace.description === undefined ? {} : { description: namespace.description }),
count: namespace.listings.length,
entries: namespace.listings.filter((listing) => namespace.selectedListings.has(listing)),
}))
return {
total: entries.length,
total: flattened.tools.length,
shown: namespaceSummaries.reduce((total, namespace) => total + namespace.entries.length, 0),
namespaces: namespaceSummaries,
}
}
function flatten(entries: ReadonlyArray<Tool | Namespace>, path: ReadonlyArray<string> = []) {
const tools: Array<Omit<Tool, "name"> & { readonly path: string }> = []
const namespaces = new Map<string, Namespace>()
for (const entry of entries) {
if (entry.type === "tool") {
tools.push({
type: "tool",
path: [...path, entry.name].join("."),
description: entry.description,
signature: entry.signature,
...(entry.pinned === undefined ? {} : { pinned: entry.pinned }),
})
continue
}
const next = [...path, entry.name]
namespaces.set(next.join("."), entry)
const nested = flatten(entry.tools, next)
tools.push(...nested.tools)
for (const [name, namespace] of nested.namespaces) namespaces.set(name, namespace)
}
return { tools, namespaces }
}
export function namespaceLine(namespace: typeof NamespaceSummary.Type) {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return `- ${namespace.name} (${label})${namespace.description === undefined ? "" : ` // ${namespace.description}`}`
}
function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return listings
.map((listing) => ({ listing, cost: Math.round(listing.line.length / CHARACTERS_PER_TOKEN) }))
.map((listing) => ({ listing, cost: cost(listing.line) }))
.toSorted((left, right) => {
if (left.cost !== right.cost) return left.cost - right.cost
if (left.listing.path < right.listing.path) return -1
@@ -113,3 +183,7 @@ function rankListings(listings: ReadonlyArray<typeof Listing.Type>) {
return 0
})
}
function cost(text: string) {
return Math.round(text.length / CHARACTERS_PER_TOKEN)
}
+12 -10
View File
@@ -23,14 +23,7 @@ export function render(catalog: CodeModeCatalog.Summary) {
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
const tools = catalog.namespaces.flatMap((namespace) => {
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
const label =
namespace.entries.length === namespace.count
? count
: namespace.entries.length === 0
? `${count}, none shown`
: `${count}, ${namespace.entries.length} shown`
return [`- ${namespace.name} (${label})`, ...namespace.entries.map((entry) => entry.line)]
return [CodeModeCatalog.namespaceLine(namespace), ...namespace.entries.map((entry) => entry.line)]
})
return `${prompt(catalog.shown < catalog.total)}
@@ -47,6 +40,15 @@ ${render(current)}`
const currentComplete = current.shown === current.total
if (previousComplete !== currentComplete) return replacement
const descriptions = Instructions.diffByKey(
previous.namespaces.filter((namespace) => namespace.description !== undefined),
current.namespaces.filter((namespace) => namespace.description !== undefined),
(namespace) => namespace.name,
(before, after) => before.description !== after.description,
)
if (descriptions.added.length > 0 || descriptions.removed.length > 0 || descriptions.changed.length > 0)
return replacement
const diff = Instructions.diffByKey(
previous.namespaces.flatMap((namespace) => namespace.entries),
current.namespaces.flatMap((namespace) => namespace.entries),
@@ -126,8 +128,8 @@ ${render(current)}`
const key = Instructions.Key.make("core/codemode")
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.List => {
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
export const make = (inventory?: CodeModeCatalog.Inventory): Instructions.List => {
const catalog = inventory === undefined ? Instructions.removed : CodeModeCatalog.summarize(inventory)
return Instructions.make({
key,
codec,
+128 -14
View File
@@ -1,9 +1,18 @@
export * as CodeModeTool from "./tool.js"
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
import { CodeMode, Namespace, Tool, toolError } from "@opencode-ai/codemode"
import type {
Content,
Context,
Error,
Info,
Metadata,
Namespace as ToolNamespace,
Result,
} from "@opencode-ai/schema/tool"
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -31,6 +40,23 @@ type CollectedFiles = {
readonly files: Array<typeof ExecuteFile.Type>
}
type Node<T> = {
tool?: T
namespace?: ToolNamespace
readonly children: Map<string, Node<T>>
}
type ToolNode = Node<Tool.Tool<never>>
type Tools = {
[name: string]: Tool.Tool<never> | Namespace.Namespace<never> | Tools
}
export type Inventory = {
readonly tools: ReadonlyMap<string, Info>
readonly namespaces?: ReadonlyMap<string, ToolNamespace>
}
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
@@ -42,7 +68,7 @@ const description = [
].join("\n")
export const create = (
registrations: ReadonlyMap<string, Info>,
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown, context: Context) => Effect.Effect<Result, Error>,
) => {
return {
@@ -61,7 +87,7 @@ export const create = (
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
registrations,
inventory,
(name, tool, input) =>
Effect.gen(function* () {
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
@@ -132,36 +158,124 @@ export const create = (
} satisfies Info
}
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
export const catalog = (inventory: Inventory) => {
const pinned = new Set(
Array.from(registrations.values())
Array.from(inventory.tools.values())
.filter((registration) => registration.options?.pinned === true)
.map(qualifiedName),
)
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
.catalog()
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
const root: CatalogNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const tool of runtime(inventory, () => Effect.fail(toolError("Execute context is unavailable"))).catalog())
getNode(root, tool.path).tool = {
type: "tool",
name: tool.path.split(".").at(-1) ?? tool.path,
description: tool.description,
signature: tool.signature,
pinned: pinned.has(tool.path),
}
return {
tools: renderCatalog(root),
} satisfies CodeModeCatalog.Inventory
}
type CatalogNode = Node<CodeModeCatalog.Tool>
function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool | CodeModeCatalog.Namespace> {
return Array.from(root.children)
.toSorted(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap(([name, node]) => {
const tools = renderCatalog(node)
const namespace =
node.namespace === undefined && tools.length === 0
? undefined
: {
type: "namespace" as const,
name,
...(node.namespace?.description === undefined ? {} : { description: node.namespace.description }),
tools,
}
if (node.tool === undefined) return namespace === undefined ? [] : [namespace]
if (namespace === undefined) return [node.tool]
return [node.tool, namespace]
})
}
function runtime(
registrations: ReadonlyMap<string, Info>,
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) {
const tools: Record<string, Tool.Tool<never>> = {}
for (const [name, registration] of registrations) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
for (const namespace of inventory.namespaces?.values() ?? []) getNode(root, namespace.name).namespace = namespace
for (const [name, registration] of inventory.tools) {
const child = definition(registration)
const path = qualifiedName(registration)
tools[path] = Tool.make({
getNode(root, qualifiedName(registration)).tool = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode<T>(root: Node<T>, path: string) {
return path.split(".").reduce((parent, name) => {
const child: Node<T> = parent.children.get(name) ?? { children: new Map() }
parent.children.set(name, child)
return child
}, root)
}
function renderTools(root: ToolNode) {
const callables = new Map<string, Tool.Tool<never>>()
const tools = renderChildren(root, [], callables)
for (const [path, tool] of callables) tools[path] = tool
return tools
}
function renderChildren(node: ToolNode, path: ReadonlyArray<string>, callables: Map<string, Tool.Tool<never>>): Tools {
return Object.fromEntries(
Array.from(node.children).flatMap(([name, child]) => {
const next = [...path, name]
// A record cannot hold both a top-level tool and namespace under the same key.
if (path.length === 0 && child.tool !== undefined && (child.namespace !== undefined || child.children.size > 0)) {
const tools: Tools = {}
flattenTools(child, next, tools)
return Object.entries(tools)
}
return [[name, renderEntry(child, next, callables)]]
}),
)
}
function renderEntry(
node: ToolNode,
path: ReadonlyArray<string>,
callables: Map<string, Tool.Tool<never>>,
): Tools[string] {
const tools = renderChildren(node, path, callables)
// CodeMode merges this dotted tool path with the nested namespace entry.
if (node.tool !== undefined && (node.namespace !== undefined || node.children.size > 0))
callables.set(path.join("."), node.tool)
if (node.namespace !== undefined)
return Namespace.make({
description: node.namespace.description,
tools,
})
if (node.tool === undefined) return tools
if (node.children.size === 0) return node.tool
return tools
}
function flattenTools(node: ToolNode, path: ReadonlyArray<string>, tools: Tools) {
if (node.tool !== undefined) tools[path.join(".")] = node.tool
for (const [name, child] of node.children) flattenTools(child, [...path, name], tools)
}
function qualifiedName(registration: Info) {
const normalized = normalizedName(registration)
if (registration.options?.namespace === undefined) return normalized
@@ -44,8 +44,17 @@ export const Plugin = define({
catalog.model.default.set(configuredDefault.providerID, configuredDefault.model)
for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id
const current = catalog.provider.get(providerID)
const source = catalog.provider.get(item.canonical ?? current?.provider.canonical ?? providerID)
const changed = item.canonical !== undefined && item.canonical !== current?.provider.canonical
catalog.provider.update(providerID, (provider) => {
if (changed && source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), {
id: provider.id,
integrationID: provider.integrationID,
})
provider.activation = "enabled"
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
@@ -53,7 +62,14 @@ export const Plugin = define({
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [id, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? id) ?? source?.models.get(id)
const inherit = changed || !catalog.model.get(providerID, id)
catalog.model.update(providerID, id, (model) => {
if (inherit && base) {
Object.assign(model, structuredClone(base))
if (item.package !== undefined) model.package = undefined
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
@@ -8,7 +8,7 @@ import { Bus } from "../bus.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Plugin } from "../plugin.js"
import { LocationWatcherPolicy } from "./location-watcher-policy.js"
import { Watcher } from "./watcher.js"
@@ -24,7 +24,7 @@ const layer = Layer.effect(
const bus = yield* Bus.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const plugins = yield* PluginSupervisor.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 +93,7 @@ const layer = Layer.effect(
)
yield* policy.observe(reconcile)
yield* Effect.gen(function* () {
yield* plugins.flush
yield* plugins.awaitActivation
yield* reconcile(policy.current())
}).pipe(
Effect.catchCauseIf(
@@ -109,13 +109,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Watcher.node,
FSUtil.node,
Location.node,
Git.node,
Bus.node,
PluginSupervisor.node,
LocationWatcherPolicy.node,
],
deps: [Watcher.node, FSUtil.node, Location.node, Git.node, Bus.node, Plugin.node, LocationWatcherPolicy.node],
})
+4 -4
View File
@@ -46,8 +46,8 @@ import { InstructionBuiltIns } from "./instructions/builtins.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { SessionInstructions } from "./session/instructions.js"
import { SessionGenerateNode } from "./session/generate-node.js"
import { SessionPromptNode } from "./session/prompt-node.js"
import { SessionRevertNode } from "./session/revert-node.js"
import { SessionPrompt } from "./session/prompt.js"
import { SessionRevert } from "./session/revert.js"
import { McpTool } from "./tool/mcp.js"
import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
@@ -97,8 +97,8 @@ const nodes = [
Form.node,
Generate.node,
SessionGenerateNode.node,
SessionPromptNode.node,
SessionRevertNode.node,
SessionPrompt.node,
SessionRevert.node,
ReadToolFileSystem.node,
McpTool.node,
SessionInstructions.node,
+2 -4
View File
@@ -7,7 +7,6 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -81,7 +80,6 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = resolvePath(location.directory, input.path)
@@ -113,7 +111,7 @@ const layer = Layer.effect(
resource: externalResource,
save: slash(
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
(yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory,
"*",
),
),
@@ -128,5 +126,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
deps: [FSUtil.node, Location.node],
})
-2
View File
@@ -10,7 +10,6 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -28,7 +27,6 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+3 -2
View File
@@ -132,7 +132,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
packageName,
settings: configured,
modelID: resolved.modelID ?? resolved.id,
providerID: resolved.providerID,
providerID: resolved.canonical ?? resolved.providerID,
})
: undefined
const native = mapping?.package ?? resolved.package
@@ -161,6 +161,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
@@ -169,7 +170,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
provider: resolved.canonical ?? resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
+99 -111
View File
@@ -4,42 +4,26 @@ 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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "./app.js"
import { Cause, Context, Effect, Exit, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope, Semaphore } from "effect"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { Mcp } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
import { PluginRuntime } from "./plugin/runtime.js"
import { WebSearch } from "./websearch.js"
import { Reference } from "./reference.js"
import { Rpc } from "./rpc.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
import { Generate } from "./generate.js"
import { Permission } from "./permission.js"
export interface Interface {
readonly activate: (
plugins: readonly Versioned[],
failures?: readonly Failure[],
) => Effect.Effect<void>
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 Versioned = PluginDefinition & {
readonly version: string
export type Generation = PluginDefinition & {
readonly revision: string
readonly source?: Plugin.Source
readonly features?: Plugin.Features
}
@@ -52,11 +36,22 @@ 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: Versioned; readonly scope: Scope.Closeable }>()
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
const ready = yield* Latch.make(true)
const pending = new Set<object>()
const hold = () =>
Effect.sync(() => {
const token = {}
pending.add(token)
ready.closeUnsafe()
return Effect.sync(() => {
if (pending.delete(token) && pending.size === 0) ready.openUnsafe()
})
})
let inventory: Plugin.Info[] = []
let host: Parameters<PluginDefinition["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const load = Effect.fnUntraced(function* (plugin: Generation) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() =>
@@ -83,7 +78,7 @@ const layer = Layer.effect(
})
const activate = Effect.fn("Plugin.activate")(function* (
plugins: readonly Versioned[],
plugins: readonly Generation[],
failures: readonly Failure[] = [],
) {
const definitions = plugins.map((plugin) => ({ ...plugin, id: Plugin.ID.make(plugin.id) }))
@@ -93,78 +88,92 @@ const layer = Layer.effect(
ids.add(definition.id)
}
yield* 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.version === definition.version
})
) {
const nextInventory = [...Array.from(active.values(), (entry) => activeInfo(entry.plugin)), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
yield* State.batch(
yield* Effect.acquireUseRelease(
hold(),
() =>
lock.withPermit(
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)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
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
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
) {
for (const definition of definitions) {
const entry = active.get(definition.id)
if (entry) active.set(definition.id, { ...entry, plugin: definition })
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
const nextInventory = [...definitions.map(activeInfo), ...failures]
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
inventory = nextInventory
yield* bus.publish(Plugin.Event.Updated, {})
return
}
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]
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)
const loaded = yield* load(definition)
if (loaded.scope !== undefined) {
active.set(definition.id, { plugin: definition, scope: loaded.scope })
nextInventory.push(activeInfo(definition))
continue
}
nextInventory.push({
id: definition.id,
source: definition.source ?? { type: "builtin" },
state: { status: "failed", error: loaded.error },
features: { server: true, ...definition.features },
})
if (!previous) continue
const restored = yield* load(previous.plugin)
if (restored.scope !== undefined) {
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
continue
}
yield* Effect.logError("failed to restore plugin; deactivating", {
"plugin.id": definition.id,
})
}
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]
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
)
yield* bus.publish(Plugin.Event.Updated, {})
}),
),
(release) => release,
)
})
yield* Effect.addFinalizer((exit) =>
Effect.gen(function* () {
active.clear()
yield* State.batch(Scope.close(scope, exit), { flush: false })
}),
)
const close = (exit: Exit.Exit<unknown, unknown>) =>
lock.withPermit(
Effect.gen(function* () {
active.clear()
yield* State.batch(Scope.close(scope, exit), { flush: false })
}),
)
yield* Effect.addFinalizer(close)
const service = Service.of({
activate,
close,
awaitActivation: ready.await,
hold,
list: Effect.fn("Plugin.list")(function* () {
return inventory
}),
@@ -174,7 +183,7 @@ const layer = Layer.effect(
}),
)
function activeInfo(plugin: Versioned): Plugin.Info {
function activeInfo(plugin: Generation): Plugin.Info {
return {
id: Plugin.ID.make(plugin.id),
source: plugin.source ?? { type: "builtin" },
@@ -186,26 +195,5 @@ function activeInfo(plugin: Versioned): Plugin.Info {
export const node = makeLocationNode({
service: Service,
layer,
deps: [
Bus.node,
App.node,
Agent.node,
AISDK.node,
Catalog.node,
Command.node,
Integration.node,
KV.node,
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,
Generate.node,
Permission.node,
],
deps: [PluginHost.requirements],
})
+55 -4
View File
@@ -5,6 +5,7 @@ import type { IntegrationMethodRegistration } from "@opencode-ai/plugin/effect/i
import { EventManifest } from "@opencode-ai/schema/event-manifest"
import type { Event } from "@opencode-ai/schema/event"
import { ServerConfig } from "@opencode-ai/schema/mcp"
import type { Session } from "@opencode-ai/schema/session"
import { App } from "../app.js"
import { Effect, Schema, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -31,7 +32,9 @@ import { WebSearch } from "../websearch.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
import { Service, type Interface } from "../plugin.js"
import { SessionGenerate } from "../session/generate.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
const mutable = <T>(value: T) => value as DeepMutable<T>
type RpcEvent = Event.Payload & {
@@ -40,7 +43,10 @@ type RpcEvent = Event.Payload & {
readonly data: Readonly<Record<string, unknown>>
}
const isRpcEvent = (event: Event.Payload): event is RpcEvent => event.type.startsWith("rpc.")
export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, pluginID: string = "test") {
export const make = Effect.fn("PluginHost.make")(function* (
plugin: Pick<Interface, "list">,
pluginID: string = "test",
) {
const app = yield* App.Metadata
const agents = yield* Agent.Service
const aisdk = yield* AISDK.Service
@@ -61,6 +67,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const permission = yield* Permission.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
const route = <A, E, R>(sessionID: Session.ID, effect: Effect.Effect<A, E, R>) =>
runtime.session.get(sessionID).pipe(Effect.flatMap((session) => runtime.instances.provide(session)(effect)))
const locationInfo = () =>
new Location.Info({
directory: location.directory,
@@ -439,8 +447,28 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
switchAgent: runtime.session.switchAgent,
switchModel: runtime.session.switchModel,
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
generate: (input) =>
route(
input.sessionID,
SessionGenerate.Service.use((generate) => generate.generate(input)),
).pipe(Effect.map((text) => ({ text }))),
command: (input) =>
route(
input.sessionID,
Effect.gen(function* () {
const plugins = yield* Service
yield* plugins.awaitActivation
const commands = yield* Command.Service
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: { text: input.text, files: input.files, agents: input.agents, skills: input.skills },
delivery: input.delivery ?? "steer",
},
})
}),
),
rename: runtime.session.rename,
move: runtime.session.move,
synthetic: runtime.session.synthetic,
@@ -455,6 +483,29 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
return context
})
export const requirements = LayerNode.group([
App.node,
Agent.node,
AISDK.node,
Catalog.node,
Command.node,
Bus.node,
Integration.node,
KV.node,
Mcp.node,
Location.node,
Reference.node,
Rpc.node,
Skill.node,
Tool.node,
Vcs.node,
WebSearch.node,
Generate.node,
Permission.node,
PluginHooks.node,
PluginRuntime.node,
])
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
+7 -12
View File
@@ -3,7 +3,7 @@ export * as InstancePlugins from "./instance.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
/**
* Holds the plugins one instance is born with. Unlike the host-global
@@ -13,16 +13,13 @@ import type { Versioned } from "../plugin.js"
* for the instance's lifetime; runtime dynamism lives inside plugins through
* the container transform/reload APIs.
*
* Limitations: `vcs` marker declarations in an instance list are not seen by
* `ProjectMarkers` (it is global and runs during project resolution, before
* the instance exists unlike `SdkPlugins`, whose declarations it consumes
* directly), and config plugin operations may disable instance plugins by id,
* matching `SdkPlugins` behavior.
* Config plugin operations may disable instance plugins by id, matching
* `SdkPlugins` behavior.
*/
export type List = readonly Plugin[]
export interface Interface {
readonly all: () => readonly Versioned[]
readonly all: () => readonly Generation[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstancePlugins") {}
@@ -33,8 +30,8 @@ export const node = makeLocationNode({
deps: [],
})
// The constant version is load-bearing: the plugin registry treats an
// unchanged (id, version) pair as the same plugin across activations, which
// The constant revision is load-bearing: the plugin registry treats an
// unchanged (id, revision) pair as the same plugin across activations, which
// is only correct because a bound list never changes after creation.
// `source: "sdk"` means host-contributed; an instance list is the
// per-instance form of the same channel.
@@ -43,8 +40,6 @@ export function bound(plugins: List) {
if (duplicates.length > 0) {
throw new Error(`duplicate instance plugin ids: ${duplicates.map((plugin) => plugin.id).join(", ")}`)
}
const stamped = plugins.map(
(plugin): Versioned => ({ ...plugin, version: "instance", source: { type: "sdk" } }),
)
const stamped = plugins.map((plugin): Generation => ({ ...plugin, revision: "instance", source: { type: "sdk" } }))
return Layer.succeed(Service, Service.of({ all: () => stamped }))
}
+47 -97
View File
@@ -26,7 +26,6 @@ import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
@@ -90,100 +89,52 @@ import { VcsGitPlugin } from "./vcs/git.js"
import { WarmingPlugin } from "./warming.js"
import { WellKnownPlugin } from "../wellknown/plugin.js"
const services = Effect.fn("PluginInternal.services")(function* () {
const agent = yield* Agent.Service
const processes = yield* AppProcess.Service
const catalog = yield* Catalog.Service
const command = yield* Command.Service
const config = yield* Config.Service
const credential = yield* Credential.Service
const pluginSources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const environment = yield* Environment.Service
const mutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service
const locationWatcherPolicy = yield* LocationWatcherPolicy.Service
const filesystem = yield* FileSystem.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = yield* HttpClient.HttpClient
const image = yield* Image.Service
const instructionDiscovery = yield* InstructionDiscovery.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const location = yield* Location.Service
const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service
const mcp = yield* Mcp.Service
const npm = yield* Npm.Service
const permission = yield* Permission.Service
const runtime = yield* PluginRuntime.Service
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const websearch = yield* WebSearch.Service
const ripgrep = yield* Ripgrep.Service
const compaction = yield* SessionCompaction.Service
const instructions = yield* SessionInstructions.Service
const shell = yield* Shell.Service
const shellSelect = yield* ShellSelect.Service
const snapshot = yield* Snapshot.Service
const skill = yield* Skill.Service
const skillDiscovery = yield* SkillDiscovery.Service
const tools = yield* Tool.Service
const toolOutput = yield* ToolOutput.Service
const watcher = yield* Watcher.Service
const wellknown = yield* WellKnown.Service
return Context.mergeAll(
Context.make(Agent.Service, agent),
Context.make(AppProcess.Service, processes),
Context.make(Catalog.Service, catalog),
Context.make(Command.Service, command),
Context.make(Config.Service, config),
Context.make(Credential.Service, credential),
Context.make(ConfigPluginSource.Service, pluginSources),
Context.make(Bus.Service, bus),
Context.make(Environment.Service, environment),
Context.make(FileMutation.Service, mutation),
Context.make(Formatter.Service, formatter),
Context.make(LocationWatcherPolicy.Service, locationWatcherPolicy),
Context.make(FileSystem.Service, filesystem),
Context.make(FSUtil.Service, fs),
Context.make(Global.Service, global),
Context.make(HttpClient.HttpClient, http),
Context.make(Image.Service, image),
Context.make(InstructionDiscovery.Service, instructionDiscovery),
Context.make(Integration.Service, integration),
Context.make(KV.Service, kv),
Context.make(Location.Service, location),
Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models),
Context.make(Mcp.Service, mcp),
Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission),
Context.make(PluginRuntime.Service, runtime),
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(WebSearch.Service, websearch),
Context.make(Ripgrep.Service, ripgrep),
Context.make(SessionCompaction.Service, compaction),
Context.make(SessionInstructions.Service, instructions),
Context.make(Shell.Service, shell),
Context.make(ShellSelect.Service, shellSelect),
Context.make(Snapshot.Service, snapshot),
Context.make(Skill.Service, skill),
Context.make(SkillDiscovery.Service, skillDiscovery),
Context.make(Tool.Service, tools),
Context.make(ToolOutput.Service, toolOutput),
Context.make(Watcher.Service, watcher),
Context.make(WellKnown.Service, wellknown),
)
})
const services = [
Agent.Service,
AppProcess.Service,
Catalog.Service,
Command.Service,
Config.Service,
Credential.Service,
Bus.Service,
Environment.Service,
FileMutation.Service,
Formatter.Service,
LocationWatcherPolicy.Service,
FileSystem.Service,
FSUtil.Service,
Global.Service,
HttpClient.HttpClient,
Image.Service,
InstructionDiscovery.Service,
Integration.Service,
KV.Service,
Location.Service,
LocationMutation.Service,
ModelsDev.Service,
Mcp.Service,
Npm.Service,
Permission.Service,
PluginRuntime.Service,
Form.Service,
ReadToolFileSystem.Service,
Reference.Service,
WebSearch.Service,
Ripgrep.Service,
SessionCompaction.Service,
SessionInstructions.Service,
Shell.Service,
ShellSelect.Service,
Snapshot.Service,
Skill.Service,
SkillDiscovery.Service,
Tool.Service,
ToolOutput.Service,
Watcher.Service,
WellKnown.Service,
] as const
type ContextServices<A> = A extends Context.Context<infer R> ? R : never
export type Requirements = ContextServices<Effect.Success<ReturnType<typeof services>>>
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
export const requirements = LayerNode.group([
Agent.node,
@@ -192,7 +143,6 @@ export const requirements = LayerNode.group([
Command.node,
Config.node,
Credential.node,
ConfigPluginSource.node,
Bus.node,
Environment.node,
FileMutation.node,
@@ -282,12 +232,12 @@ const post = [
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
const context = yield* services()
// Capture only services; activation supplies the child Scope and batching context.
const context = Context.pick(...services)(yield* Effect.context<Requirements>())
const resolve = (plugins: readonly InternalPlugin[]) =>
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+35 -21
View File
@@ -8,23 +8,17 @@ import { readdir } from "node:fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
const Module = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
@@ -32,22 +26,39 @@ const Definition = Schema.Struct({
]),
})
export class LoadError extends Schema.TaggedError<LoadError>()("PluginModule.LoadError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
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 = local
? { entrypoint: pathToFileURL(operation.target).href }
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const installed: Npm.EntryPoint = local
? { directory: path.dirname(operation.target), entrypoint: pathToFileURL(operation.target).href }
: options?.install === false
? yield* npm.resolve(operation.target, { subpaths: ["server", ""] })
: yield* npm.add(operation.target, { subpaths: ["server", ""] })
const entrypoint = installed.entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
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 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 value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const value = (yield* Schema.decodeUnknownEffect(Module)(mod).pipe(
Effect.mapError(
(cause) =>
new LoadError({
message: "Plugin must export a default definition with an id and an effect or setup function.",
cause,
}),
),
)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
const features = local
? yield* localFeatures(operation.target)
@@ -63,24 +74,27 @@ export const load = Effect.fn("PluginModule.load")(function* (
return {
id: plugin.id,
features,
vcs: plugin.vcs,
version: JSON.stringify(operation),
revision: JSON.stringify([operation, installed.revision]),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
: {
type: "package" as const,
target: operation.target,
...(installed.version ? { version: installed.version } : {}),
},
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
} 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 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}`),
)
["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 } : {}),
+79 -77
View File
@@ -6,17 +6,14 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { ConfigProviderV1 } from "../../v1/config/provider.js"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
const RemoteResponse = Schema.Struct({ providers: Schema.Record(Schema.String, ConfigProvider.Info) })
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -89,7 +86,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined
let providers: typeof RemoteResponse.Type.providers | undefined
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
@@ -117,59 +114,73 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
yield* load()
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
catalog.provider.update(providerID, (provider) => {
if (source && source.provider !== provider)
Object.assign(provider, structuredClone(source.provider), { id: provider.id })
provider.integrationID = Integration.ID.make("opencode")
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
provider.package = item.npm ? Provider.aisdk(item.npm) : ""
provider.settings = {
...provider.settings,
...withoutCredentials(item.options),
...(item.api ? { baseURL: item.api } : {}),
}
provider.headers = { ...provider.headers, ...item.options?.headers }
provider.package = item.package ?? provider.package
provider.settings = Provider.mergeOverlay(
withoutCredentials(provider.settings),
withoutCredentials(item.settings),
)
provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
provider.body = Provider.mergeOverlay(provider.body, item.body)
})
for (const [modelID, config] of Object.entries(item.models ?? {})) {
const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID)
catalog.model.update(providerID, modelID, (model) => {
if (config.family !== undefined) model.family = Model.Family.make(config.family)
Object.assign(model, structuredClone(base ?? model))
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.id !== undefined) model.modelID = Model.ID.make(config.id)
model.compatibility = Model.compatibility(config.interleaved) ?? model.compatibility
if (config.provider !== undefined) {
model.package = config.provider.npm ? Provider.aisdk(config.provider.npm) : undefined
if (config.provider.api) model.settings = { ...model.settings, baseURL: config.provider.api }
}
if (config.tool_call !== undefined) model.capabilities.tools = config.tool_call
if (config.modalities?.input !== undefined) model.capabilities.input = [...config.modalities.input]
if (config.modalities?.output !== undefined) model.capabilities.output = [...config.modalities.output]
model.headers = { ...model.headers, ...config.headers }
model.settings = { ...model.settings, ...ConfigProviderOptionsV1.model(withoutCredentials(config.options)) }
if (config.variants !== undefined) {
model.variants ??= []
for (const [id, options] of Object.entries(config.variants)) {
const variantID = Model.VariantID.make(id)
let existing = model.variants.find((item) => item.id === variantID)
if (!existing) {
existing = { id: variantID }
model.variants.push(existing)
}
existing.headers = { ...existing.headers, ...options.headers }
existing.settings = {
...existing.settings,
...ConfigProviderOptionsV1.model(withoutCredentials(options)),
}
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
if (config.capabilities !== undefined)
model.capabilities = {
...config.capabilities,
input: [...config.capabilities.input],
output: [...config.capabilities.output],
}
model.settings = Provider.mergeOverlay(
withoutCredentials(model.settings),
withoutCredentials(config.settings),
)
model.headers = Provider.mergeHeaders(model.headers, config.headers)
model.body = Provider.mergeOverlay(model.body, config.body)
for (const variant of config.variants ?? []) {
let existing = model.variants.find((item) => item.id === variant.id)
if (!existing) {
existing = { id: variant.id }
model.variants.push(existing)
}
if (variant.settings !== undefined)
existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings))
if (variant.headers !== undefined)
existing.headers = Provider.mergeHeaders(existing.headers, variant.headers)
if (variant.body !== undefined)
existing.body = Provider.mergeOverlay(
existing.body,
variantBody(variant.body, model.package ?? item.package ?? source?.provider.package),
)
}
if (config.release_date !== undefined) {
const released = Date.parse(config.release_date)
model.time.released = Number.isFinite(released) ? released : 0
}
if (config.cost !== undefined) {
model.cost = remoteCost(config.cost)
}
model.status = config.status ?? "active"
model.enabled = config.status !== "deprecated"
if (config.limit !== undefined) model.limit = { ...config.limit }
if (config.cost !== undefined)
model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({
tier: cost.tier && { ...cost.tier },
input: cost.input,
output: cost.output,
cache: {
read: cost.cache?.read ?? Money.USDPerMillionTokens.zero,
write: cost.cache?.write ?? Money.USDPerMillionTokens.zero,
},
}))
model.enabled = !config.disabled
if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit }
})
}
}
@@ -208,7 +219,7 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
const token = value.type === "oauth" ? value.access : value.key
return http
.execute(
HttpClientRequest.get(`${server}/api/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
@@ -219,14 +230,29 @@ function fetchProviders(http: HttpClient.HttpClient, value: Credential.Value) {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.map((remote) => remote.config.provider),
Effect.map((remote) => remote.providers),
)
}),
)
}
function withoutCredentials(body: Readonly<Record<string, unknown>> | undefined) {
return Object.fromEntries(Object.entries(body ?? {}).filter(([key]) => key !== "apiKey" && key !== "headers"))
function variantBody(body: Readonly<Record<string, unknown>>, packageName: string | undefined) {
if (packageName !== Provider.aisdk("@ai-sdk/openai")) return body
const { reasoningEffort, reasoningSummary, ...native } = body
const reasoning = {
...(typeof reasoningEffort === "string" ? { effort: reasoningEffort } : {}),
...(typeof reasoningSummary === "string" ? { summary: reasoningSummary } : {}),
}
if (Object.keys(reasoning).length === 0) return body
// Existing Console variants stored SDK options here before V2 consumed raw bodies.
return Provider.mergeOverlay({ reasoning }, native)
}
function withoutCredentials<Value>(body: Readonly<Record<string, Value>> | undefined) {
return (
body &&
Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key)))
)
}
function normalizeServer(input: unknown) {
@@ -242,30 +268,6 @@ function normalizeServer(input: unknown) {
})
}
function remoteCost(input: NonNullable<(typeof ConfigProviderV1.Model.Type)["cost"]>) {
const base = {
input: Money.USDPerMillionTokens.make(input.input),
output: Money.USDPerMillionTokens.make(input.output),
cache: {
read: Money.USDPerMillionTokens.make(input.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.cache_write ?? 0),
},
}
if (!input.context_over_200k) return [base]
return [
base,
{
tier: { type: "context" as const, size: 200_000 },
input: Money.USDPerMillionTokens.make(input.context_over_200k.input),
output: Money.USDPerMillionTokens.make(input.context_over_200k.output),
cache: {
read: Money.USDPerMillionTokens.make(input.context_over_200k.cache_read ?? 0),
write: Money.USDPerMillionTokens.make(input.context_over_200k.cache_write ?? 0),
},
},
]
}
function poll(http: HttpClient.HttpClient, server: string, deviceCode: string, interval: Duration.Duration) {
const loop = (wait: Duration.Duration): Effect.Effect<Credential.OAuth, unknown> =>
Effect.gen(function* () {
@@ -0,0 +1,82 @@
export * as PluginRuntimeProvider from "./runtime-provider.js"
import { Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Agent } from "../agent.js"
import { Job } from "../job.js"
import { Instance } from "../instance/service.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Mcp } from "../mcp/index.js"
import { PersistentPty } from "../persistent-pty.js"
import { Session } from "../session.js"
import { PluginRuntime } from "./runtime.js"
// Application wiring stays outside the facade so Plugin can be imported by Session.
export const configured = (cell: PluginRuntime.Cell) =>
makeGlobalNode({
name: "plugin-runtime-provider",
layer: Layer.effectDiscard(
Effect.gen(function* () {
const sessions = yield* Session.Service
const instances = yield* Instance.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const persistentPty = yield* PersistentPty.Service
const runtime: PluginRuntime.Interface = {
session: sessions,
instances,
job: jobs,
persistentPty,
location: {
agent: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* agents.list(),
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
},
mcp: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
},
},
}
cell.runtime = runtime
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (cell.runtime === runtime) cell.runtime = undefined
}),
)
}),
),
deps: [
PluginRuntime.node,
Session.node,
Instance.byLocationNode,
Job.node,
LocationServiceMap.node,
PersistentPty.node,
],
})
export const node = configured(PluginRuntime.defaultCell)
+12 -76
View File
@@ -1,14 +1,14 @@
export * as PluginRuntime from "./runtime.js"
import { Context, Effect, Layer } from "effect"
import { Agent } from "../agent.js"
import type { Agent } from "../agent.js"
import type { Instance } from "../instance/service.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Job } from "../job.js"
import { Location } from "../location.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Mcp } from "../mcp/index.js"
import { PersistentPty } from "../persistent-pty.js"
import { Session } from "../session.js"
import type { Job } from "../job.js"
import type { Location } from "../location.js"
import type { Mcp } from "../mcp/index.js"
import type { PersistentPty } from "../persistent-pty.js"
import type { Session } from "../session.js"
export interface Interface {
readonly session: Pick<
@@ -17,8 +17,6 @@ export interface Interface {
| "create"
| "messages"
| "prompt"
| "generate"
| "command"
| "rename"
| "move"
| "resume"
@@ -29,6 +27,7 @@ export interface Interface {
| "wait"
| "context"
>
readonly instances: Pick<Instance.Interface, "provide">
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel" | "completeBackground">
readonly persistentPty: Pick<PersistentPty.Interface, "read">
readonly location: {
@@ -60,7 +59,7 @@ const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A
return f(runtime)
})
const defaultCell = makeCell()
export const defaultCell = makeCell()
export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
Layer.succeed(
@@ -71,8 +70,6 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
move: (input) => require(cell, (runtime) => runtime.session.move(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
@@ -83,6 +80,9 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
context: (sessionID) => require(cell, (runtime) => runtime.session.context(sessionID)),
},
instances: {
provide: (session) => (effect) => require(cell, (runtime) => runtime.instances.provide(session)(effect)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
wait: (input) => require(cell, (runtime) => runtime.job.wait(input)),
@@ -106,70 +106,6 @@ export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
}),
)
export const providerLayerWithCell = (cell: Cell) =>
Layer.effectDiscard(
Effect.gen(function* () {
const sessions = yield* Session.Service
const jobs = yield* Job.Service
const locations = yield* LocationServiceMap.Service
const persistentPty = yield* PersistentPty.Service
const runtime: Interface = {
session: sessions,
job: jobs,
persistentPty,
location: {
agent: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* agents.list(),
}
}).pipe(Effect.provide(locations.get(ref)), Effect.orDie),
},
mcp: {
list: (ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
const mcp = yield* Mcp.Service
return {
location: new Location.Info({
directory: location.directory,
workspaceID: location.workspaceID,
project: location.project,
}),
data: yield* mcp.servers(),
}
}).pipe(Effect.provide(locations.get(ref))),
},
},
}
cell.runtime = runtime
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (cell.runtime === runtime) cell.runtime = undefined
}),
)
}),
)
export const layer = layerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
// Raw layer replacements are compiled without dependencies, so cell-scoped
// provider replacements must go through this node to keep their deps wired.
export const providerNodeWithCell = (cell: Cell) =>
makeGlobalNode({
name: "plugin-runtime-provider",
layer: providerLayerWithCell(cell),
deps: [node, Session.node, Job.node, LocationServiceMap.node, PersistentPty.node],
})
export const providerNode = providerNodeWithCell(defaultCell)
+6 -7
View File
@@ -4,15 +4,14 @@ import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Context, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import type { Versioned } from "../plugin.js"
import type { Generation } from "../plugin.js"
export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
/**
* Holds the plugins an embedder (the `@opencode-ai/sdk` host) contributes,
* so `PluginSupervisor` can add them on every Location boot through the ordinary
* generation path that `PluginSupervisor` uses for plugins discovered from
* config. Registration publishes an unlocated update so every booted Location
* so the application loader can add them on every Location boot through its
* ordinary generation path. Registration publishes an unlocated update so every booted Location
* reloads its plugin generation from the shared store.
*
* Each host-global layer owns one private store. Location graphs reuse that
@@ -21,7 +20,7 @@ export const Updated = Bus.ephemeral({ type: "sdk.plugin.updated", schema: {} })
*/
export interface Interface {
readonly register: (plugin: Plugin) => Effect.Effect<void>
readonly all: () => readonly Versioned[]
readonly all: () => readonly Generation[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SdkPlugins") {}
@@ -30,12 +29,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugins = new Map<string, Versioned>()
const plugins = new Map<string, Generation>()
let revision = 0
return Service.of({
register: (plugin) =>
Effect.sync(() => {
plugins.set(plugin.id, { ...plugin, version: String(++revision), source: { type: "sdk" } })
plugins.set(plugin.id, { ...plugin, revision: String(++revision), source: { type: "sdk" } })
}).pipe(Effect.andThen(bus.publish(Updated, {})), Effect.asVoid),
all: () => [...plugins.values()],
})
+7 -4
View File
@@ -3,10 +3,11 @@
export * as SkillPlugin from "./skill.js"
import { define, type Context } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Skill } from "../skill.js"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { Config } from "../config.js"
import os from "os"
import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" }
@@ -68,9 +69,11 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia
})
const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () {
const sources = yield* ConfigPluginSource.Service
return (yield* sources.operations())
.map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target))
const config = yield* Config.Service
return (yield* config.entries())
.filter((entry): entry is Document => entry.type === "document")
.flatMap((entry) => entry.info.plugins ?? [])
.map((entry) => (typeof entry === "string" ? entry : entry.package))
.toSorted()
})
@@ -1,14 +0,0 @@
export * as PluginSupervisor from "./supervisor-service.js"
import { Context, Effect } from "effect"
/**
* Dependency-only supervisor seam. Keep this module free of implementation
* imports: the supervisor reaches PluginRuntime, which depends on Session.
*/
export interface Interface {
/** Wait for the initial plugin generation and startup updates to settle. */
readonly flush: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginSupervisor") {}
+97 -48
View File
@@ -1,8 +1,7 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import { Cause, Effect, Layer, Stream } from "effect"
import path from "path"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -13,18 +12,20 @@ import { InstancePlugins } from "./instance.js"
import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { Service } from "./supervisor-service.js"
import { PluginUpdate } from "./update.js"
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
pre: readonly Plugin.Generation[],
post: readonly Plugin.Generation[],
operations: readonly ConfigPluginSource.Operation[],
install: boolean,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Versioned>()
const packages = new Map<string, Plugin.Generation>()
const pending = new Set<string>()
const failures = new Map<
string,
Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
@@ -51,17 +52,23 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
),
),
const plugin = yield* PluginModule.load(operation, { install }).pipe(
Effect.catchCause((cause) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
const error = Cause.squash(cause)
return Effect.logWarning("failed to load plugin", { target: operation.target, ref, cause }).pipe(
Effect.as({ error: error instanceof PluginModule.LoadError ? error.message : "Plugin failed to load", ref }),
)
}),
)
if ("pending" in plugin) {
pending.add(operation.target)
continue
}
if ("error" in plugin) {
failures.set(operation.target, {
source: pluginSource(operation.target),
state: { status: "failed", error: plugin.error },
state: { status: "failed", error: plugin.error, ref: plugin.ref },
features: { server: true },
})
continue
@@ -80,81 +87,122 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
),
pending: [...pending],
}
})
export const layer = Layer.effect(
Service,
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* Plugin.Service
const sdk = yield* SdkPlugins.Service
const instance = yield* InstancePlugins.Service
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const npm = yield* Npm.Service
const ready = yield* Latch.make()
const updates = yield* PluginUpdate.Service
const internal = yield* PluginInternal.list()
let release: Effect.Effect<void> | undefined = yield* registry.hold()
yield* Effect.addFinalizer(() => release ?? Effect.void)
// Built-ins capture services from this layer; unload them before those services close.
yield* Effect.addFinalizer(registry.close)
let packages = new Set<string>()
let outdated = new Set<string>()
let generation = 0
let observed = 0
const activate = Effect.fn("PluginSupervisor.activate")(function* () {
// Resolve OpenCode's internal plugins with their privileged Location services.
const internal = yield* PluginInternal.list()
const current = ++generation
// Combine internal plugins with host-contributed plugins in boot order.
// Instance-bound plugins come last: later activation can override earlier
// container writes, so the instance's explicit choices win over globals.
const pre = [
...internal.pre.map((plugin) => ({ ...plugin, version: "internal", source: { type: "builtin" as const } })),
...internal.pre.map((plugin) => ({ ...plugin, revision: "internal", source: { type: "builtin" as const } })),
...sdk.all(),
...instance.all(),
]
const post = internal.post.map((plugin) => ({
...plugin,
version: "internal",
revision: "internal",
source: { type: "builtin" as const },
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
if (resolved.refreshes.length) {
yield* Effect.forEach(
resolved.refreshes,
(target) =>
npm
.add(target, { subpaths: ["server", ""], refresh: true })
.pipe(
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
),
{ concurrency: "unbounded", discard: true },
).pipe(Effect.forkDetach)
}
// Activate everything available locally before waiting on missing package installs.
const immediate = yield* resolve(pre, post, operations, false)
const source = (source: Plugin.Source) =>
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
const apply = (resolved: typeof immediate) =>
registry.activate(
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
resolved.failures.map((failure) => ({ ...failure, source: source(failure.source) })),
)
yield* apply(immediate)
const resolved = immediate.pending.length ? yield* resolve(pre, post, operations, true) : immediate
if (resolved !== immediate) yield* apply(resolved)
const loaded = new Set(
[...resolved.plugins, ...resolved.failures].flatMap((plugin) =>
plugin.source?.type === "package" ? [plugin.source.target] : [],
),
)
packages = loaded
yield* Effect.forEach(
loaded,
(target) => updates.check(target).pipe(Effect.map((available) => [target, available] as const)),
{ concurrency: "unbounded" },
).pipe(
Effect.flatMap((checked) => {
if (current !== generation) return Effect.void
const next = new Set(checked.flatMap(([target, available]) => (available ? [target] : [])))
if (next.size === outdated.size && [...next].every((target) => outdated.has(target))) return Effect.void
outdated = next
return apply(resolved)
}),
Effect.forkScoped({ startImmediately: true }),
)
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
const reloads = Stream.merge(
Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])),
updates.changes().pipe(
Stream.filter((update) => packages.has(update.target)),
Stream.tap((update) =>
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
),
Stream.map(() => undefined),
),
).pipe(
// Make accepted work visible to awaitActivation before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
yield* ready.close
if (!release) release = yield* registry.hold()
return observed
}),
),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
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"),
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate().pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause })))
if (observed === target) yield* ready.open
if (observed !== target) return
const settled = release
release = undefined
if (settled) yield* settled
}),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: ready.await })
yield* Effect.sleep("24 hours").pipe(
Effect.andThen(
Effect.acquireUseRelease(
registry.hold(),
() => activate(),
(release) => release,
),
),
Effect.forever,
Effect.forkScoped,
)
}),
)
@@ -163,6 +211,7 @@ const nodeDeps = [
SdkPlugins.node,
InstancePlugins.node,
ConfigPluginSource.node,
PluginUpdate.node,
Bus.node,
Npm.node,
PluginInternal.requirements,
@@ -170,7 +219,7 @@ const nodeDeps = [
function pluginSource(target: string): Plugin.Source {
if (path.isAbsolute(target)) return { type: "local", path: target }
return { type: "package", package: target }
return { type: "package", target }
}
export const node = makeLocationNode({ service: Service, layer, deps: nodeDeps })
export const node = makeLocationNode({ name: "plugin-supervisor", layer, deps: nodeDeps })
+55
View File
@@ -0,0 +1,55 @@
export * as PluginUpdate from "./update.js"
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
import { Npm } from "@opencode-ai/util/npm"
import { EffectFlock } from "@opencode-ai/util/effect-flock"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { KeyedMutex } from "../effect/keyed-mutex.js"
const interval = 24 * 60 * 60 * 1_000
export interface Interface {
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const npm = yield* Npm.Service
const locks = KeyedMutex.makeUnsafe<string>()
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
return Service.of({
check: (target, options) =>
locks.withLock(target)(
Effect.gen(function* () {
const checkedAt = yield* Clock.currentTimeMillis
const current = status.get(target)
if (!options?.refresh && current && checkedAt - current.checkedAt < interval) return current.outdated
const outdated = yield* npm.check(target).pipe(
Effect.tapCause((cause) => Effect.logWarning("failed to check plugin update", { target, cause })),
Effect.option,
)
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
status.set(target, { outdated: value, checkedAt })
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
return value
}),
),
update: (target) =>
npm.update(target).pipe(
Effect.tap(() => Effect.sync(() => status.delete(target))),
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
Effect.asVoid,
),
changes: () => Stream.fromPubSub(changes),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Npm.node] })
-1
View File
@@ -20,7 +20,6 @@ import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
vcs: { id: "git", markers: [".git"] },
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
-1
View File
@@ -24,7 +24,6 @@ import {
export const Plugin = define({
id: "opencode.vcs.hg",
vcs: { id: "hg", markers: [".hg"] },
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
+5 -22
View File
@@ -13,7 +13,6 @@ import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectMarkers } from "./project/markers.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
@@ -44,7 +43,6 @@ export interface Resolved {
// This checkout's main directory; the stored project canonical may be another clone.
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
@@ -98,7 +96,6 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const markers = yield* ProjectMarkers.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
@@ -172,7 +169,7 @@ const layer = Layer.effect(
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const found = yield* fs
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
@@ -318,10 +315,9 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
options?: { readonly discovery?: boolean },
_options?: { readonly discovery?: boolean },
) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* markers.discover(directory, options)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
@@ -330,7 +326,7 @@ const layer = Layer.effect(
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
if (repo) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -346,27 +342,14 @@ const layer = Layer.effect(
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
})
}
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
if (hg) {
return yield* persist({
...hg,
canonical: hg.directory,
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
})
}
if (marker) {
const previous = yield* cached(marker.marker)
return yield* persist({
previous,
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
directory: marker.directory,
canonical: marker.directory,
vcs: { type: marker.type, store: marker.marker },
})
}
@@ -385,5 +368,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
})
-191
View File
@@ -1,191 +0,0 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
export interface Match {
readonly type: string
readonly directory: AbsolutePath
readonly marker: AbsolutePath
}
export interface Interface {
readonly discover: (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
// The filesystem half of discovery: walk up for config, scan plugin
// directories, and read configured plugin operations. This is the part a
// no-discovery caller must skip — it imports plugin modules.
const scanOperations = Effect.fnUntraced(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
return yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(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),
)
},
)
})
const discover = Effect.fn("ProjectMarkers.discover")(function* (
directory: AbsolutePath,
options?: { readonly discovery?: boolean },
) {
// discovery: false skips the config scan and its plugin module loading;
// sdk-declared vcs markers are host-explicit, not ambient, so they stay.
const operations = options?.discovery === false ? [] : yield* scanOperations(directory)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
}
}
if (!markers.size) return undefined
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
Effect.map((entries) => entries[0]),
Effect.orElseSucceed(() => undefined),
)
if (!marker) return undefined
const type = markers.get(path.basename(marker))
if (!type) return undefined
return {
type,
directory: AbsolutePath.make(path.dirname(marker)),
marker: AbsolutePath.make(marker),
} satisfies Match
})
return Service.of({ discover, targets: () => [...known] })
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
})
-45
View File
@@ -8,7 +8,6 @@ import { Project } from "./project.js"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Instance } from "./instance/service.js"
import { Database } from "./database/database.js"
@@ -45,14 +44,11 @@ import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { PluginSupervisor } from "./plugin/supervisor-service.js"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { Job } from "./job.js"
import { Command } from "./command.js"
import { Global } from "@opencode-ai/util/global"
import { SessionEnvironment } from "./session/environment.js"
import { InstructionEntry } from "./session/instruction-entry.js"
@@ -184,20 +180,6 @@ export interface Interface {
readonly prompt: (
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["prompt"]>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
prompt: string
}) => Effect.Effect<string, NotFoundError | SessionGenerate.Error>
readonly command: (input: {
sessionID: SessionSchema.ID
command: string
text: string
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | Command.NotFoundError | Command.ExecutionError>
readonly shell: (
input: Parameters<Session.Handle["shell"]>[0] & { sessionID: SessionSchema.ID },
) => ReturnType<Session.Handle["shell"]>
@@ -398,33 +380,6 @@ const layer = Layer.effect(
),
),
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(instances.provide(session))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
const session = yield* result.get(input.sessionID)
const commands = yield* Effect.gen(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
}).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
invocation: {
sessionID: input.sessionID,
prompt: {
text: input.text,
files: input.files,
agents: input.agents,
skills: input.skills,
},
delivery,
},
})
}),
shell: (input) => sessions.forSession(input.sessionID).shell(input),
skill: (input) => sessions.forSession(input.sessionID).skill(input),
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
+73 -16
View File
@@ -1,6 +1,6 @@
export * as SessionCompaction from "./compaction.js"
import { LLMClient, LLMEvent, Message } from "@opencode-ai/ai"
import { LLMClient, LLMEvent, Message, type ContentPart } from "@opencode-ai/ai"
import { Agent } from "@opencode-ai/schema/agent"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -10,18 +10,21 @@ import { llmClient } from "../effect/app-node-platform.js"
import { SessionEvent } from "./event.js"
import type { SessionContext } from "./context.js"
import type { SessionMessage } from "./message.js"
import type { SessionModelRequest } from "./model-request.js"
import { SessionModelRequest } from "./model-request.js"
import type { SessionRunnerModel } from "./runner/model.js"
import { SessionSchema } from "./schema.js"
import { toSessionError } from "./to-session-error.js"
import { Token } from "../util/token.js"
import { SessionUsage } from "./usage.js"
import { State } from "../state.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 15_000
const OUTPUT_TOKEN_MAX = 32_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const IMAGE_TOKEN_ESTIMATE = 1_500
const PDF_TOKEN_ESTIMATE = 2_000
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Objective
@@ -71,7 +74,9 @@ export type AutoInput = {
readonly prepare: SessionModelRequest.Interface["prepare"]
}
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
type RequiredInput = Pick<AutoInput, "messages" | "resolved"> & {
readonly context: SessionContext.Loaded
}
export type ManualInput = {
readonly session: SessionSchema.Info
@@ -107,6 +112,67 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionCompaction") {}
export const estimateTokens = (input: RequiredInput) => {
const index = input.messages.findLastIndex(
(message) =>
message.type === "assistant" &&
!message.error &&
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0,
)
const last = input.messages[index]
// Keep the anchor's local tool results: they are not covered by its provider usage.
const added = SessionModelRequest.unsupportedParts(
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
input.resolved.capabilities,
)
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
.reduce((sum, message) => sum + message.content.reduce((sum, part) => sum + estimatePart(part), 0), 0)
if (last?.type === "assistant" && last.tokens)
return (
added +
last.tokens.input +
last.tokens.cache.read +
last.tokens.cache.write +
last.tokens.output +
last.tokens.reasoning
)
const transcript = SessionModelRequest.baseTranscript({
agent: input.context.agent.info,
model: input.resolved,
tools: input.context.tools,
initial: input.context.initial,
messages: [],
})
return (
added +
transcript.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
input.context.tools.definitions.reduce(
(sum, tool) => sum + Token.estimate(tool.name + tool.description + JSON.stringify(tool.inputSchema)),
0,
)
)
}
const estimateMedia = (mime: string) => {
const type = mime.toLowerCase()
return type.startsWith("image/") ? IMAGE_TOKEN_ESTIMATE : type === "application/pdf" ? PDF_TOKEN_ESTIMATE : 0
}
const estimatePart = (part: ContentPart): number => {
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
if (part.type === "media") return estimateMedia(part.mediaType)
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
if (part.result.type === "content")
return part.result.value.reduce(
(sum, content) => sum + (content.type === "text" ? Token.estimate(content.text) : estimateMedia(content.mime)),
0,
)
return Token.estimate(
typeof part.result.value === "string" ? part.result.value : (JSON.stringify(part.result.value) ?? ""),
)
}
export const truncateToolOutput = (value: string) => {
if (value.length <= TOOL_OUTPUT_MAX_CHARS) return value
let end = 0
@@ -364,27 +430,18 @@ export const layer = Layer.effect(
const required = (input: RequiredInput) => {
const config = state.get()
if (!config.auto) return false
// Run the completed checkpoint before considering another automatic compaction.
const last = input.messages.at(-1)
if (last?.type === "compaction" && last.status === "completed") return false
const limit = input.resolved.limit
const context = limit.context
if (context <= 0) return false
const last = input.messages.findLast(
(message): message is SessionMessage.Assistant & { tokens: NonNullable<SessionMessage.Assistant["tokens"]> } =>
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const output = Math.min(limit.output, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
context - Math.max(output, config.buffer),
)
const used =
last.tokens.input +
last.tokens.output +
last.tokens.reasoning +
last.tokens.cache.read +
last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return estimateTokens(input) >= promptCeiling
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, state.get().tokens)
+4 -4
View File
@@ -13,7 +13,7 @@ import { InstructionBuiltIns } from "../instructions/builtins.js"
import { Location } from "../location.js"
import { McpInstructions } from "../mcp/instructions.js"
import { McpTool } from "../tool/mcp.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Plugin } from "../plugin.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
@@ -85,7 +85,7 @@ const layer = Layer.effect(
const mcpTools = yield* McpTool.Service
const models = yield* SessionRunnerModel.Service
const modelRequests = yield* SessionModelRequest.Service
const plugins = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
const referenceInstructions = yield* ReferenceInstructions.Service
const skillInstructions = yield* SkillInstructions.Service
const store = yield* SessionStore.Service
@@ -125,7 +125,7 @@ const layer = Layer.effect(
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
yield* plugins.flush
yield* plugins.awaitActivation
yield* mcpTools.flush
const agent = yield* agents.select(session.agent)
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
@@ -190,7 +190,7 @@ export const node = makeLocationNode({
Location.node,
McpInstructions.node,
McpTool.node,
PluginSupervisor.node,
Plugin.node,
ReferenceInstructions.node,
SessionRunnerModel.node,
SessionModelRequest.node,
-16
View File
@@ -1,16 +0,0 @@
export * as SessionPromptNode from "./prompt-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Image } from "../image.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Skill } from "../skill.js"
import { SessionPrompt } from "./prompt.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionPrompt.Service,
layer: SessionPrompt.layer,
deps: [FSUtil.node, PluginSupervisor.node, PluginHooks.node, Image.node, Skill.node],
})
+10 -3
View File
@@ -6,13 +6,14 @@ import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { Session } from "@opencode-ai/schema/session"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Image } from "../image.js"
import { Mime } from "../mime.js"
import { PluginHooks } from "../plugin/hooks.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Plugin } from "../plugin.js"
import { Skill } from "../skill.js"
import { AttachmentError, SkillNotFoundError } from "./error.js"
@@ -27,7 +28,7 @@ export type Input = {
export const make = Effect.fn("SessionPrompt.make")(function* () {
const fs = yield* FSUtil.Service
const plugins = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const image = yield* Image.Service
const skillService = yield* Skill.Service
@@ -37,7 +38,7 @@ export const make = Effect.fn("SessionPrompt.make")(function* () {
messageID: SessionMessage.ID
input: Input
}) {
yield* plugins.flush
yield* plugins.awaitActivation
const event = yield* hooks.trigger("session", "prompt", {
sessionID: request.sessionID,
messageID: request.messageID,
@@ -205,6 +206,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const layer = Layer.effect(Service, make())
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Plugin.node, PluginHooks.node, Image.node, Skill.node],
})
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
function decodeDataURL(uri: string) {
-15
View File
@@ -1,15 +0,0 @@
export * as SessionRevertNode from "./revert-node.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Snapshot } from "../snapshot.js"
import { SessionRevert } from "./revert.js"
// Keep the supervisor implementation out of the global Session import path.
export const node = makeLocationNode({
service: SessionRevert.Service,
layer: SessionRevert.layer,
deps: [Database.node, Bus.node, PluginSupervisor.node, Snapshot.node],
})
+11 -4
View File
@@ -2,9 +2,10 @@ export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Plugin } from "../plugin.js"
import { RelativePath } from "../schema.js"
import { Snapshot } from "../snapshot.js"
import { SessionEvent } from "./event.js"
@@ -34,11 +35,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
export const make = Effect.fn("SessionRevert.make")(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const plugins = yield* PluginSupervisor.Service
const plugins = yield* Plugin.Service
const snapshot = yield* Snapshot.Service
const stage: Interface["stage"] = Effect.fn("SessionRevert.stage")(function* (input) {
yield* plugins.flush
yield* plugins.awaitActivation
const original = input.session.revert?.snapshot
? Snapshot.ID.make(input.session.revert.snapshot)
: yield* snapshot.capture()
@@ -66,7 +67,7 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
})
const clear: Interface["clear"] = Effect.fn("SessionRevert.clear")(function* (session) {
yield* plugins.flush
yield* plugins.awaitActivation
if (!session.revert) return
const original = session.revert.snapshot ? Snapshot.ID.make(session.revert.snapshot) : undefined
if (original)
@@ -83,6 +84,12 @@ export const make = Effect.fn("SessionRevert.make")(function* () {
export const layer = Layer.effect(Service, make())
export const node = makeLocationNode({
service: Service,
layer,
deps: [Database.node, Bus.node, Plugin.node, Snapshot.node],
})
export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Interface, session: SessionSchema.Info) {
if (!session.revert) return
yield* bus.publish(SessionEvent.RevertEvent.Committed, {

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