Compare commits

..

30 Commits

Author SHA1 Message Date
Kit Langton bda0a25a1f refactor: adopt head's unconditional boot-time resume
The rebase brings in the removal of the resumeSuspendedSessions flag
(the fetch entry resumes unconditionally now), so the profile and the
embedded SDK stop passing it and the SDK's conditional fork becomes
unconditional, matching every other runtime.
2026-08-12 11:35:52 -04:00
Kit Langton 61832852dc refactor(util): resolve global path roots through the workerd bundle condition
Replaces the navigator.userAgent sniff in global.ts with a #global-roots
conditional import, the same mechanism the native-module stubs and
#runtime-import already use: the workerd bundle statically resolves the
tmp-rooted variant and every other runtime keeps the XDG computation,
with no runtime detection at module scope.
2026-08-12 11:33:50 -04:00
Kit Langton b7163ce860 refactor: drop the speculative OPENCODE_GLOBAL_ROOT escape hatch
The workerd tmp-rooting is what the profile needs; an env override with
no consumer can return when something wants it.
2026-08-12 11:33:50 -04:00
Kit Langton f3fc5f0479 refactor: simplify the workerd profile surface
Review pass over the profile. fff.workerd.ts becomes the standard bind()
shim over the shared fff module instead of a hand-copied 119-line type
surface that had already drifted. The dead Options.paths and its
redundant Global override are deleted. The copy-pasted MCP clientInfo
block becomes a ServerOptions mcp.stdio capability flag handled by the
standard routes replacement. Database.configuredClient joins configured()
so the profile stops hand-assembling the node's Global dependency.
ServerFetch.make folds overrides into BootOptions so the embed seam has
one shape, and sdk-next's EmbedOptions aliases it. The drizzle session's
duck-typed transactionStatements check becomes the named
NativeTransactionSqlClient contract that sqlite.workerd satisfies. The
bundled models.dev snapshot is decoded and normalized once per isolate
instead of per runtime, which matters when one isolate hosts many
Durable Objects. Spike-test interceptor and log-read boilerplate
collapse into the existing helpers, and the vitest 3.2.7 pins get their
rationale in the config.
2026-08-12 11:33:50 -04:00
Kit Langton 2258adc4c8 fix(workerd-spike): skip the suite on windows runners
The patched pool's module fallback service handles /@fs ids with posix
assumptions, so Windows drive-letter paths (/@fs/C:/...) fall through and
raw-text modules fail to resolve before any test runs. The purity guard is
platform-independent — the bundle graph proven inside a Linux isolate is the
same graph everywhere — so the suite skips on win32 rather than teaching the
pinned pool about windows paths.
2026-08-12 11:32:17 -04:00
Kit Langton 70daf6948f fix(workerd-spike): pin the vitest 3.2.7 runtime packages
The workspace also contains vitest 4.x, and hoisting differs by platform: on
windows the pool loaded @vitest/utils 4.x against @vitest/pretty-format 3.2.7
and died on a missing export before any test ran. Declaring the 3.2.7 set
directly on this package makes resolution the same under either layout.
2026-08-12 11:32:17 -04:00
Kit Langton f102216aa3 ci: run the workerd spike in turbo test
turbo only runs the test tasks declared here, so the spike would never guard
anything in CI without an entry. Registering it makes the purity check — core
booting inside a real workerd isolate — run on every push.
2026-08-12 11:32:17 -04:00
Kit Langton 570dc00b3e test: workerd boot spike — opencode core in a durable object
packages/workerd-spike runs the full opencode core + server stack inside a
real Durable Object via @cloudflare/vitest-pool-workers: boot with all 42
migrations journaled on real DO SQLite, session create over the HTTP API, a
complete prompt turn against a fetchMock'd OpenAI-compatible provider read
back through the durable session log cursor route, a turn that completes with
no request in flight, and recovery of a session evicted mid-turn.

Eviction is simulated with DurableObjectState.abort() between prompt-accepted
and turn-complete; a fresh instance boots over the same storage, resumes the
claimed execution, and replays the drain. The durable log stays gapless
across the death, so a consumer resuming from a pre-eviction cursor sees no
gaps and no duplicates.

The profile persists durable events because that history is what recovery
replays. Harness notes: pins pool 0.12.6 (newer pool/workerd pairings segfault
on macOS) with a patch extending the stale workerd builtin allowlist and
fixing fallback-service handling of case-insensitive filesystems, /@fs ids,
JSON requires, and unanchored module-rule globs; missing node builtins resolve
to unenv polyfills via vite aliases.
2026-08-12 11:32:17 -04:00
Kit Langton ab35d99775 feat(sdk): embed opencode on alternate runtime profiles
createEmbeddedRoutes accepts runtime-profile service replacements, and the
embedded SDK exposes them through EmbedOptions: overrides applied after the
standard set, plus an opt-in boot-time resume of Sessions whose execution
claim was never released, for runtimes that die without teardown.

@opencode-ai/sdk-next/workerd composes the workerd profile
(ServerWorkerd.serverOptions + replacements) with the embedded SDK, so a
Durable Object host gets the same typed client and event streams as any other
sdk-next consumer, over Durable Object SQLite, with no network hop.

Health reports pid 0 where the runtime has no OS process identity, and the
drizzle session delegates to the client's native withTransaction when the
client rejects BEGIN/SAVEPOINT (Durable Object SQLite). Node platform modules
are deep-imported so the barrel's eager undici and node:sqlite side imports
never load.
2026-08-12 11:32:17 -04:00
Kit Langton 5ee173b1d6 feat(server): workerd runtime profile for durable objects
ServerWorkerd.create builds the fetch handler for a Durable Object's fetch(),
with every intentionally-local service replaced: the database runs on the
injected DO SQLite, plugin discovery is precompiled-only, MCP is remote-only,
Snapshot and Vcs degrade to empty results, and Shell/FileSystem/Pty fail with
a clear defect until a remote sandbox backs them.

Threading it through needs one seam: createRoutes and ServerFetch.make take
runtime-profile replacements applied after the standard set, so later entries
win. script/workerd-probe.ts pins that the graph bundles under the workerd
condition without bun builtins.
2026-08-12 11:32:17 -04:00
Kit Langton c680feda35 feat(core): workerd runtime stubs for bundles without a process
Resolve the native-module import conditions (#pty, #fff, #photon-wasm,
#shell-parser-wasm, #process-lock-ffi) to inert workerd stubs, so the module
graph loads in a runtime with no subprocesses, FFI, or filesystem artifacts.
Loopback OAuth servers import node:http lazily for the same reason, MCP gains
an stdio flag for runtimes that cannot spawn local servers, and Global roots
every path under one writable directory (tmp on workerd, OPENCODE_GLOBAL_ROOT
anywhere).
2026-08-12 11:31:32 -04:00
Kit Langton 70853b1e5b feat(tui): surface plugin failures (#41940) 2026-08-12 10:51:00 -04:00
opencode-agent[bot] 1fea1c2ebc fix(core): route Muse models to Meta prompt (#42036)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 09:31:59 -05:00
opencode-agent[bot] 1da591b84d fix(core): return content-only Code Mode results (#41954)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 09:14:41 -05:00
opencode-agent[bot] b990f9a5c1 chore: generate 2026-08-12 13:32:36 +00:00
Dax Raad 9769e7012c feat(core): configure package publishing 2026-08-12 09:30:34 -04:00
opencode-agent[bot] 5fd28cffc5 refactor(tui): use command IDs for v2 keybinds (#41882)
Requested by Sebastian Herrlinger (@kommander). Rebased onto current v2 with conflicts resolved and all required checks passing.
2026-08-12 12:53:24 +00:00
Luke Parker c8dffb6893 test(app): deflake cached tab paint probe (#41965) 2026-08-12 20:21:27 +10:00
opencode-agent[bot] 01cbdcb8e4 fix(ai): batch Anthropic parallel tool results (#41948)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-12 00:18:50 -05:00
opencode-agent[bot] bf751a907d fix(core): jitter session retry delays (#41942)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-11 23:58:20 -05:00
opencode-agent[bot] 9d63ca8f90 chore: generate 2026-08-12 04:19:09 +00:00
opencode-agent[bot] e3bd82013c fix(ai): preserve stream transport failures (#41779)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-11 23:17:48 -05:00
Kit Langton 90b6fa0eab fix(core): expose local attachment paths (#41789) 2026-08-11 23:32:49 -04:00
Kit Langton 5b7b1830d2 refactor(server): resume suspended sessions unconditionally in the fetch entry (#41938) 2026-08-12 03:31:30 +00:00
Kit Langton a8fc664b6d fix(tui): pad markdown table cells (#41937) 2026-08-11 23:21:00 -04:00
Kit Langton d853ff8848 fix(tui): configure new session location (#41929) 2026-08-11 22:55:48 -04:00
opencode-agent[bot] 94bc0fc6fa chore: generate 2026-08-12 02:40:33 +00:00
Kit Langton d3eecf7ba2 feat(tui): compact turn token usage with expandable steps (#41922) 2026-08-12 02:39:19 +00:00
Kit Langton 99166f7c17 feat(tui): add plus button to session tab bar (#41887) 2026-08-11 22:38:08 -04:00
opencode-agent[bot] 08dcf7d731 chore: generate 2026-08-12 02:23:16 +00:00
145 changed files with 5418 additions and 1810 deletions
+394 -110
View File
@@ -345,9 +345,6 @@
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.18.4",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
@@ -367,8 +364,6 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.10.1",
"@ff-labs/fff-node": "0.10.1",
"@lydell/node-pty": "catalog:",
@@ -404,6 +399,8 @@
"zod": "catalog:",
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -658,6 +655,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
@@ -1035,6 +1033,31 @@
"typescript": "catalog:",
},
},
"packages/workerd-spike": {
"name": "@opencode-ai/workerd-spike",
"version": "0.0.0",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/server": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.12.6",
"@cloudflare/workers-types": "^4.20250808.0",
"@effect/platform-node": "catalog:",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"unenv": "2.0.0-rc.24",
"vitest": "3.2.7",
"wrangler": "4.28.0",
"xdg-basedir": "5.1.0",
},
},
"packages/www": {
"name": "@opencode-ai/www",
"dependencies": {
@@ -1064,6 +1087,7 @@
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch",
@@ -1537,6 +1561,8 @@
"@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.15.2", "", { "dependencies": { "@cloudflare/unenv-preset": "2.7.11", "@remix-run/node-fetch-server": "^0.8.0", "get-port": "^7.1.0", "miniflare": "4.20251118.1", "picocolors": "^1.1.1", "tinyglobby": "^0.2.12", "unenv": "2.0.0-rc.24", "wrangler": "4.50.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0" } }, "sha512-SPMxsesbABOjzcAa4IzW+yM+fTIjx3GG1doh229Pg16FjSEZJhknyRpcld4gnaZioK3JKwG9FWdKsUhbplKY8w=="],
"@cloudflare/vitest-pool-workers": ["@cloudflare/vitest-pool-workers@0.12.6", "", { "dependencies": { "cjs-module-lexer": "^1.2.3", "esbuild": "0.27.0", "miniflare": "4.20260120.0", "wrangler": "4.60.0", "zod": "^3.25.76" }, "peerDependencies": { "@vitest/runner": "2.0.x - 3.2.x", "@vitest/snapshot": "2.0.x - 3.2.x", "vitest": "2.0.x - 3.2.x" } }, "sha512-smzhKzBdB4JYKo9bb8x3i70CtSj5GRhDfGvUhbLI7uPUv4QwMubddmZ+XYNekcy3C8/FG1fjLiwUqisITik8oQ=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20251118.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UmWmYEYS/LkK/4HFKN6xf3Hk8cw70PviR+ftr3hUvs9HYZS92IseZEp16pkL6ZBETrPRpZC7OrzoYF7ky6kHsg=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20251118.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RockU7Qzf4rxNfY1lx3j4rvwutNLjTIX7rr2hogbQ4mzLo8Ea40/oZTzXVxl+on75joLBrt0YpenGW8o/r44QA=="],
@@ -2123,6 +2149,8 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@opencode-ai/workerd-spike": ["@opencode-ai/workerd-spike@workspace:packages/workerd-spike"],
"@opencode-ai/www": ["@opencode-ai/www@workspace:packages/www"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.9.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ=="],
@@ -3253,19 +3281,19 @@
"@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=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="],
"@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
"@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="],
"@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
"@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="],
"@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="],
@@ -3597,6 +3625,8 @@
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
"cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="],
"classnames": ["classnames@2.3.2", "", {}, "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="],
"clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="],
@@ -4037,6 +4067,8 @@
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
"exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="],
"ext-list": ["ext-list@2.2.2", "", { "dependencies": { "mime-db": "^1.28.0" } }, "sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA=="],
"ext-name": ["ext-name@5.0.0", "", { "dependencies": { "ext-list": "^2.0.0", "sort-keys-length": "^1.0.0" } }, "sha512-yblEwXAbGv1VQDmow7s38W77hzAgJAO50ztBLMcUyUBfxv1HC+LGwtiEN+Co6LtlqT/5uwVOxsD4TNIilWhwdQ=="],
@@ -5507,7 +5539,7 @@
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -5545,6 +5577,8 @@
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"stripe": ["stripe@18.0.0", "", { "dependencies": { "@types/node": ">=8.1.0", "qs": "^6.11.0" } }, "sha512-3Fs33IzKUby//9kCkCa1uRpinAoTvj6rJgQ2jrBEysoxEvfsclvXdna1amyEYbA2EKkjynuB4+L/kleCCaWTpA=="],
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
@@ -5617,7 +5651,9 @@
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
@@ -5805,6 +5841,8 @@
"vite": ["vite@7.1.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "^8.12.1", "es-module-lexer": "^1.5.4", "fast-glob": "^3.3.2", "magic-string": "^0.30.11" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="],
"vite-plugin-icons-spritesheet": ["vite-plugin-icons-spritesheet@3.0.1", "", { "dependencies": { "chalk": "^5.4.1", "glob": "^11.0.1", "node-html-parser": "^7.0.1", "tinyexec": "^0.3.2" }, "peerDependencies": { "vite": ">=5.2.0" } }, "sha512-Cr0+Z6wRMwSwKisWW9PHeTjqmQFv0jwRQQMc3YgAhAgZEe03j21el0P/CA31KN/L5eiL1LhR14VTXl96LetonA=="],
@@ -5813,7 +5851,7 @@
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
"vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "^3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="],
"volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="],
@@ -6227,6 +6265,14 @@
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cloudflare/vitest-pool-workers/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"@cloudflare/vitest-pool-workers/miniflare": ["miniflare@4.20260120.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260120.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "^3.25.76" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q=="],
"@cloudflare/vitest-pool-workers/wrangler": ["wrangler@4.60.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.11.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20260120.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260120.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260120.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g=="],
"@cloudflare/vitest-pool-workers/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6373,6 +6419,8 @@
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opencode-ai/workerd-spike/wrangler": ["wrangler@4.28.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.6.0", "blake3-wasm": "2.1.5", "esbuild": "0.25.4", "miniflare": "4.20250803.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.19", "workerd": "1.20250803.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20250803.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-y0yHIuScpok9oSErLqDbxkBChC2+/jZpvqMg2NxOto1JCyUtDUuKljOfcVMaI48d9GuhOCSoWSumYxLAHNxaLA=="],
"@opencode-ai/www/@astrojs/cloudflare": ["@astrojs/cloudflare@14.1.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/underscore-redirects": "1.0.3", "@cloudflare/vite-plugin": "^1.39.0", "piccolore": "^0.1.3", "vite": "^8.0.13" }, "peerDependencies": { "astro": "^7.0.0", "wrangler": "^4.83.0" } }, "sha512-Zyo1E/5/dmegmKODbwUzOd67euNd6oKcbllhAwe3uFFprA7mIFNkpF6yBBa78vIkpMNuU13MkBJHCLd+yJSuEA=="],
"@opencode-ai/www/astro": ["astro@7.1.3", "", { "dependencies": { "@astrojs/compiler-rs": "^0.3.1", "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-satteri": "0.3.4", "@astrojs/telemetry": "3.3.3", "@capsizecss/unpack": "^4.0.0", "@clack/prompts": "^1.1.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "am-i-vibing": "^0.4.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "ci-info": "^4.4.0", "clsx": "^2.1.1", "common-ancestor-path": "^2.0.0", "cookie": "^2.0.1", "devalue": "^5.8.1", "diff": "^8.0.3", "dset": "^3.1.4", "es-module-lexer": "^2.0.0", "esbuild": "^0.28.0", "flattie": "^1.1.1", "fontace": "~0.4.1", "get-tsconfig": "5.0.0-beta.4", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "js-yaml": "^4.1.1", "jsonc-parser": "^3.3.1", "magic-string": "^0.30.21", "magicast": "^0.5.2", "mrmime": "^2.0.1", "neotraverse": "^1.0.1", "obug": "^2.1.1", "p-limit": "^7.3.0", "p-queue": "^9.1.0", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.4", "semver": "^7.7.4", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "svgo": "^4.0.1", "tinyclip": "^0.1.12", "tinyexec": "^1.0.4", "tinyglobby": "^0.2.15", "ultrahtml": "^1.6.0", "unifont": "~0.7.4", "unstorage": "^1.17.5", "vite": "^8.0.13", "vitefu": "^1.1.2", "xxhash-wasm": "^1.1.0", "yargs-parser": "^22.0.0", "zod": "^4.3.6" }, "optionalDependencies": { "sharp": "^0.34.0 || ^0.35.0" }, "peerDependencies": { "@astrojs/markdown-remark": "7.2.1" }, "optionalPeers": ["@astrojs/markdown-remark"], "bin": { "astro": "./bin/astro.mjs" } }, "sha512-4dhPyAAXthf3xLEYnG8SeL7yr/nTPPABfY7e9YF0yuO+vK9Xp+8Q5j4xzsmL3GueukQv4oNwGNTBepLOiDGeJA=="],
@@ -6477,6 +6525,8 @@
"@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"@solidjs/start/vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="],
"@storybook/addon-docs/react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"@storybook/addon-docs/react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
@@ -6523,12 +6573,6 @@
"@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@vitest/mocker/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
"@vscode/emmet-helper/vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
@@ -6843,6 +6887,10 @@
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"storybook/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
@@ -6853,6 +6901,8 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
@@ -6889,17 +6939,11 @@
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"vite-node/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"vitest/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"vitest/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
"vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
"vscode-json-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.18.0", "", {}, "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g=="],
@@ -7197,6 +7241,72 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.0", "", { "os": "android", "cpu": "x64" }, "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.0", "", { "os": "linux", "cpu": "none" }, "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.0", "", { "os": "none", "cpu": "x64" }, "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.0", "", { "os": "none", "cpu": "arm64" }, "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="],
"@cloudflare/vitest-pool-workers/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@cloudflare/vitest-pool-workers/miniflare/undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd": ["workerd@1.20260120.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260120.0", "@cloudflare/workerd-darwin-arm64": "1.20260120.0", "@cloudflare/workerd-linux-64": "1.20260120.0", "@cloudflare/workerd-linux-arm64": "1.20260120.0", "@cloudflare/workerd-windows-64": "1.20260120.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw=="],
"@cloudflare/vitest-pool-workers/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cloudflare/vitest-pool-workers/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
"@cloudflare/vitest-pool-workers/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.11.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg=="],
"@cloudflare/vitest-pool-workers/wrangler/workerd": ["workerd@1.20260120.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260120.0", "@cloudflare/workerd-darwin-arm64": "1.20260120.0", "@cloudflare/workerd-linux-64": "1.20260120.0", "@cloudflare/workerd-linux-arm64": "1.20260120.0", "@cloudflare/workerd-windows-64": "1.20260120.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="],
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -7397,6 +7507,16 @@
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@opencode-ai/workerd-spike/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.6.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.19", "workerd": "^1.20250802.0" }, "optionalPeers": ["workerd"] }, "sha512-h7Txw0WbDuUbrvZwky6+x7ft+U/Gppfn/rWx6IdR+e9gjygozRJnV26Y2TOr3yrIFa6OsZqqR2lN+jWTrakHXg=="],
"@opencode-ai/workerd-spike/wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
"@opencode-ai/workerd-spike/wrangler/miniflare": ["miniflare@4.20250803.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "sharp": "^0.33.5", "stoppable": "1.1.0", "undici": "^7.10.0", "workerd": "1.20250803.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-1tmCLfmMw0SqRBF9PPII9CVLQRzOrO7uIBmSng8BMSmtgs2kos7OeoM0sg6KbR9FrvP/zAniLyZuCAMAjuu4fQ=="],
"@opencode-ai/workerd-spike/wrangler/unenv": ["unenv@2.0.0-rc.19", "", { "dependencies": { "defu": "^6.1.4", "exsolve": "^1.0.7", "ohash": "^2.0.11", "pathe": "^2.0.3", "ufo": "^1.6.1" } }, "sha512-t/OMHBNAkknVCI7bVB9OWjUUAwhVv9vsPIAGnNUxnu3FxPQN11rjh0sksLMzc3g7IlTgvHmOTl4JM7JHpcv5wA=="],
"@opencode-ai/workerd-spike/wrangler/workerd": ["workerd@1.20250803.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20250803.0", "@cloudflare/workerd-darwin-arm64": "1.20250803.0", "@cloudflare/workerd-linux-64": "1.20250803.0", "@cloudflare/workerd-linux-arm64": "1.20250803.0", "@cloudflare/workerd-windows-64": "1.20250803.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oYH29mE/wNolPc32NHHQbySaNorj6+KASUtOvQHySxB5mO1NWdGuNv49woxNCF5971UYceGQndY+OLT+24C3wQ=="],
"@opencode-ai/www/@astrojs/cloudflare/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.1", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q=="],
"@opencode-ai/www/@astrojs/cloudflare/@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@1.0.3", "", {}, "sha512-cxnGSw+sJigBLdX4TMSZKkzV6C3gMLJMucDk2W+n281Xhie68T2/9f1+1NMNDCZsc5i0FED7Qt5I10g2O9wtZg=="],
@@ -7533,6 +7653,30 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@solidjs/start/vitest/@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="],
"@solidjs/start/vitest/@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="],
"@solidjs/start/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
"@solidjs/start/vitest/@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="],
"@solidjs/start/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="],
"@solidjs/start/vitest/@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="],
"@solidjs/start/vitest/@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"@solidjs/start/vitest/es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="],
"@solidjs/start/vitest/std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"@solidjs/start/vitest/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"@solidjs/start/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"@solidjs/start/vitest/vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
"@storybook/addon-docs/react-dom/scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
@@ -7545,8 +7689,6 @@
"@vercel/routing-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
@@ -7767,6 +7909,8 @@
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
@@ -7841,12 +7985,6 @@
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
"vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
@@ -8247,6 +8385,74 @@
"@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-o1GX0+IPlFi/D8ei9y/jj3yucJWNfPnbB5appVBWevAyUdZA5KzQ2nK/hDxiu9olTZlFEFpf1m1Rn3FaGxHqsw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA=="],
"@cloudflare/vitest-pool-workers/miniflare/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA=="],
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q=="],
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA=="],
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ=="],
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA=="],
"@cloudflare/vitest-pool-workers/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA=="],
"@electron/asar/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@electron/universal/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
@@ -8391,6 +8597,74 @@
"@opencode-ai/updates/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
"@opencode-ai/workerd-spike/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
"@opencode-ai/workerd-spike/wrangler/miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
"@opencode-ai/workerd-spike/wrangler/miniflare/undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"@opencode-ai/workerd-spike/wrangler/miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@opencode-ai/workerd-spike/wrangler/miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250803.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6QciMnJp1p3F1qUiN0LaLfmw7SuZA/gfUBOe8Ft81pw16JYZ3CyiqIKPJvc1SV8jgDx8r+gz/PRi1NwOMt329A=="],
"@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20250803.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DoIgghDowtqoNhL6OoN/F92SKtrk7mRQKc4YSs/Dst8IwFZq+pCShOlWfB0MXqHKPSoiz5xLSrUKR9H6gQMPvw=="],
"@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20250803.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mYdz4vNWX3+PoqRjssepVQqgh42IBiSrl+wb7vbh7VVWUVzBnQKtW3G+UFiBF62hohCLexGIEi7L0cFfRlcKSQ=="],
"@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20250803.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RmrtUYLRUg6djKU7Z6yebS6YGJVnaDVY6bbXca+2s26vw4ibJDOTPLuBHFQF62Grw3fAfsNbjQh5i14vG2mqUg=="],
"@opencode-ai/workerd-spike/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250803.0", "", { "os": "win32", "cpu": "x64" }, "sha512-uLV8gdudz36o9sUaAKbBxxTwZwLFz1KyW7QpBvOo4+r3Ib8yVKXGiySIMWGD7A0urSMrjf3e5LlLcJKgZUOjMA=="],
"@opencode-ai/www/@astrojs/cloudflare/@astrojs/internal-helpers/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"@opencode-ai/www/@astrojs/cloudflare/@cloudflare/vite-plugin/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
@@ -8601,6 +8875,12 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"@solidjs/start/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"@solidjs/start/vitest/vite/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
"@solidjs/start/vitest/vite/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
@@ -8759,6 +9039,8 @@
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -8767,78 +9049,6 @@
"unplugin/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@astrojs/cloudflare/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -8943,6 +9153,8 @@
"@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="],
"@cloudflare/vitest-pool-workers/miniflare/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"@opencode-ai/desktop/@actions/artifact/@actions/core/@actions/exec/@actions/io": ["@actions/io@1.1.3", "", {}, "sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q=="],
@@ -9171,6 +9383,78 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
"@solidjs/start/vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
"@solidjs/start/vitest/vite/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"archiver-utils/glob/jackspeak/@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
+1
View File
@@ -172,6 +172,7 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
}
}
@@ -573,7 +573,10 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
cache_control: cacheControl(breakpoints, part.cache),
})
}
messages.push({ role: "user", content })
const previous = messages.at(-1)
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
else messages.push({ role: "user", content })
}
return messages
+73 -30
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
import {
FetchHttpClient,
Headers,
@@ -297,44 +297,86 @@ export const classifyHttpFailure = (input: {
})
}
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
const transportError = (input: {
readonly message: string
readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) =>
type HttpOperation = "request" | "read"
const NativeTransportFailure = Schema.Struct({
message: Schema.String,
code: Schema.optionalKey(Schema.String),
cause: Schema.optionalKey(Schema.Unknown),
})
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
const nativeTransportFailure = (error: unknown) => {
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
if (!failure) return undefined
if (failure.code !== undefined) return failure
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
if (cause?.code !== undefined) return cause
return failure
}
const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
new AIError({
module: "RequestExecutor",
method: "execute",
method: input.operation,
reason: new TransportReason({
message: input.message,
kind: input.kind,
url: input.request ? redactUrl(input.request.url) : undefined,
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
message: failure.message,
transport: "http",
operation: input.operation,
code: failure.code,
url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
}),
})
if (Cause.isTimeoutError(error)) {
return transportError({ message: error.message, kind: "Timeout" })
}
if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
}
const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") {
const source =
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
? input.error.reason.cause
: input.error
const native = nativeTransportFailure(source)
const code = native?.code
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
if (!HttpClientError.isHttpClientError(input.error))
return transportError({ message: message ?? "HTTP transport failed", code })
if (input.error.reason._tag === "TransportError") {
return transportError({
message: error.reason.description ?? "HTTP transport failed",
kind: error.reason._tag,
request,
message: message ?? input.error.reason.description ?? "HTTP transport failed",
code: code ?? input.error.reason._tag,
})
}
return transportError({
message: `HTTP transport failed: ${error.reason._tag}`,
kind: error.reason._tag,
request,
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
code: code ?? input.error.reason._tag,
})
}
export const stream = (
executor: Interface,
request: HttpClientRequest.HttpClientRequest,
middleware?: HttpMiddleware,
): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
)
}),
)
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
Service,
Effect.gen(function* () {
@@ -343,15 +385,16 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware)
return yield* http
.execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
Effect.flatMap(statusError(request, redactedNames)),
)
const response = yield* middleware(request, (input) =>
http
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError(toHttpError(redactedNames)))
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
return yield* statusError(response.request, redactedNames)(response)
})
return Service.of({
+4 -21
View File
@@ -1,4 +1,4 @@
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth.js"
import { render as renderEndpoint } from "../endpoint.js"
@@ -6,6 +6,7 @@ import { Framing } from "../framing.js"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
import * as ProviderShared from "../../protocols/shared.js"
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
import { RequestExecutor } from "../executor.js"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -86,26 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
runtime.http
.execute(prepared.request, prepared.middleware)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
response.stream.pipe(
Stream.mapError((error) =>
ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
ProviderShared.errorText(error),
),
),
),
),
),
),
),
frames: (prepared, _request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
})
export const sseJson = {
+36 -13
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { AIError, TransportReason } from "../../schema/index.js"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
@@ -29,12 +29,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = (
method: string,
message: string,
input: { readonly url?: string; readonly kind?: string } = {},
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
) =>
new AIError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
reason: new TransportReason({
message,
transport: "websocket",
operation: input.operation,
url: input.url,
code: input.code,
}),
})
const eventMessage = (event: Event) => {
@@ -55,7 +61,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
return Effect.fail(
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url,
kind: "open",
operation: "request",
code: "closed",
}),
)
}
@@ -79,7 +86,10 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
}),
),
)
}
@@ -89,7 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
Effect.fail(
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url,
kind: "open",
operation: "request",
code: String(event.code),
}),
),
)
@@ -118,7 +129,8 @@ const webSocketUrl = (value: string) =>
catch: (error) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value,
kind: "websocket",
operation: "request",
code: "invalid-url",
}),
})
@@ -129,7 +141,7 @@ export const open = (input: WebSocketRequest) =>
catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
kind: "open",
operation: "request",
}),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -150,7 +162,10 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
}),
),
)
}
@@ -158,7 +173,10 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
}),
),
)
}
@@ -167,7 +185,11 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
operation: "read",
code: String(event.code),
}),
),
)
}
@@ -188,7 +210,7 @@ export const fromWebSocket = (
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
kind: "write",
operation: "write",
}),
}),
messages: Stream.fromQueue(messages),
@@ -243,7 +265,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url,
kind: "websocket",
operation: "request",
code: "unavailable",
}),
)
}
+9 -1
View File
@@ -92,10 +92,18 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext),
}) {}
export const TransportType = Schema.Literals(["http", "websocket"])
export type TransportType = typeof TransportType.Type
export const TransportOperation = Schema.Literals(["request", "read", "write"])
export type TransportOperation = typeof TransportOperation.Type
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"),
message: Schema.String,
kind: Schema.optional(Schema.String),
transport: TransportType,
operation: TransportOperation,
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
}) {}
+101 -3
View File
@@ -1,10 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse } from "./lib/http.js"
import { dynamicResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js"
@@ -67,6 +67,62 @@ const expectAIError = (error: unknown) => {
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
start(controller) {
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
},
}),
),
]),
),
),
)
it.effect("unwraps native transport failure causes", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed",
operation: "read",
code: "ECONNRESET",
})
}).pipe(
Effect.provide(
responsesLayer([
new Response(
new ReadableStream({
pull(controller) {
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
},
}),
),
]),
),
),
)
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
@@ -79,6 +135,48 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("reports the request sent by middleware", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, (original, handler) =>
handler(
original.pipe(
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
),
),
)
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "<redacted>" },
},
},
})
}).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: input.request,
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
}),
}),
),
),
),
),
)
it.effect("classifies context overflow responses", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+20 -6
View File
@@ -1,5 +1,5 @@
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
@@ -14,7 +14,9 @@ export type HandlerInput = {
) => HttpClientResponse.HttpClientResponse
}
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
export type Handler = (
input: HandlerInput,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
Layer.succeed(
@@ -34,6 +36,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
}
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
@@ -63,14 +71,20 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
* exercise transport errors that surface during parsing.
*/
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
dynamicResponse((input) =>
Effect.sync(() => {
const encoder = new TextEncoder()
let index = 0
const stream = new ReadableStream({
start(controller) {
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
controller.error(new Error("connection reset"))
pull(controller) {
const chunk = chunks[index]
if (chunk !== undefined) {
index++
controller.enqueue(encoder.encode(chunk))
return
}
controller.error(error)
},
})
return input.respond(stream, { headers: SSE_HEADERS })
@@ -271,6 +271,47 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("batches parallel tool results into one Anthropic user message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Check both cities."),
Message.assistant([
{ type: "text", text: "I'll check both." },
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
]),
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
],
cache: "none",
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
{
role: "assistant",
content: [
{ type: "text", text: "I'll check both." },
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
],
},
{
role: "user",
content: [
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
],
},
])
expect(prepared.body.messages).toHaveLength(3)
}),
)
it.effect("keeps tools and sends tool_choice none", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -915,6 +956,54 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 0,
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
},
{ type: "content_block_stop", index: 0 },
{
type: "content_block_start",
index: 1,
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
},
{
type: "content_block_delta",
index: 1,
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
},
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
{ type: "message_stop" },
),
),
),
)
expect(response.toolCalls).toMatchObject([
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
{ id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.message.content).toMatchObject([
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
])
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
}),
)
it.effect("keeps malformed server tool input terminal", () =>
Effect.gen(function* () {
const body = sseEvents(
+39 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Ref, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
HttpOptions,
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
import { sseEvents } from "../lib/sse.js"
@@ -1221,12 +1221,44 @@ describe("OpenAI Chat route", () => {
it.effect("surfaces transport errors that occur mid-stream", () =>
Effect.gen(function* () {
const layer = truncatedStream([
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
])
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
const layer = truncatedStream(
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
systemError("ECONNRESET", "socket closed unexpectedly"),
)
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
const error = yield* LLMClient.stream(request).pipe(
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
Stream.runDrain,
Effect.provide(layer),
Effect.flip,
)
expect(error.message).toContain("Failed to read openai/openai-chat stream")
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed unexpectedly",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://api.openai.test/v1/chat/completions",
})
}),
)
it.effect("surfaces transport errors before the first stream frame", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: socket closed before output",
transport: "http",
operation: "read",
code: "ECONNRESET",
})
}),
)
@@ -189,7 +189,12 @@ test.describe("smoke: session timeline", () => {
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
if (
!firstPaint &&
visible.includes(last) &&
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
!root.querySelector('[data-markdown-key="initial"]')
) {
firstPaint = true
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
const rect = row.getBoundingClientRect()
@@ -204,10 +209,16 @@ test.describe("smoke: session timeline", () => {
}
;(
window as Window & {
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
__sessionTabPaint?: {
samples: typeof samples
painted: () => boolean
removed: () => number
stop: () => void
}
}
).__sessionTabPaint = {
samples,
painted: () => firstPaint,
removed: () => removedFirstPaintNodes,
stop: () => {
running = false
@@ -219,17 +230,19 @@ test.describe("smoke: session timeline", () => {
)
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
await page.waitForFunction(() =>
(
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
)
await page.waitForFunction(() => {
const probe = (
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
).__sessionTabPaint
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
})
await page.waitForTimeout(200)
const first = await page.evaluate(() => {
const probe = (
window as Window & {
__sessionTabPaint?: {
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
painted: () => boolean
removed: () => number
stop: () => void
}
+53 -31
View File
@@ -1,7 +1,8 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/util/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { Flock } from "@opencode-ai/util/flock"
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
@@ -28,7 +29,6 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
@@ -49,38 +49,60 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const withLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const lock = yield* restore(
Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
)
yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
return yield* restore(effect)
}),
),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const get = Effect.fn("cli.config.get")(() =>
withLock(
Effect.gen(function* () {
const migration = yield* migrate.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
),
)
if (migration?.cause)
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
if (migration?.info) return migration.info
return Option.getOrElse(decode(yield* readJson()), () => empty)
}),
),
)
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
lock
.withPermits(1)(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
)
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
withLock(
Effect.gen(function* () {
const migration = yield* migrate
if (migration?.cause) return yield* Effect.failCause(migration.cause)
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
+123 -15
View File
@@ -1,13 +1,18 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
import { Definitions } from "@opencode-ai/tui/config/keybind"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import { randomUUID } from "crypto"
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import type { Info } from "./schema"
import { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
@@ -15,7 +20,60 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const persist = Effect.fnUntraced(function* (text: string, info: Info) {
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
const cause = yield* Effect.gen(function* () {
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, input.file)
}).pipe(
Effect.as(undefined),
Effect.catchCause((cause) => Effect.succeed(cause)),
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
)
return cause === undefined ? { info } : { info, cause }
})
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
const text = yield* fs.readFileString(input.file)
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
const config = Option.getOrUndefined(decodeRecord(value))
if (config === undefined) return
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
if (keybinds === undefined) return
const deduped = findKeybindObjects(text)
.slice(0, -1)
.reduce((text) => {
const property = findKeybindObjects(text)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
const updated = Object.keys(keybinds).reduce((text, name) => {
const target =
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
(name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
if (target === undefined) return text
const properties = findKeybindProperties(text, name)
if (!properties.length) return text
const remove = !(target in Definitions) || (target !== name && target in keybinds)
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
const property = findKeybindProperties(text, name)[0]
return property === undefined ? text : removeProperty(text, property)
}, text)
if (remove) return updated
if (target === name) return updated
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
if (key === undefined) return text
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
}, deduped)
if (updated === text) return
const updatedErrors: ParseError[] = []
const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
if (updatedErrors.length || info === undefined) return
return yield* persist(updated, info)
}
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
@@ -23,19 +81,59 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const temp = input.file + ".tmp"
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, input.file)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
return result
})
function findKeybindProperties(text: string, name: string) {
const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
}
function findKeybindObjects(text: string) {
const tree = parseTree(text)
if (tree === undefined) return []
return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
}
function removeProperty(text: string, property: Node) {
const properties = property.parent?.children ?? []
const index = properties.indexOf(property)
const end = property.offset + property.length
const next = properties[index + 1]
if (next) {
const comma = findComma(text, end, next.offset)
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
}
const previous = properties[index - 1]
if (previous) {
const comma = findComma(text, previous.offset + previous.length, property.offset)
if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
}
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
return text.slice(0, property.offset) + text.slice(end)
}
function findComma(text: string, start: number, end: number) {
const scanner = createScanner(text, false)
scanner.setPosition(start)
while (true) {
scanner.scan()
const offset = scanner.getTokenOffset()
if (scanner.getTokenLength() === 0 || offset >= end) return
if (text[offset] === ",") return offset
}
}
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
@@ -49,6 +147,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking =
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
const keybinds =
legacy?.keybinds === undefined
? undefined
: Object.fromEntries(
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
if (!(target in Definitions)) return []
return [[target, value]]
}),
)
return {
...(themeName !== undefined || themeMode !== undefined
@@ -59,7 +167,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
},
}
: {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(keybinds === undefined ? {} : { keybinds }),
...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
+288 -4
View File
@@ -1,7 +1,9 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { Effect, FileSystem, Option } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
import { Config } from "../src/config"
@@ -21,7 +23,14 @@ test("migrates tui and kv config into cli.json", async () => {
path.join(directory, "tui.json"),
JSON.stringify({
theme: "legacy",
keybinds: { leader: "ctrl+o" },
keybinds: {
leader: "ctrl+o",
app_exit: "ctrl+q",
app_heap_snapshot: "ctrl+h",
input_paste: { key: "ctrl+v", preventDefault: false },
session_delete: false,
"dialog.select.next": "ctrl+n",
},
plugin: [["example", { mode: "safe" }]],
plugin_enabled: { disabled: false },
leader_timeout: 500,
@@ -65,7 +74,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).toMatchObject({
theme: { name: "legacy", mode: "light" },
keybinds: { leader: "ctrl+o" },
keybinds: {
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
},
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
leader: { timeout: 500 },
scroll: { speed: 2, acceleration: true },
@@ -80,7 +95,13 @@ test("migrates tui and kv config into cli.json", async () => {
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
leader: "ctrl+o",
"app.exit": "ctrl+q",
"prompt.paste": { key: "ctrl+v", preventDefault: false },
"session.delete": false,
"dialog.select.next": "ctrl+n",
})
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
@@ -141,6 +162,257 @@ test("preserves legacy cursor settings", async () => {
}
})
test("migrates legacy keybind names in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
// Preserve this comment
"keybinds": {
// Session list shortcut
"session_list": "ctrl+l",
"app_heap_snapshot": "ctrl+h",
// Legacy delete shortcut
"session_delete": "ctrl+d",
// Canonical delete shortcut
"session.delete": "ctrl+x",
"app.heap_snapshot": "ctrl+shift+h"
}
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
const text = await Bun.file(file).text()
expect(text).toContain("// Preserve this comment")
expect(text).toContain("// Session list shortcut")
expect(text).toContain("// Legacy delete shortcut")
expect(text).toContain("// Canonical delete shortcut")
expect(parse(text).keybinds).toEqual({
"session.list": "ctrl+l",
"session.delete": "ctrl+x",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("uses migrated keybinds when persistence fails", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "rename") return () => Effect.die(new Error("read-only config"))
return Reflect.get(target, property, receiver)
},
})
try {
const config = await Effect.runPromise(
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates and updates the effective duplicate top-level keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("serializes migration and updates across processes", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const started = path.join(directory, "started")
const release = path.join(directory, "release")
const migrateReady = path.join(directory, "migrate-ready")
const updateReady = path.join(directory, "update-ready")
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(started, migrate.exited)
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
stdout: "ignore",
stderr: "pipe",
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
expect(await new Response(update.stderr).text()).toBe("")
expect([migrateCode, updateCode]).toEqual([0, 0])
expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
} finally {
update.kill()
await update.exited
}
} finally {
await Bun.write(release, "")
migrate.kill()
await migrate.exited
await Bun.$`rm -rf ${directory}`
}
})
test("config reads remain interruptible while waiting for the file lock", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
const locks = path.join(directory, "locks")
const held = await Flock.acquire(file, { dir: locks })
try {
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provide(NodeFileSystem.layer),
),
)
const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
} finally {
await held.release()
await Bun.$`rm -rf ${directory}`
}
})
test("updates effective duplicate canonical keybinds", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
return yield* service.update((draft) => {
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
})
}),
)
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
"session.delete": "changed",
"permission.mode": "changed",
})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("removes orphaned keybinds without deleting trailing comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(
file,
`{
"keybinds": {
"app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
"app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
},
}
`,
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.keybinds).toEqual({})
const text = await Bun.file(file).text()
expect(text).toContain("/* Keep legacy explanation */")
expect(text).toContain("/* Keep canonical explanation */")
expect(parse(text).keybinds).toEqual({})
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
@@ -167,3 +439,15 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}`
}
})
async function waitForFile(file: string, exited: Promise<number>) {
const found = await Promise.race([
(async () => {
while (!(await Bun.file(file).exists())) await Bun.sleep(10)
return true
})(),
exited.then(() => false),
Bun.sleep(5000).then(() => false),
])
if (!found) throw new Error(`timed out waiting for ${file}`)
}
@@ -0,0 +1,42 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect"
import { Config } from "../../src/config"
const [mode, directory, started, release, ready] = process.argv.slice(2)
if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
const state = { writes: 0 }
const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
state.writes++
if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
return Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(started, ""))
while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
yield* node.writeFileString(target, data, options)
})
}
const fs = new Proxy(node, {
get(target, property, receiver) {
if (property === "writeFileString") return writeFileString
return Reflect.get(target, property, receiver)
},
})
const service = await Effect.runPromise(
Config.Service.pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: directory, state: directory })),
Effect.provideService(FileSystem.FileSystem, fs),
),
)
await Bun.write(ready, "")
if (mode === "migrate") await Effect.runPromise(service.get())
if (mode === "update")
await Effect.runPromise(
service.update((draft) => {
draft.mouse = false
}),
)
+69
View File
@@ -0,0 +1,69 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { Effect, Option } from "effect"
import { expect, mock, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { Config } from "../src/config"
import type { MiniCommandInput } from "../src/mini"
import { OPENCODE_VERSION } from "../src/version"
test("mini handler passes resolved CLI keybinds to the runtime", async () => {
const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const configDirectory = path.join(root, "config")
const stateDirectory = path.join(root, "state")
await mkdir(configDirectory, { recursive: true })
await Bun.write(
path.join(configDirectory, "cli.json"),
JSON.stringify({
keybinds: { "composer.subagent.interrupt": "ctrl+i" },
leader: { timeout: 321 },
}),
)
let received: MiniCommandInput["tuiConfig"]
const mini = await import("../src/mini")
mock.module("../src/mini", () => ({
...mini,
validateMiniTerminal() {},
runMini(input: Pick<MiniCommandInput, "tuiConfig">) {
received = input.tuiConfig
return Promise.resolve()
},
}))
const handler = (await import("../src/commands/handlers/mini")).default
const server = Bun.serve({
port: 0,
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
})
try {
await Effect.runPromise(
handler({
server: Option.some(server.url.toString()),
standalone: false,
continue: false,
session: Option.none(),
fork: false,
replay: true as never,
replayLimit: Option.none(),
model: Option.none(),
agent: Option.none(),
prompt: Option.none(),
demo: false,
}).pipe(
Effect.provide(Config.layer),
Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
Effect.provide(NodeFileSystem.layer),
Effect.scoped,
),
)
const config = await received
expect(config?.leader.timeout).toBe(321)
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
} finally {
server.stop(true)
mock.restore()
await rm(root, { recursive: true, force: true })
}
})
+3
View File
@@ -0,0 +1,3 @@
# @opencode-ai/core
Core runtime services for OpenCode.
+26 -13
View File
@@ -4,24 +4,28 @@
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
"private": true,
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/core"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist"
],
"scripts": {
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
"bin": {
"opencode": "./bin/opencode"
},
"exports": {
"./environment": "./src/environment/index.ts",
"./testing/environment-conformance": "./test/lib/environment-conformance.ts",
"./session/runner": "./src/session/runner/index.ts",
"./instructions": "./src/instructions/index.ts",
"./*": "./src/*.ts"
},
"imports": {
@@ -32,32 +36,46 @@
"default": "./src/database/sqlite.node.ts"
},
"#pty": {
"workerd": "./src/pty/pty.workerd.ts",
"bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
"node": "./src/filesystem/fff.node.ts",
"default": "./src/filesystem/fff.bun.ts"
},
"#photon-wasm": {
"workerd": "./src/image/photon-wasm.workerd.ts",
"bun": "./src/image/photon-wasm.bun.ts",
"node": "./src/image/photon-wasm.node.ts",
"default": "./src/image/photon-wasm.bun.ts"
},
"#shell-parser-wasm": {
"workerd": "./src/shell/parser-wasm.workerd.ts",
"bun": "./src/shell/parser-wasm.bun.ts",
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"workerd": "./src/util/process-lock-ffi.workerd.ts",
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
},
"#v1-migration": {
"types": "./src/database/v1-migration.bun.ts",
"bun": "./src/database/v1-migration.bun.ts",
"node": "./src/database/v1-migration.noop.ts",
"workerd": "./src/database/v1-migration.noop.ts",
"default": "./src/database/v1-migration.noop.ts"
}
},
"devDependencies": {
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
@@ -93,8 +111,6 @@
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.29.0",
"@ff-labs/fff-bun": "0.10.1",
@@ -128,8 +144,5 @@
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"zod": "catalog:"
},
"overrides": {
"drizzle-orm": "catalog:"
}
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
await rm("dist", { recursive: true, force: true })
await $`bun tsc -p tsconfig.build.json`
const root = path.resolve("src")
const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true }))
const result = await Bun.build({
entrypoints: files.filter((file) => !file.endsWith(".d.ts")),
root,
outdir: "dist",
target: "node",
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
splitting: true,
loader: {
".txt": "text",
".md": "text",
},
naming: {
entry: "[dir]/[name].[ext]",
chunk: "chunks/[name]-[hash].[ext]",
asset: "assets/[name]-[hash].[ext]",
},
})
if (!result.success) throw new AggregateError(result.logs, "Failed to build Core")
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import { $ } from "bun"
import { rm } from "node:fs/promises"
import { fileURLToPath } from "node:url"
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
const originalText = await Bun.file("package.json").text()
const pkg = JSON.parse(originalText) as {
name: string
version: string
exports: Record<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
const output = (value: string, types = false) =>
value.replace("./src/", types ? "./dist/types/" : "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js")
if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) {
console.log(`already published ${pkg.name}@${pkg.version}`)
process.exit(0)
}
try {
await $`bun run typecheck`
await $`bun run build`
pkg.exports = Object.fromEntries(
Object.entries(pkg.exports).map(([key, value]) => {
if (typeof value !== "string") return [key, value]
return [key, { import: output(value), types: output(value, true) }]
}),
)
pkg.imports = Object.fromEntries(
Object.entries(pkg.imports).map(([key, conditions]) => [
key,
Object.fromEntries(
Object.entries(conditions).map(([condition, value]) => [condition, output(value, condition === "types")]),
),
]),
)
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
await rm(tarball, { force: true })
await $`bun pm pack`
await $`npm publish ${tarball} --tag ${Script.channel} --access public`
} finally {
await Bun.write("package.json", originalText)
await rm(tarball, { force: true })
}
+3 -1
View File
@@ -763,7 +763,9 @@ function apiCallErrorReason(error: APICallError) {
if (error.statusCode !== undefined || !error.isRetryable) return reason
return new TransportReason({
message: reason.message,
kind: error.name,
transport: "http",
operation: "request",
code: error.name,
url: error.url,
http: "http" in reason ? reason.http : undefined,
})
+4 -2
View File
@@ -75,7 +75,9 @@ export const create = (
const outputFileParts = outputFiles(content)
if (outputFileParts.length > 0)
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
return executed.output
if (executed.output !== undefined) return executed.output
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
{
onToolCallStart: ({ index, name, input }) => {
@@ -155,7 +157,7 @@ function runtime(
tools[path] = Tool.make({
description: child.description,
input: child.inputSchema,
output: child.outputSchema,
output: child.outputSchema ?? Schema.NullOr(Schema.String),
execute: (input) => executeTool(name, registration, input),
})
}
+9
View File
@@ -65,4 +65,13 @@ export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
/** `configured`, but over an injected SqlClient layer instead of a filesystem path. */
export function configuredClient(client: Layer.Layer<SqlClient.SqlClient>) {
return makeGlobalNode({
service: Service,
layer: layerFromClient.pipe(Layer.provide(client)),
deps: [Global.node],
})
}
export const node = configured({ path: ":memory:" })
@@ -28,6 +28,20 @@ export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase {
readonly context: never
}
/**
* A SqlClient whose runtime rejects SQL transaction statements (BEGIN/COMMIT/SAVEPOINT) and
* manages transactions natively instead — Cloudflare Durable Object SQLite. Producers set
* `transactionStatements: false` (see `database/sqlite.workerd.ts`) and the session delegates
* to the client's `withTransaction` rather than issuing transaction statements.
*/
export interface NativeTransactionSqlClient extends SqlClient {
readonly transactionStatements: false
}
export function managesTransactionsNatively(client: SqlClient): client is NativeTransactionSqlClient {
return (client as SqlClient & { readonly transactionStatements?: boolean }).transactionStatements === false
}
export type EffectSQLiteRunResult = readonly never[]
export interface EffectSQLiteSessionOptions {
@@ -120,6 +134,7 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
}
private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) {
if (managesTransactionsNatively(this.client)) return this.client.withTransaction(effect)
return Effect.uninterruptibleMask((restore) =>
Effect.withFiber<A, E | SqlError, R>((fiber) => {
const services = fiber.context
+1 -11
View File
@@ -1,5 +1,4 @@
import { Database, type SQLQueryBindings } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
@@ -98,16 +97,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as Database })
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
+1 -11
View File
@@ -1,5 +1,4 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import { Context, Effect, Layer } from "effect"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient } from "effect/unstable/sql"
@@ -95,16 +94,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
-3
View File
@@ -5,11 +5,8 @@ import { identity } from "effect/Function"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown>
+5 -15
View File
@@ -1,10 +1,10 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import type { NativeTransactionSqlClient } from "./drizzle/effect-sqlite/session.js"
import { Sqlite } from "./sqlite.js"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
@@ -216,10 +216,10 @@ const make = (options: Config) =>
config: options,
withTransaction: makeWithTransaction(native, connection, semaphore),
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
// as the drizzle session must route through withTransaction instead.
// as the drizzle session detect this and route through withTransaction.
transactionStatements: false,
},
)
} as const,
) satisfies NativeTransactionSqlClient
return client
})
@@ -238,17 +238,7 @@ const nativeLayer = (config: Config) =>
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe(Layer.provide(Reactivity.layer))
}
@@ -0,0 +1,997 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
@@ -0,0 +1,11 @@
import { Effect, Layer } from "effect"
export type Status = { readonly status: "completed" }
export const layer = Layer.empty
export function status() {
return Effect.succeed({ status: "completed" } as const)
}
export function run() {
return Effect.succeed({ status: "completed" } as const)
}
+2 -997
View File
@@ -1,997 +1,2 @@
export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message.js"
import { SessionSchema } from "../session/schema.js"
import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
export type SourceMessage = {
readonly id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type SourcePart = {
readonly id: string
readonly message_id: string
readonly session_id: string
readonly time_created: number
readonly time_updated: number
readonly data: string
}
export type TransformInput = {
readonly session: typeof SessionTable.$inferSelect
readonly messages: ReadonlyArray<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
export type Warning = {
readonly reason: string
readonly sessionID: string
readonly messageID?: string
readonly partID?: string
readonly observedType?: string
}
export type TransformResult = {
readonly messages: ReadonlyArray<{
readonly id: string
readonly session_id: string
readonly type: SessionMessage.Type
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: Record<string, unknown>
}>
readonly session: Pick<
typeof SessionTable.$inferInsert,
| "agent"
| "model"
| "cost"
| "tokens_input"
| "tokens_output"
| "tokens_reasoning"
| "tokens_cache_read"
| "tokens_cache_write"
| "revert"
| "time_compacting"
>
readonly watermark: number
readonly warnings: ReadonlyArray<Warning>
}
type Progress = {
readonly label: string
readonly numerator?: number
readonly denominator?: number
}
export type Status =
| { readonly status: "required" | "completed" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
| { readonly status: "idle" }
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
let runtimeState: RuntimeState = { status: "idle" }
export function transformSession(input: TransformInput): TransformResult {
const warnings: Warning[] = []
const messages = input.messages
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id }))
: undefined
if (decoded) return { row, value: decoded }
warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id })
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id))
const messageIDs = new Set(input.messages.map((row) => row.id))
const parts = input.parts
.map((row) => {
const value = Option.getOrUndefined(decodeJson(row.data))
const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined
if (!messageIDs.has(row.message_id)) {
warnings.push({
reason: "orphan-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
}
const decoded =
value && typeof value === "object"
? Option.getOrUndefined(
decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }),
)
: undefined
if (decoded) return { row, value: decoded }
warnings.push({
reason: "invalid-part",
sessionID: input.session.id,
messageID: row.message_id,
partID: row.id,
observedType,
})
return undefined
})
.filter((item): item is NonNullable<typeof item> => item !== undefined)
.sort((a, b) => a.row.id.localeCompare(b.row.id))
const byMessage = Map.groupBy(parts, (item) => item.row.message_id)
const paired = new Set<string>()
const used = new Set(messages.map((item) => item.row.id))
const projected = messages
.flatMap((item) => {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const pairedSummary = messages.find(
(candidate) =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
: -1
const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id)
const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex)
return [
row(
{ ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) },
{
id: item.row.id,
type: "compaction",
status: "completed",
reason: compaction.auto ? "auto" : "manual",
summary: summaryText,
recent: serializeRecent(tail, byMessage),
time: { created: item.row.time_created },
},
),
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)]
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
synthetic.length > 0 &&
attachments.length === 0 &&
agentAttachments.length === 0
)
return [
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
const user = row(item.row, {
id: item.row.id,
type: "user",
text,
...(attachments.length ? { files: attachments } : {}),
...(agentAttachments.length ? { agents: agentAttachments } : {}),
time: { created: item.row.time_created },
})
if (synthetic.length === 0) return [user]
return [
user,
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
time: { created: item.row.time_created },
}),
]
}
if (item.value.role !== "assistant") return []
const assistant = item.value
const parent = messages.find((candidate) => candidate.row.id === assistant.parentID)
const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : []
if (
parentParts.some((part) => part.type === "subtask") &&
owned.some((part) => part.type === "tool" && part.tool === "task")
)
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
if (part.type !== "tool") return []
return [migrateTool(part, item.row.time_created)]
})
const start =
owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ??
owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0]
const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1)
const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : []))))
const finish = normalizeFinish(assistant.finish)
return [
row(item.row, {
id: item.row.id,
type: "assistant",
agent: assistant.agent,
model: {
providerID: assistant.providerID,
id: assistant.modelID,
variant: assistant.variant ?? "default",
},
content,
...(start || end || snapshotFiles.length
? {
snapshot: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(snapshotFiles.length ? { files: snapshotFiles } : {}),
},
}
: {}),
...(finish ? { finish } : {}),
cost: assistant.cost,
tokens: {
input: assistant.tokens.input,
output: assistant.tokens.output,
reasoning: assistant.tokens.reasoning,
cache: assistant.tokens.cache,
},
...(assistant.error ? { error: migrateError(assistant.error) } : {}),
time: {
created: item.row.time_created,
...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }),
},
}),
]
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
if (item.value.role !== "user") return false
const owned = byMessage.get(item.row.id) ?? []
if (owned.some((part) => part.value.type === "compaction")) return false
return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask")
})
return {
messages: projected,
session: {
agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null),
model:
input.session.model ??
(latestUser?.value.role === "user"
? {
id: latestUser.value.model.modelID,
providerID: latestUser.value.model.providerID,
variant: latestUser.value.model.variant ?? "default",
}
: null),
cost: assistants.reduce((total, item) => total + item.cost, 0),
tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0),
tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0),
tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0),
tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0),
tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0),
revert: null,
time_compacting: null,
},
watermark: projected.length - 1,
warnings,
}
}
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const state = yield* readState(db)
if (runtimeState.status === "running") return runtimeState
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
const cause = input.cause
return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}`
}
function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
}).pipe(Effect.orDie),
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
source
.query<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("SELECT * FROM session ORDER BY id DESC").all()
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function row(
source: SourceMessage,
message: {
readonly id: string
readonly type: SessionMessage.Type
readonly time: { readonly created: number }
readonly [key: string]: unknown
},
): TransformResult["messages"][number] {
const { id, type, ...data } = message
return {
id,
session_id: source.session_id,
type,
seq: 0,
time_created: source.time_created,
time_updated: source.time_updated,
data,
}
}
function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) {
const base = {
type: "tool" as const,
id: part.callID,
name: part.tool,
...(part.metadata ? { providerState: part.metadata } : {}),
}
if (part.state.status === "completed")
return {
...base,
state: {
status: "completed",
input: part.state.input,
content:
part.state.time.compacted === undefined
? [
{ type: "text", text: part.state.output },
...(part.state.attachments ?? []).map((file) => ({
type: "file" as const,
uri: file.url,
mime: file.mime,
...(file.filename ? { name: file.filename } : {}),
})),
]
: [{ type: "text", text: "[Old tool result content cleared]" }],
metadata: part.state.metadata,
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
if (part.state.status === "error")
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.execution", message: part.state.error },
...(typeof part.state.metadata?.output === "string"
? { content: [{ type: "text", text: part.state.metadata.output }] }
: {}),
...(part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.time.start, completed: part.state.time.end },
}
return {
...base,
state: {
status: "error",
input: part.state.input,
error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}),
},
time: { created: part.state.status === "running" ? part.state.time.start : fallback },
}
}
function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) {
const message =
"message" in error.data
? error.data.message
: error.name === "MessageOutputLengthError"
? "The model exceeded its output limit"
: error.name
const type =
error.name === "ProviderAuthError"
? "provider.auth"
: error.name === "ContentFilterError"
? "provider.content-filter"
: error.name === "ContextOverflowError"
? "provider.invalid-request"
: error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError"
? "provider.invalid-output"
: error.name === "MessageAbortedError"
? "aborted"
: error.name === "APIError"
? "provider.error"
: "unknown"
return { type, message }
}
function normalizeFinish(finish: string | undefined) {
if (!finish) return undefined
return (
(["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find(
(value) => value === finish,
) ?? "unknown"
)
}
function migrateFile(part: SessionV1.FilePart) {
if (!part.url.startsWith("data:")) return []
const comma = part.url.indexOf(",")
if (comma < 0) return []
const header = part.url.slice(0, comma)
const payload = part.url.slice(comma + 1)
const data = header.endsWith(";base64")
? Buffer.from(payload, "base64").toString("base64")
: Buffer.from(decodeURIComponent(payload)).toString("base64")
return [
{
data,
mime: part.mime,
source:
part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const },
...(part.filename ? { name: part.filename } : {}),
...(part.source
? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } }
: {}),
},
]
}
function unavailableFile(part: SessionV1.FilePart) {
const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url)
return `[Attachment unavailable after migration: ${label} (${part.mime})]`
}
function syntheticID(source: string, used: Set<string>) {
const prefix = source.slice(0, 16)
const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
for (let salt = 0; ; salt++) {
const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex")
let value = BigInt(`0x${hex}`)
let suffix = ""
while (suffix.length < 14) {
suffix = alphabet[Number(value % 62n)] + suffix
value /= 62n
}
const id = prefix + suffix
if (used.has(id)) continue
used.add(id)
return id
}
}
function serializeRecent(
messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>,
parts: Map<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
return messages
.flatMap((message) => {
const owned = parts.get(message.row.id)?.map((part) => part.value) ?? []
if (message.value.role === "user")
return [
`[User]: ${owned
.filter((part) => part.type === "text" && !part.ignored)
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n\n")}`,
]
return owned.flatMap((part) =>
part.type === "text"
? [`[Assistant]: ${part.text}`]
: part.type === "reasoning" && part.text
? [`[Assistant reasoning]: ${part.text}`]
: [],
)
})
.join("\n\n")
}
function readState(db: Database.Interface["db"]): Effect.Effect<MigrationState | undefined> {
return db
.select({ value: KVTable.value })
.from(KVTable)
.where(eq(KVTable.key, MIGRATION_STATE_KEY))
.get()
.pipe(
Effect.map((row) => parseState(row?.value)),
Effect.orDie,
)
}
function parseState(input: unknown): MigrationState | undefined {
if (!input || typeof input !== "object" || !("phase" in input)) return
if (input.phase === "completed") return { phase: "completed" }
if (input.phase !== "sessions") return
if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" }
if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor }
}
function hasLegacySessions(db: Database.Interface["db"]) {
return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe(
Effect.map((row) => row !== undefined),
Effect.orDie,
)
}
export * as V1Migration from "#v1-migration"
export * from "#v1-migration"
@@ -0,0 +1,12 @@
import { bind } from "./fff.js"
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff.js"
// No fff backend exists on workerd; every create reports unavailability and
// FileSystemSearch degrades the same way it does on a runtime without the native module.
const adapter = bind(undefined, "fff unavailable on workerd runtime")
export const available = adapter.available
export const create = adapter.create
export * as Fff from "./fff.workerd.js"
@@ -0,0 +1,4 @@
// workerd has no filesystem path to a photon wasm artifact. Image.Photon only
// reads this lazily and surfaces a typed ResizerUnavailableError when loading
// fails, so an empty path degrades cleanly instead of breaking module load.
export default ""
+7
View File
@@ -164,6 +164,8 @@ export const Options = Schema.Struct({
version: Schema.String,
}),
),
/** Set false on runtimes that cannot spawn child processes; local (stdio) servers report failed instead of connecting. */
stdio: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -502,6 +504,11 @@ export const layer = (options?: Options) =>
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
if (options?.stdio === false && entry.config.type === "local") {
entry.status = { status: "failed", error: "stdio MCP servers are unavailable in this runtime" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
+2 -1
View File
@@ -2,7 +2,6 @@ export * as MCPOAuth from "./oauth.js"
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
@@ -152,6 +151,8 @@ export const authorize = (input: {
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://127.0.0.1")
if (url.pathname !== redirectPath) {
+22 -6
View File
@@ -544,6 +544,24 @@ const Cache = Schema.Struct({
})
const defaultSource = "https://models.opencode.ai"
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. Decoded and normalized once per
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
// host many runtimes (Cloudflare colocates Durable Object instances), so
// per-runtime decoding would multiply the cost.
let bundledCache: readonly Snapshot[] | undefined
const bundledSnapshot = Effect.suspend(() =>
bundledCache
? Effect.succeed(bundledCache)
: decodeCatalog(snapshotText).pipe(
Effect.map((catalog) => {
bundledCache = normalize(catalog)
return bundledCache
}),
),
)
function cacheKey(source: string) {
if (source === defaultSource) return "models-dev:catalog"
return `models-dev:catalog:${Hash.fast(source)}`
@@ -607,11 +625,9 @@ export const layer = (options?: Options) =>
)
: Effect.succeed(undefined)
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. It is the boot-time floor
// for the catalog; the periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)
// The bundled snapshot is the boot-time floor for the catalog; the
// periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : bundledSnapshot
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
@@ -635,7 +651,7 @@ export const layer = (options?: Options) =>
const cached = options?.file ? undefined : yield* loadFromCache()
if (cached) return normalize(cached.catalog)
const bundled = yield* loadSnapshot
if (bundled) return normalize(bundled)
if (bundled) return bundled
if (!fetch) return []
const catalog = yield* lock.withPermit(
Effect.gen(function* () {
+2 -1
View File
@@ -1,4 +1,3 @@
import { createServer } from "node:http"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
@@ -58,6 +57,8 @@ const browser = (app: App.Info) =>
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
+5 -1
View File
@@ -23,7 +23,11 @@ export const GooglePlugin = make("google", (id) => (id.includes("gemini-") ? PRO
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined))
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined))
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined))
export const MetaPlugin = make("meta", (id) => (id.includes("muse-spark") ? PROMPT_META : undefined))
export const MetaPlugin = make("meta", (id) => {
if (!id.includes("muse")) return
const name = id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
})
export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
@@ -1,4 +1,4 @@
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL.
You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by {{MODEL_NAME}}, a large language model trained by Meta MSL.
Use the instructions below and the tools available to assist the user.
@@ -55,5 +55,5 @@ Use the instructions below and the tools available to assist the user.
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
# User Help & Feedback
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta Muse Spark.
- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta {{MODEL_NAME}}.
- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the `webfetch` tool to gather information to answer the question from the V2 OpenCode docs at https://opencode.ai/v2/docs/.
+9
View File
@@ -0,0 +1,9 @@
import type { Proc } from "./pty.js"
export type { Disp, Exit, Opts, Proc } from "./pty.js"
// workerd cannot spawn processes; the Pty service surfaces this as a defect if
// a terminal is ever requested on this runtime.
export function spawn(): Proc {
throw new Error("Pseudo-terminals are unavailable on the workerd runtime")
}
+3 -1
View File
@@ -48,10 +48,12 @@ export const schedule = (
assistantMessageID: () => SessionMessage.ID,
) =>
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
Schedule.jittered,
Schedule.setInputType<RetryableFailure>(),
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
const minimum = retryAfter(failure)
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
}),
Schedule.tap((metadata) =>
bus.publish(SessionEvent.RetryScheduled, {
@@ -1,5 +1,6 @@
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
import type { Model } from "../../model.js"
import { SessionMessage } from "../message.js"
import type { FileAttachment } from "@opencode-ai/schema/prompt"
@@ -14,6 +15,17 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const attachmentLocation = (file: FileAttachment) => {
if (file.source.type !== "uri") return undefined
const url = URL.parse(file.source.uri)
if (url?.protocol !== "file:") return undefined
try {
return fileURLToPath(url)
} catch {
return undefined
}
}
const textAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
@@ -36,7 +48,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
const directoryAttachment = (file: FileAttachment): ContentPart => ({
type: "text",
text: `\n\n${[
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
@@ -55,7 +67,10 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
const attachmentContent = (file: FileAttachment): ContentPart[] => {
if (file.mime === "text/plain") return [textAttachment(file)]
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
if (imageMimes.has(file.mime)) return [media(file)]
if (imageMimes.has(file.mime)) {
const location = attachmentLocation(file)
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
}
return []
}
@@ -0,0 +1,4 @@
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
// loads these lazily and degrades when initialization fails, so empty paths
// keep module load side-effect free instead of resolving from disk.
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
@@ -0,0 +1,14 @@
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
// workerd has no FFI and no cross-process file locking; a Durable Object is
// already single-threaded per instance, so nothing on this runtime should
// reach these.
const unavailable = (_fd: number): LockResult => {
throw new Error("Process locks are unavailable on the workerd runtime")
}
export const lockDarwin = unavailable
export const lockLinux = unavailable
+9 -2
View File
@@ -49,7 +49,9 @@ const client = LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
RequestExecutor.Service.of({
execute: () => Effect.die("Unexpected HTTP request"),
}),
),
),
)
@@ -542,7 +544,12 @@ it.effect("retries status-less AI SDK transport failures", () =>
isRetryable: true,
}),
)
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
expect(error.reason).toMatchObject({
_tag: "Transport",
transport: "http",
operation: "request",
code: "AI_APICallError",
})
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
}),
+1 -1
View File
@@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -0,0 +1,47 @@
import { Database } from "bun:sqlite"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
// Emulates the Durable Object storage API over bun:sqlite so the workerd
// adapter and the workerd server profile can be verified without workerd or
// Cloudflare runtime dependencies. The real runtime is covered by the
// workerd-spike package, which boots inside an actual isolate.
export const makeDurableObjectStorage = (): DurableObjectStorage => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
return {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { Effect, Layer } from "effect"
@@ -7,7 +7,7 @@ import { Bus } from "@opencode-ai/core/bus"
import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { FSUtil } from "@opencode-ai/util/fs-util"
+1 -1
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { it } from "../lib/effect"
const key = (value: string) => Instructions.Key.make(value)
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
import { it } from "./effect"
import { Failed, NotFound, WrongKind, type Files } from "@opencode-ai/core/environment/index"
export interface EnvironmentHarness {
readonly files: Files
@@ -16,17 +15,21 @@ export const environmentConformance = <E>(
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
it.live(title, () =>
Effect.gen(function* () {
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
test(title, () =>
Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
Effect.gen(function* () {
yield* Effect.ignore(harness.files.remove(harness.root))
if (harness.dispose) yield* harness.dispose
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
)
yield* harness.files.mkdir(harness.root)
return yield* body(harness)
}),
),
),
)
const bytes = (value: string) => new TextEncoder().encode(value)
+1 -1
View File
@@ -1,5 +1,5 @@
import { Effect, Option, Schema } from "effect"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
export interface State {
readonly values: Readonly<Record<string, Schema.Json>>
+38
View File
@@ -248,6 +248,12 @@ const mcp = Layer.mock(MCP.Service, {
required: ["ok"],
},
}),
new MCP.Tool({
server: MCP.ServerName.make("demo"),
name: "status",
description: "Status",
inputSchema: { type: "object", properties: {} },
}),
new MCP.Tool({
server: MCP.ServerName.make("direct"),
name: "lookup",
@@ -290,6 +296,13 @@ const mcp = Layer.mock(MCP.Service, {
{ type: "media", data: "aGVsbG8=", mimeType: "image/png" },
],
})
if (input.name === "status")
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
isError: false,
content: [{ type: "text", text: "hello" }],
})
return new MCP.ToolResult({
server: MCP.ServerName.make(input.server),
tool: input.name,
@@ -984,6 +997,31 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
}),
)
it.effect("returns content-only MCP results through Code Mode", () =>
Effect.gen(function* () {
assertion = yield* Deferred.make<Permission.AssertInput>()
decision = Effect.void
const registry = yield* Tool.Service
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
const execution = yield* toolSet.execute({
sessionID: Session.ID.make("ses_mcp_content_only"),
...toolIdentity,
call: {
type: "tool-call",
id: "call_mcp_content_only",
name: "execute",
input: { code: "return await tools.demo.status({})" },
},
})
expect(execution).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo.status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
Effect.gen(function* () {
const registry = yield* Tool.Service
+41
View File
@@ -393,4 +393,45 @@ describe("fromPromise", () => {
expect(progress).toEqual([{ phase: "greeting" }])
}),
)
it.effect("returns content-only plugin results through Code Mode", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const registry = yield* Tool.Service
const host = yield* PluginHost.make(plugins)
const promisePlugin = define({
id: "content-only-tool",
setup: async (ctx) => {
await ctx.tool.transform((tools) => {
tools.add({
name: "demo_status",
description: "Returns a status string",
input: Schema.Struct({}),
execute: async () => ({ content: [{ type: "text", text: "hello" }] }),
options: { codemode: true },
})
})
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
const toolSet = yield* registry.snapshot()
const throughCodeMode = yield* toolSet.execute({
sessionID: Session.ID.make("ses_content_only_tool"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_content_only_tool"),
call: {
type: "tool-call",
id: "call_content_only_tool",
name: "execute",
input: { code: "return await tools.demo_status({})" },
},
})
expect(throughCodeMode).toMatchObject({
output: { output: "hello", toolCalls: [{ tool: "demo_status", status: "completed" }] },
content: [{ type: "text", text: "hello" }],
})
}),
)
})
@@ -90,6 +90,36 @@ describe("SystemPromptPlugin", () => {
}),
)
it.effect("selects the Meta prompt for Muse family model IDs", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.MetaPlugin.effect(pluginHost)
yield* Effect.forEach(
[
["meta/muse-spark-preview", "Muse Spark"],
["muse-spark-1.2", "Muse Spark"],
["meta/muse-glimmer-30b", "Muse Glimmer"],
["muse-glimmer-30b", "Muse Glimmer"],
] as const,
([id, name]) => {
const event = context(id)
return hooks.trigger("session", "context", event).pipe(
Effect.tap(() =>
Effect.sync(() => {
expect(event.system[0]?.text).toContain(`powered by ${name},`)
expect(event.system[0]?.text).toContain(`using Meta ${name}.`)
expect(event.system[0]?.text).not.toContain("{{MODEL_NAME}}")
}),
),
)
},
{ discard: true },
)
}),
)
it.effect("preserves an explicit agent system prompt", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
+4 -2
View File
@@ -39,7 +39,9 @@ describe("toSessionError", () => {
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(
toSessionError(llm(new TransportReason({ message: "transport", transport: "http", operation: "request" }))).type,
).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
@@ -111,7 +113,7 @@ describe("toSessionError", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })),
llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
]
const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
+2 -2
View File
@@ -14,7 +14,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
@@ -32,7 +32,7 @@ describe("SessionExecution lifecycle", () => {
new AIError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
}),
),
),
+1 -1
View File
@@ -17,7 +17,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { Location } from "@opencode-ai/core/location"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
+119 -10
View File
@@ -11,6 +11,8 @@ import { Shell } from "@opencode-ai/schema/shell"
import { Location } from "@opencode-ai/schema/location"
import { AbsolutePath } from "@opencode-ai/schema/schema"
import { DateTime } from "effect"
import path from "path"
import { pathToFileURL } from "url"
const created = DateTime.makeUnsafe(0)
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
@@ -267,12 +269,13 @@ Recent work
])
})
test("lowers directory attachments as directory context", () => {
test("exposes admitted reference directory source paths in model context", () => {
const location = path.resolve("/references/harness-engineering")
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "harness-engineering",
})
const messages = toLLMMessages(
[
@@ -295,14 +298,15 @@ Recent work
{ type: "text", text: "Review this directory" },
{
type: "text",
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
metadata: { attachment: { source: directory.source, name: "src/" } },
text: `\n\nAttached directory: ${location}\n\nlib/\nindex.ts`,
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
},
],
})
})
test("preserves attachment order after the prompt", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -313,7 +317,7 @@ Recent work
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
source: { type: "uri", uri: pathToFileURL(directory).href },
name: "src/",
}),
FileAttachment.make({
@@ -332,12 +336,13 @@ Recent work
expect(messages).toHaveLength(1)
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
"Review these attachments",
"\n\nAttached directory: src/\n\nindex.ts",
`\n\nAttached directory: ${directory}\n\nindex.ts`,
"\n\nAttached file: main.ts\n\nexport const value = 1",
])
})
test("omits empty prompt text before an attachment", () => {
const directory = path.resolve("/project/src")
const messages = toLLMMessages(
[
SessionMessage.User.make({
@@ -348,7 +353,7 @@ Recent work
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
source: { type: "uri", uri: pathToFileURL(directory).href },
name: "src/",
}),
],
@@ -359,7 +364,9 @@ Recent work
)
expect(messages).toHaveLength(1)
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
expect(messages[0]?.content).toMatchObject([
{ type: "text", text: `\n\nAttached directory: ${directory}\n\nindex.ts` },
])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => {
@@ -391,6 +398,108 @@ Recent work
])
})
test("exposes admitted local image source paths before provider media", () => {
const data = Base64.make("AAECAw==")
const location = path.resolve("/project/IMG_3480.JPG")
const image = FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: pathToFileURL(location).href },
name: "IMG_3480.JPG",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-local-image-path"),
type: "user",
text: "Inspect this image",
files: [image],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "text", text: `Attached file: ${location}` },
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
])
})
test("falls back to attachment names for invalid local source paths", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-invalid-local-paths"),
type: "user",
text: "Inspect these attachments",
files: [
FileAttachment.make({
data: Base64.make(Buffer.from("index.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src%2Flib" },
name: "src/",
}),
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image%2Fpreview.png" },
name: "preview.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect these attachments" },
{
type: "text",
text: "\n\nAttached directory: src/\n\nindex.ts",
metadata: {
attachment: {
source: { type: "uri", uri: "file:///project/src%2Flib" },
name: "src/",
},
},
},
{ type: "media", mediaType: "image/png", data, filename: "preview.png" },
])
})
test("does not add attachment location text for non-local provider media", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-remote-image"),
type: "user",
text: "Inspect this image",
files: [
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "https://example.com/image.png" },
name: "image.png",
}),
],
time: { created },
}),
],
model,
)
expect(messages[0]?.content).toEqual([
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
])
})
test("deduplicates provider media while preserving durable attachment references", () => {
const data = Base64.make("AAECAw==")
const messages = toLLMMessages(
@@ -468,7 +577,7 @@ Recent work
FileAttachment.make({
data,
mime: "image/png",
source: { type: "uri", uri: "file:///project/image.png" },
source: { type: "uri", uri: pathToFileURL(path.resolve("/project/image.png")).href },
name: "image.png",
mention: { start: 0, end: 9, text: "[Image 1]" },
}),
@@ -22,7 +22,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { Tool } from "@opencode-ai/core/tool"
@@ -31,7 +31,7 @@ import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions"
import { McpInstructions } from "@opencode-ai/core/mcp/instructions"
+27 -20
View File
@@ -38,7 +38,7 @@ import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
import { SessionRunner } from "@opencode-ai/core/session/runner"
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics"
@@ -61,7 +61,7 @@ import {
} from "@opencode-ai/core/session/sql"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Instructions } from "@opencode-ai/core/instructions"
import { Instructions } from "@opencode-ai/core/instructions/index"
import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { SkillInstructions } from "@opencode-ai/core/skill/instructions"
@@ -515,7 +515,11 @@ const providerUnavailable = () =>
new AIError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Provider unavailable" }),
reason: new TransportReason({
message: "Provider unavailable",
transport: "http",
operation: "request",
}),
})
const incompleteStream = () =>
@@ -3947,7 +3951,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries eligible pre-output failures after exponential backoff", () =>
it.effect("bounds jittered exponential backoff for eligible pre-output failures", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry transport")
@@ -3956,9 +3960,9 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("1999 millis")
yield* TestClock.adjust("1599 millis")
expect(requests).toHaveLength(1)
yield* TestClock.adjust("1 millis")
yield* TestClock.adjust("801 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -3983,7 +3987,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -4028,7 +4032,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
@@ -4085,7 +4089,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests[1]?.messages.at(-2)).toMatchObject({
@@ -4126,7 +4130,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(executions).toEqual(["settled"])
@@ -4165,7 +4169,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
@@ -4203,7 +4207,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
}
@@ -4224,7 +4228,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
yield* TestClock.adjust(delay)
yield* TestLLM.wait(index + 2)
}
@@ -4239,12 +4243,15 @@ describe("SessionRunnerLLM", () => {
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
expect(retries.map((event) => event.data)).toMatchObject([
{ attempt: 2, at: 2_000 },
{ attempt: 3, at: 6_000 },
{ attempt: 4, at: 14_000 },
{ attempt: 5, at: 30_000 },
])
for (const [index, range] of [
[1_600, 2_400],
[4_800, 7_200],
[11_200, 16_800],
[24_000, 36_000],
].entries()) {
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
}
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
const assistant = requireAssistant(yield* session.context(sessionID))
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
@@ -4274,7 +4281,7 @@ describe("SessionRunnerLLM", () => {
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
expect(requests).toHaveLength(3)
+6 -50
View File
@@ -1,63 +1,19 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { Effect, Layer } from "effect"
import { SqlClient } from "effect/unstable/sql"
import { SqlError } from "effect/unstable/sql/SqlError"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { makeDurableObjectStorage } from "./fixture/durable-object-storage"
import { tempGlobalLayer } from "./fixture/global"
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
// be verified without workerd or Cloudflare runtime dependencies.
const makeFakeStorage = () => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
const storage: DurableObjectStorage = {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
return storage
}
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
describe("sqlite.workerd", () => {
test("executes statements with bindings and maps rows to records", async () => {
const rows = await run(
makeFakeStorage(),
makeDurableObjectStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
@@ -73,7 +29,7 @@ describe("sqlite.workerd", () => {
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
const rows = await run(
makeFakeStorage(),
makeDurableObjectStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
@@ -86,7 +42,7 @@ describe("sqlite.workerd", () => {
})
test("withTransaction commits on success and rolls back on failure", async () => {
const storage = makeFakeStorage()
const storage = makeDurableObjectStorage()
const count = await run(
storage,
Effect.gen(function* () {
@@ -109,7 +65,7 @@ describe("sqlite.workerd", () => {
test("nested withTransaction fails with SqlError", async () => {
const error = await run(
makeFakeStorage(),
makeDurableObjectStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
@@ -122,7 +78,7 @@ describe("sqlite.workerd", () => {
})
test("boots the full database layer with migrations over injected storage", async () => {
const storage = makeFakeStorage()
const storage = makeDurableObjectStorage()
const core = await import("@opencode-ai/core/database/database")
await Effect.runPromise(
Effect.scoped(
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location"
+1 -1
View File
@@ -4,7 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation"
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
+1 -1
View File
@@ -21,7 +21,7 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
+1 -1
View File
@@ -5,7 +5,7 @@ import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
+1 -1
View File
@@ -12,7 +12,7 @@ import { filesystem } from "@opencode-ai/util/effect/app-node-platform"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location"
+1 -1
View File
@@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment"
import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission"
+1 -1
View File
@@ -1,7 +1,7 @@
import { beforeEach, expect } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Database } from "@opencode-ai/core/database/database"
import { makeMemoryDriver } from "@opencode-ai/core/environment"
import { makeMemoryDriver } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { WorkspaceTable } from "@opencode-ai/core/workspace/sql"
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"outDir": "dist/types",
"tsBuildInfoFile": null
},
"include": ["src"]
}
+3
View File
@@ -13083,6 +13083,9 @@
},
"text": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["id", "time", "type", "text"],
+2 -1
View File
@@ -5,7 +5,8 @@ export namespace ServiceStatus {
export const Health = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
pid: Schema.Int.check(Schema.isGreaterThan(0)),
// 0 means the runtime has no OS process identity (e.g. workerd).
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
}).annotate({ identifier: "ServiceHealth" })
export type Health = typeof Health.Type
+3 -1
View File
@@ -5,7 +5,8 @@
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./workerd": "./src/workerd.ts"
},
"scripts": {
"test": "bun test --timeout 5000",
@@ -17,6 +18,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
+19 -6
View File
@@ -1,5 +1,7 @@
import { OpenCode } from "@opencode-ai/client/effect"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import type { ServerFetch } from "@opencode-ai/server/fetch"
import { createEmbeddedRoutes } from "@opencode-ai/server/routes"
import type { ServerOptions } from "@opencode-ai/server/options"
import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect"
@@ -13,21 +15,32 @@ export type CreateOptions = ServerOptions & {
readonly log?: LogOptions
}
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}) {
/** Host hooks for embedding opencode on a non-default runtime profile (e.g. workerd). */
export type EmbedOptions = ServerFetch.BootOptions
export const create = Effect.fn("OpenCode.create")(function* (options: CreateOptions = {}, embed: EmbedOptions = {}) {
const { log, ...server } = options
const runtime = yield* Effect.acquireRelease(
Effect.sync(() =>
ManagedRuntime.make(
createEmbeddedRoutes({
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
}).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
createEmbeddedRoutes(
{
...server,
app: { ...server.app, name: server.app?.name ?? "sdk" },
database: { path: ":memory:", ...server.database },
},
embed.overrides ?? [],
).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(Logging.layer(log))),
),
),
(runtime) => runtime.disposeEffect,
)
const context = yield* runtime.contextEffect
// Unconditional, as on every runtime: the sweep is a no-op when nothing is
// suspended (always, for the default in-memory database). Forked so the
// returned client is never delayed; resumed drains are already logged and
// durably recorded by the execution layer.
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
const plugins = Context.get(context, SdkPlugins.Service)
const router = Context.get(context, HttpRouter.HttpRouter)
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
+29
View File
@@ -0,0 +1,29 @@
export * as OpenCodeWorkerd from "./workerd"
import { ServerWorkerd } from "@opencode-ai/server/workerd"
import { Layer } from "effect"
import * as OpenCode from "./opencode"
import type { LogOptions } from "./logging"
export type CreateOptions = ServerWorkerd.Options & {
readonly log?: LogOptions
}
/**
* Boots the embedded opencode SDK on the workerd runtime profile: the full
* application graph inside a Cloudflare Durable Object, with the database on
* the injected `DurableObjectStorage` SQLite and every intentionally-local
* service replaced or disabled (see `ServerWorkerd.replacements`).
*
* Suspended Sessions resume on boot (as on every runtime) because a Durable
* Object can be evicted mid-turn with no teardown; the write-ahead execution
* claim marks the turn and the boot-time sweep replays it.
*
* Returns the same typed `OpenCode.Interface` as `OpenCode.create` typed
* session operations plus the live `events.subscribe()` stream served over
* an in-process fetch transport, so no request leaves the isolate.
*/
export const create = ({ log, ...options }: CreateOptions) =>
OpenCode.create({ ...ServerWorkerd.serverOptions(options), log }, { overrides: ServerWorkerd.replacements(options) })
export const layer = (options: CreateOptions) => Layer.effect(OpenCode.Service, create(options))
+2 -1
View File
@@ -10,7 +10,8 @@
},
"scripts": {
"test": "bun test --only-failures",
"typecheck": "tsgo -b"
"typecheck": "tsgo -b",
"probe:workerd": "bun run script/workerd-probe.ts"
},
"dependencies": {
"@effect/platform-node": "catalog:",
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bun
/**
* Bundle probe for the workerd profile: verifies the full module graph behind
* src/workerd.ts resolves under the `workerd` condition without any `bun:`
* builtins. `node:` builtins stay external (workerd provides them through
* nodejs_compat); the probe prints the surviving externals so the A4 boot
* spike knows exactly what the runtime must supply.
*/
import path from "node:path"
import { mkdtempSync } from "node:fs"
import os from "node:os"
const outdir = mkdtempSync(path.join(os.tmpdir(), "opencode-workerd-probe-"))
const result = await Bun.build({
entrypoints: [path.join(import.meta.dir, "../src/workerd.ts")],
conditions: ["workerd"],
target: "node",
outdir,
sourcemap: "none",
throw: false,
})
if (!result.success) {
console.error(`workerd bundle probe FAILED (${result.logs.length} issues)`)
for (const log of result.logs) console.error(String(log))
process.exit(1)
}
// Everything in the graph is bundled, so any import specifier that survives in
// the output is an external the runtime must provide. A specifier reached only
// through `import()` is loaded lazily behind a runtime guard, so it costs
// nothing on a runtime that never takes that branch; a static import is
// evaluated on module load and must resolve.
const transpiler = new Bun.Transpiler({ loader: "js" })
const externals = new Map<string, { static: boolean }>()
const record = (specifier: string, isStatic: boolean) => {
const seen = externals.get(specifier)
externals.set(specifier, { static: (seen?.static ?? false) || isStatic })
}
for (const artifact of result.outputs) {
const text = await artifact.text()
for (const imported of transpiler.scanImports(text)) record(imported.path, imported.kind !== "dynamic-import")
for (const match of text.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)) record(match[1], true)
}
const sorted = Array.from(externals.keys()).toSorted()
const isBun = (specifier: string) => specifier === "bun" || specifier.startsWith("bun:")
const leaked = sorted.filter((specifier) => isBun(specifier) && externals.get(specifier)!.static)
const bytes = result.outputs.reduce((total, artifact) => total + artifact.size, 0)
console.log(`workerd bundle probe OK: ${result.outputs.length} artifacts, ${(bytes / 1024 / 1024).toFixed(1)} MiB`)
console.log(`external builtins (${sorted.length}):`)
for (const specifier of sorted) {
const lazy = externals.get(specifier)!.static ? "" : " (lazy)"
console.log(` ${specifier}${lazy}`)
}
if (leaked.length > 0) {
console.error(`FAILED: bun builtins statically imported in the workerd graph: ${leaked.join(", ")}`)
process.exit(1)
}
+14 -7
View File
@@ -3,19 +3,20 @@ export * as ServerFetch from "./fetch"
import { Context, Effect, Layer } from "effect"
import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { isAllowedCorsOrigin } from "./cors"
import { createRoutes } from "./routes"
import type { ServerOptions } from "./options"
export interface BootOptions {
/**
* Resumes execution-journaled Sessions once the application layer boots. Pair with
* `SessionExecution.configured({ suspendOnStart: true })` on runtimes that can die without
* teardown, so turns orphaned by a hard death replay on the next boot.
* Runtime-profile service replacements, applied after the standard set so later entries
* win swaps services the standard graph assumes are local. See `ServerWorkerd.replacements`.
*/
readonly resumeSuspendedSessions?: boolean
readonly overrides?: LayerNode.Replacements
}
/**
* Builds a web-standard fetch handler `(request: Request) => Promise<Response>` serving the
* same HttpApi routes as the Node server process without binding a port, owning a listener, or
@@ -32,13 +33,19 @@ export interface BootOptions {
* Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it
* serves unauthenticated, so an embedder without a password must front the handler with its own
* access control.
*
* Sessions whose execution claim was never released resume once the layer is built, exactly as
* the Node server process does: a runtime that dies without teardown an evicted Durable
* Object leaves the same durable signature as a killed process replays orphaned turns on the
* next boot, and the sweep is a no-op when nothing is suspended.
*/
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) {
const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices)))
const context = yield* Layer.build(
createRoutes(options, () => [], boot.overrides ?? []).pipe(Layer.provide(HttpServer.layerServices)),
)
// Forked so the returned handler is never delayed; resumed drains are already
// logged and durably recorded by the execution layer.
if (boot.resumeSuspendedSessions)
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
return Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
+2 -1
View File
@@ -11,7 +11,8 @@ export const HealthHandler = HttpApiBuilder.group(Api, "server.health", (handler
return {
healthy: true as const,
version: info.app.version ?? "unknown",
pid: process.pid,
// Runtimes without OS process identity (workerd) report 0.
pid: process.pid ?? 0,
}
}),
)
+6
View File
@@ -40,5 +40,11 @@ export const ServerOptions = Schema.Struct({
fff: Schema.optional(Schema.Boolean),
}),
),
mcp: Schema.optional(
Schema.Struct({
/** Set false on runtimes that cannot spawn child processes; local (stdio) MCP servers report failed instead of connecting. */
stdio: Schema.optional(Schema.Boolean),
}),
),
})
export type ServerOptions = typeof ServerOptions.Type
+13 -4
View File
@@ -66,27 +66,34 @@ const applicationServices = LayerNode.group([
SessionRestart.node,
])
export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray<string> = () => []) {
export function createRoutes(
options: ServerOptions = {},
serviceURLs: () => ReadonlyArray<string> = () => [],
overrides: LayerNode.Replacements = [],
) {
return makeRoutes(
options.password
? ServerAuth.Config.configLayer({ password: Option.some(options.password) })
: ServerAuth.Config.layer,
options,
serviceURLs,
overrides,
)
}
export function createEmbeddedRoutes(options: ServerOptions = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [])
export function createEmbeddedRoutes(options: ServerOptions = {}, overrides: LayerNode.Replacements = []) {
return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], overrides)
}
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
options: ServerOptions,
serviceURLs: () => ReadonlyArray<string>,
// Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win.
overrides: LayerNode.Replacements,
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
const standard: LayerNode.Replacements = [
[Database.node, Database.configured(options.database)],
[Bus.node, Bus.configured({ persist: options.events?.persist })],
[App.node, App.configured(options.app)],
@@ -113,6 +120,7 @@ function makeRoutes<AuthError, AuthServices>(
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
stdio: options.mcp?.stdio,
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
@@ -122,6 +130,7 @@ function makeRoutes<AuthError, AuthServices>(
WorkspaceDriver.registryNode({ [modalProvider]: modalWorkspaceDriver({ app: "opencode-workspaces" }) }),
],
]
const replacements: LayerNode.Replacements = [...standard, ...overrides]
const serviceLayer = options.simulation
? Layer.unwrap(
Effect.gen(function* () {
+150
View File
@@ -0,0 +1,150 @@
export * as ServerWorkerd from "./workerd"
import { Effect, Layer } from "effect"
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
import { Database } from "@opencode-ai/core/database/database"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Pty } from "@opencode-ai/core/pty"
import { Shell } from "@opencode-ai/core/shell"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { Vcs } from "@opencode-ai/core/vcs"
import type { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { ServerFetch } from "./fetch"
import type { ServerOptions } from "./options"
/**
* The workerd runtime profile: boots opencode core and server inside a
* Cloudflare Durable Object, with every intentionally-local service replaced
* or disabled.
*
* - Database runs on the injected `DurableObjectStorage` SQLite.
* - Watcher and fff are disabled through their existing option flags; pty, fff,
* shell-parser, photon, and process-lock native modules resolve to inert
* stubs under the `workerd` bundle condition.
* - Shell, FileSystem, FileSystemSearch, and Pty fail with a clear defect until
* a remote sandbox backs them; Snapshot and Vcs degrade to no-op results.
* - Config is injected as a string (no filesystem); plugin discovery is
* precompiled-only and MCP is restricted to remote transports.
*
* Bundle with the `workerd` condition, e.g.
* `bun build src/workerd.ts --conditions=workerd --target=node`
* (see `script/workerd-probe.ts`).
*/
export interface Options {
/** Durable Object storage whose SQLite database backs the opencode database. */
readonly storage: DurableObjectStorage
readonly app?: ServerOptions["app"]
readonly password?: string
/** Inline opencode config content (JSON), same as `ServerOptions.config.content`. */
readonly config?: { readonly content?: string }
/** models.dev catalog options; the bundled snapshot is the boot-time floor either way. */
readonly models?: ServerOptions["models"]
}
/**
* Builds the web-standard fetch handler for a Durable Object's `fetch()`. The
* application layer builds eagerly in the caller's scope, so hold it in the
* Durable Object instance rather than per request.
*/
export function create(options: Options) {
// Eviction can kill the isolate between a turn's Started and terminal events with no
// teardown. The write-ahead execution claim plus ServerFetch.make's boot-time resume
// recovers such orphaned turns by replaying the drain from durable history on the next wake.
return ServerFetch.make(serverOptions(options), { overrides: replacements(options) })
}
export function serverOptions(options: Options): ServerOptions {
return {
app: options.app,
password: options.password,
fs: { filewatcher: false, fff: false },
// Durable event history is how a turn orphaned by eviction is recovered:
// the boot-time resume replays it. A runtime that dies without teardown
// cannot opt out of it, so this is not exposed as an option.
events: { persist: true },
config: { content: options.config?.content },
models: options.models,
// No child processes on workerd: local (stdio) MCP servers report failed
// instead of connecting; remote transports work unchanged.
mcp: { stdio: false },
}
}
/** The workerd replacement graph, applied after the standard server replacements. */
export function replacements(options: Options): LayerNode.Replacements {
return [
[Database.node, Database.configuredClient(sqliteLayer({ storage: options.storage }))],
[Snapshot.node, Snapshot.noopLayer],
[Vcs.node, vcsLayer],
[Shell.node, shellLayer],
[FileSystem.node, fileSystemLayer],
[FileSystemSearch.node, fileSystemSearchLayer],
[Pty.node, ptyLayer],
// Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm
// install, or import of plugin code from disk.
[ConfigPluginSource.node, ConfigPluginSource.empty],
]
}
const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailable in the workerd profile`))
// Vcs degrades to empty results, matching its behavior for locations without a
// supported VCS, so read-only clients never need to special-case this runtime.
const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
info: () => Effect.succeed({ branch: {} }),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
}),
)
// Shell commands need a real process; queries for unknown IDs stay typed while
// creation is a defect until a remote sandbox backs them.
const shellLayer = Layer.succeed(
Shell.Service,
Shell.Service.of({
name: () => Effect.succeed("unsupported"),
create: () => unavailable("Shell.create"),
list: () => Effect.succeed([]),
get: (id) => Effect.fail(new Shell.NotFoundError({ id })),
wait: (id) => Effect.fail(new Shell.NotFoundError({ id })),
timeout: (id) => Effect.fail(new Shell.NotFoundError({ id })),
output: (id) => Effect.fail(new Shell.NotFoundError({ id })),
remove: (id) => Effect.fail(new Shell.NotFoundError({ id })),
}),
)
// The Location-scoped filesystem has no local worktree to serve until a remote
// sandbox backs it.
const fileSystemLayer = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
read: () => unavailable("FileSystem.read"),
list: () => unavailable("FileSystem.list"),
find: () => unavailable("FileSystem.find"),
}),
)
const fileSystemSearchLayer = Layer.succeed(
FileSystemSearch.Service,
FileSystemSearch.Service.of({
find: () => unavailable("FileSystemSearch.find"),
}),
)
const ptyLayer = Layer.succeed(
Pty.Service,
Pty.Service.of({
list: () => Effect.succeed([]),
get: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
create: () => unavailable("Pty.create"),
update: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
remove: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
write: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
attach: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })),
}),
)
+1 -1
View File
@@ -2,7 +2,7 @@ import { Effect, Sink, Stream } from "effect"
import { systemError } from "effect/PlatformError"
import type { Command, KillOptions } from "effect/unstable/process/ChildProcess"
import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
import type { Driver } from "@opencode-ai/core/environment"
import type { Driver } from "@opencode-ai/core/environment/index"
import type { App, Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal"
const INNER_WRAPPER = `
@@ -4,8 +4,8 @@ import path from "node:path"
import { afterAll, expect, test } from "bun:test"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Failed, makeFiles } from "@opencode-ai/core/environment"
import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance"
import { Failed, makeFiles } from "@opencode-ai/core/environment/index"
import { environmentConformance } from "../../core/test/lib/environment-conformance.js"
import { createModalSandbox } from "../src/workspace/modal"
const enabled =
+34
View File
@@ -0,0 +1,34 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage"
import { it } from "../../core/test/lib/effect"
import { ServerWorkerd } from "../src/workerd"
// Covers the profile's replacement graph composing and the database booting
// through the injected Durable Object storage. Verification inside a real
// isolate lives in the workerd-spike package.
it.live("boots the workerd profile over durable object storage", () =>
Effect.gen(function* () {
const handler = yield* ServerWorkerd.create({
storage: makeDurableObjectStorage(),
password: "secret",
app: { version: "workerd-test" },
config: { content: "{}" },
})
const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
expect(unauthorized.status).toBe(401)
const health = yield* Effect.promise(() =>
handler(
new Request("http://opencode.local/api/health", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
}),
),
)
expect(health.status).toBe(200)
const body: unknown = yield* Effect.promise(() => health.json())
expect(body).toMatchObject({ healthy: true, version: "workerd-test" })
}).pipe(Effect.scoped),
)
+1 -1
View File
@@ -3,7 +3,7 @@ import os from "node:os"
import path from "node:path"
import { expect, test } from "bun:test"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeFiles } from "@opencode-ai/core/environment"
import { makeFiles } from "@opencode-ai/core/environment/index"
import { Workspace } from "@opencode-ai/core/workspace"
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
import { Effect, Layer } from "effect"
+9 -1
View File
@@ -38,6 +38,7 @@ import {
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiPaths,
useTuiStartup,
type TuiApp,
} from "./context/runtime"
@@ -85,6 +86,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
@@ -148,6 +150,7 @@ const appBindingCommands = [
"variant.cycle",
"variant.list",
"provider.connect",
"opencode.settings",
"opencode.status",
"server.pair",
"service.restart",
@@ -166,6 +169,7 @@ const appBindingCommands = [
"app.toggle.file_context",
"app.toggle.diffwrap",
"app.toggle.paste_summary",
"permission.mode",
] as const
export type TuiInput = {
@@ -453,6 +457,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
const paths = useTuiPaths()
const config = useConfig()
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
const route = useRoute()
@@ -659,10 +664,13 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location:
location: newSessionLocation(
config.data.session.new_location,
paths.cwd,
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
),
})
dialog.clear()
},
+12 -10
View File
@@ -384,16 +384,18 @@ export function DevToolsBar() {
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
<Show when={Boolean(turnTokens())}>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
</Show>
</box>
<For each={groups()}>
{(group) => (
@@ -93,6 +93,15 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "New session location",
category: "Session",
path: ["session", "new_location"],
default: "launch",
values: ["launch", "inherit"],
labels: ["launch directory", "active session"],
keywords: ["directory", "cwd", "inherit"],
},
{
title: "Enabled",
category: "Tabs",
@@ -1,6 +1,6 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal, onMount } from "solid-js"
import { CliRenderEvents, TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useConfig } from "../config"
import { useClipboard } from "../context/clipboard"
import { Keymap } from "../context/keymap"
@@ -15,14 +15,33 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
const toast = useToast()
const theme = useTheme("elevated")
const overlayTheme = useTheme("overlay")
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const [copied, setCopied] = createSignal(false)
const [scrollable, setScrollable] = createSignal(false)
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
let scroll: ScrollBoxRenderable | undefined
let measure: (() => void) | undefined
onMount(() => dialog.setSize("large"))
createEffect(() => {
dimensions()
props.error
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
measure = () => {
measure = undefined
setScrollable(Boolean(scroll && scroll.scrollHeight > scroll.viewport.height))
}
renderer.once(CliRenderEvents.FRAME, measure)
renderer.requestRender()
})
onCleanup(() => {
if (measure) renderer.off(CliRenderEvents.FRAME, measure)
})
const copy = () => {
void clipboard
.write(props.error)
@@ -32,11 +51,14 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
Keymap.createLayer(() => ({
mode: "modal",
commands: [{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack }],
commands: [
{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack },
{ bind: "c", title: "Copy details", group: "Dialog", run: copy },
],
}))
useKeyboard((event) => {
if (event.name === "c") return copy()
if (!scrollable()) return
if (event.name === "up") return scroll?.scrollBy(-1)
if (event.name === "down") return scroll?.scrollBy(1)
if (event.name === "pageup") return scroll?.scrollBy(-height())
@@ -52,7 +74,7 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
{props.title}
</text>
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
esc back
esc
</text>
</box>
<text fg={theme.text.feedback.error.default}> Failed</text>
@@ -75,9 +97,17 @@ export function DialogErrorDetails(props: { title: string; error: string; onBack
</scrollbox>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued}> scroll</text>
<text fg={theme.text.subdued} onMouseUp={copy}>
{copied() ? "✓ copied" : "c copy details"}
<text>
<span style={{ fg: theme.text.default }}>
<b>{scrollable() ? "↑/↓" : ""}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{scrollable() ? " scroll" : ""}</span>
</text>
<text onMouseUp={copy}>
<span style={{ fg: copied() ? theme.text.feedback.success.default : theme.text.default }}>
<b>{copied() ? "✓ copied" : "c"}</b>
</span>
<span style={{ fg: theme.text.subdued }}>{copied() ? "" : " copy details"}</span>
</text>
</box>
</box>
+94 -10
View File
@@ -27,6 +27,8 @@ import { marqueeText } from "../util/marquee"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
@@ -42,6 +44,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
add?: () => void
status(sessionID: string): SessionTabsStatus
}
@@ -103,22 +106,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
const ordered = createMemo(() => {
const pending = preview()
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const items = ordered
const statuses = createMemo(
() =>
new Map(
items().map((tab) => {
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
const status = tabs.status(tab.sessionID)
return [
tab.sessionID,
{
@@ -145,6 +149,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
createEffect(() => {
if (!scroll) return
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
const index = items().findIndex((tab) => tab.sessionID === activeID())
if (index === -1) return
const top = index * 3
@@ -171,7 +177,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const selected = () => activeID() === tab.sessionID
const status = createMemo(() => itemStatus(tab))
const [sweepLevel, setSweepLevel] = createSignal(0)
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
const session = createMemo(() => data.session.get(tab.sessionID))
const project = createMemo(() => {
const value = session()
return value ? data.project.get(value.projectID) : undefined
@@ -188,7 +194,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
@@ -260,7 +265,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
tabs.select(tab.sessionID)
}
return (
<box
@@ -277,7 +282,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={release}
onMouseDrag={(event) => {
if (!rail || tab === NEW_SESSION_TAB) return
if (!rail) return
const target = Math.max(
0,
Math.min(
@@ -386,7 +391,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseUp={(event) => {
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
@@ -417,6 +422,63 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
)
}}
</For>
{/* One slot with two states: a subdued affordance that promotes in place into the
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
<Show when={tabs.add || newTab()}>
<box
height={1}
width="100%"
position="relative"
flexDirection="row"
paddingLeft={1}
backgroundColor={
newTab()
? theme.background.action.primary.selected
: addHovered()
? theme.background.action.primary.hovered
: theme.background.default
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => {
if (!newTab()) tabs.add?.()
}}
>
<text
width={2}
fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
+
</text>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
<Show when={newTab()}>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (!addHovered()) return
event.stopPropagation()
tabs.close()
}}
>
{addHovered() ? "×" : ""}
</text>
</Show>
</box>
</Show>
</box>
</scrollbox>
</box>
@@ -431,6 +493,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
@@ -449,7 +512,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (!pending) return tabs.tabs()
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
// The promoted new-session slot joins the strip as the active tab; the idle plus affordance
// and the promoted slot are mutually exclusive states of one control.
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
const showPlus = () => Boolean(tabs.add) && !newTab()
createEffect(() => {
const pending = preview()
if (!pending || dragging()) return
@@ -457,7 +523,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
adaptiveSessionTabLayout(
items(),
activeID(),
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
previous?.start,
),
)
const statuses = createMemo(
() =>
@@ -704,7 +775,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{sessionTabShortcutLabel(tabNumber() - 1)}
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
@@ -746,6 +817,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" " + layout().after}
</text>
</Show>
<Show when={showPlus()}>
<text
width={ADD_TAB_WIDTH}
fg={addHovered() ? theme.text.default : theme.text.subdued}
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
selectable={false}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => tabs.add?.()}
>
{" + "}
</text>
</Show>
</box>
)
}
+15 -6
View File
@@ -137,6 +137,9 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
new_location: Schema.optional(Schema.Literals(["launch", "inherit"])).annotate({
description: "Start new sessions in the TUI launch directory or inherit the active session location",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
tabs: Schema.optional(
@@ -202,7 +205,7 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "tabs"> & {
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
@@ -218,6 +221,9 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
style: "block" | "underline" | "line" | "default"
blinking: boolean
}
session: Omit<NonNullable<Info["session"]>, "new_location"> & {
new_location: "launch" | "inherit"
}
tabs: {
enabled: boolean
scope: "global" | "cwd"
@@ -228,10 +234,10 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
if (!options.terminalSuspend) {
keybinds.terminal_suspend = "none"
if (keybinds.input_undo === undefined) {
const inputUndo = TuiKeybind.defaultValue("input_undo")
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
keybinds["terminal.suspend"] = "none"
if (keybinds["input.undo"] === undefined) {
const inputUndo = TuiKeybind.defaultValue("input.undo")
keybinds["input.undo"] = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
.filter((value, index, values) => values.indexOf(value) === index)
.join(",")
}
@@ -248,7 +254,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
sounds: input.attention?.sounds ?? {},
},
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
commandMap: TuiKeybind.CommandMap,
bindingDefaults: TuiKeybind.bindingDefaults(),
}),
leader: { timeout: input.leader?.timeout ?? 2000 },
@@ -259,6 +264,10 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
blinking: input.cursor.blinking ?? true,
}
: undefined,
session: {
...input.session,
new_location: input.session?.new_location ?? "launch",
},
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
+332 -2
View File
@@ -1,2 +1,332 @@
export * from "./v1/keybind"
export * as TuiKeybind from "./v1/keybind"
export * as TuiKeybind from "./keybind"
import type { KeyEvent, Renderable } from "@opentui/core"
import type { Binding } from "@opentui/keymap"
import type { BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
import { Schema } from "effect"
const KeyStroke = Schema.Struct({
name: Schema.String,
ctrl: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean),
meta: Schema.optional(Schema.Boolean),
super: Schema.optional(Schema.Boolean),
hyper: Schema.optional(Schema.Boolean),
})
const BindingObject = Schema.StructWithRest(
Schema.Struct({
key: Schema.Union([Schema.String, KeyStroke]),
event: Schema.optional(Schema.Literals(["press", "release"])),
preventDefault: Schema.optional(Schema.Boolean),
fallthrough: Schema.optional(Schema.Boolean),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
export const BindingValueSchema = Schema.Union([
Schema.Literal(false),
Schema.Literal("none"),
BindingItem,
Schema.Array(BindingItem),
])
export type BindingValueSchema = Schema.Schema.Type<typeof BindingValueSchema>
type Definition = {
default: BindingValueSchema
description: string
}
export const LeaderDefault = "ctrl+x"
const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
export const Definitions = {
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
"app.exit": keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
"app.debug": keybind("none", "Toggle debug panel"),
"app.console": keybind("none", "Toggle console"),
"app.scrap": keybind("none", "Open scrap screen"),
"app.toggle.animations": keybind("none", "Toggle animations"),
"app.toggle.file_context": keybind("none", "Toggle file context"),
"app.toggle.diffwrap": keybind("none", "Toggle diff wrapping"),
"app.toggle.paste_summary": keybind("none", "Toggle paste summary"),
"command.palette.show": keybind("ctrl+p", "List available commands"),
"help.show": keybind("none", "Open help dialog"),
"docs.open": keybind("none", "Open documentation"),
"opencode.settings": keybind("none", "Open settings"),
"server.pair": keybind("none", "Pair device"),
"service.restart": keybind("none", "Restart service"),
"permission.mode": keybind("none", "Toggle auto-approve permissions"),
"diff.open": keybind("none", "Open diff viewer"),
"diff.close": keybind("escape,q", "Close diff viewer"),
"diff.down": keybind("j,down", "Move diff viewer down"),
"diff.up": keybind("k,up", "Move diff viewer up"),
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
"diff.expand": keybind("right", "Expand diff viewer item"),
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
"diff.collapse": keybind("left", "Collapse diff viewer item"),
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
"diff.next_file": keybind("n", "Jump to next diff file"),
"diff.previous_file": keybind("p", "Jump to previous diff file"),
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
"diff.single_patch": keybind("s", "Toggle single patch view"),
"diff.switch_source": keybind("d", "Switch diff viewer source"),
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
"prompt.editor": keybind("<leader>e", "Open external editor"),
"theme.switch": keybind("<leader>t", "List available themes"),
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
"opencode.status": keybind("<leader>s", "View status"),
"opencode.debug": keybind("none", "View debug info"),
"session.export": keybind("<leader>x", "Export session to editor"),
"session.copy": keybind("none", "Copy session transcript"),
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
"session.tab.close": keybind("<leader>w", "Close current session tab"),
"session.timeline": keybind("<leader>g", "Show session timeline"),
"session.fork": keybind("none", "Fork session from message"),
"session.rename": keybind("ctrl+r", "Rename session"),
"session.delete": keybind("ctrl+d", "Delete session"),
"session.share": keybind("none", "Share current session"),
"session.unshare": keybind("none", "Unshare current session"),
"session.interrupt": keybind("escape", "Interrupt current session"),
"session.background": keybind("ctrl+b", "Background blocking session tools"),
"session.compact": keybind("<leader>c", "Compact the session"),
"session.cd": keybind("none", "Change working directory"),
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
"session.child.first": keybind("down", "Toggle subagent picker"),
"session.child.next": keybind("right", "Go to next child session"),
"session.child.previous": keybind("left", "Go to previous child session"),
"session.parent": keybind("up", "Go to parent session"),
"session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"),
"session.quick_switch.1": keybind("<leader>1", "Switch to session in quick slot 1"),
"session.quick_switch.2": keybind("<leader>2", "Switch to session in quick slot 2"),
"session.quick_switch.3": keybind("<leader>3", "Switch to session in quick slot 3"),
"session.quick_switch.4": keybind("<leader>4", "Switch to session in quick slot 4"),
"session.quick_switch.5": keybind("<leader>5", "Switch to session in quick slot 5"),
"session.quick_switch.6": keybind("<leader>6", "Switch to session in quick slot 6"),
"session.quick_switch.7": keybind("<leader>7", "Switch to session in quick slot 7"),
"session.quick_switch.8": keybind("<leader>8", "Switch to session in quick slot 8"),
"session.quick_switch.9": keybind("<leader>9", "Switch to session in quick slot 9"),
"session.tab.select.1": keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
"session.tab.select.2": keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
"session.tab.select.3": keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
"session.tab.select.4": keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
"session.tab.select.5": keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
"session.tab.select.6": keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
"model.dialog.favorite": keybind("ctrl+f", "Toggle model favorite status"),
"model.list": keybind("<leader>m", "List available models"),
"model.cycle_recent": keybind("f2", "Next recently used model"),
"model.cycle_recent_reverse": keybind("shift+f2", "Previous recently used model"),
"model.cycle_favorite": keybind("none", "Next favorite model"),
"model.cycle_favorite_reverse": keybind("none", "Previous favorite model"),
"mcp.list": keybind("none", "List MCP servers"),
"provider.connect": keybind("none", "Connect integration"),
"agent.list": keybind("<leader>a", "List agents"),
"agent.cycle": keybind("shift+tab", "Next agent"),
"agent.cycle.reverse": keybind("none", "Previous agent"),
"variant.cycle": keybind("ctrl+t", "Cycle model variants"),
"variant.list": keybind("none", "List model variants"),
"session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
"session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
"session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"),
"session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"),
"session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"),
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
"session.message.next": keybind("alt+down", "Navigate to next message"),
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
"messages.copy": keybind("<leader>y", "Copy message"),
"session.undo": keybind("<leader>u", "Undo message"),
"session.redo": keybind("<leader>r", "Redo message"),
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.skills": keybind("none", "Open skill selector"),
"prompt.stash": keybind("none", "Stash prompt"),
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
"prompt.stash.list": keybind("none", "List stashed prompts"),
"prompt.clear": keybind("ctrl+c", "Clear input field"),
"prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
"input.submit": keybind("return", "Submit input"),
"input.newline": keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
"input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
"input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
"input.move.up": keybind("up", "Move cursor up in input"),
"input.move.down": keybind("down", "Move cursor down in input"),
"input.select.left": keybind("shift+left", "Select left in input"),
"input.select.right": keybind("shift+right", "Select right in input"),
"input.select.up": keybind("shift+up", "Select up in input"),
"input.select.down": keybind("shift+down", "Select down in input"),
"input.line.home": keybind("ctrl+a", "Move to start of line in input"),
"input.line.end": keybind("ctrl+e", "Move to end of line in input"),
"input.select.line.home": keybind("ctrl+shift+a", "Select to start of line in input"),
"input.select.line.end": keybind("ctrl+shift+e", "Select to end of line in input"),
"input.visual.line.home": keybind("alt+a", "Move to start of visual line in input"),
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
"input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"),
"input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"),
"input.backspace": keybind("backspace,shift+backspace", "Backspace in input"),
"input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
"input.undo": keybind("ctrl+-,super+z", "Undo in input"),
"input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"),
"input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
"input.word.backward": keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
"input.select.word.forward": keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
"input.select.word.backward": keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
"input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
"input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
"input.select.all": keybind("super+a", "Select all in input"),
"prompt.history.previous": keybind("up", "Previous history item"),
"prompt.history.next": keybind("down", "Next history item"),
"composer.subagent.up": keybind("up", "Previous subagent"),
"composer.subagent.down": keybind("down", "Next subagent"),
"composer.subagent.select": keybind("return", "Navigate to subagent"),
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
"composer.shell.up": keybind("up", "Previous shell"),
"composer.shell.down": keybind("down", "Next shell"),
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
"dialog.select.home": keybind("home", "Move to first dialog item"),
"dialog.select.end": keybind("end", "Move to last dialog item"),
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
"plugins.toggle": keybind("space", "Toggle plugin"),
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
"plugins.list": keybind("none", "Open plugin manager dialog"),
"plugins.install": keybind("none", "Install plugin"),
"which-key.toggle": keybind("ctrl+alt+k", "Toggle which-key panel"),
"which-key.layout.toggle": keybind("ctrl+alt+shift+k", "Switch which-key layout"),
"which-key.pending.toggle": keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
"which-key.group.previous": keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
"which-key.group.next": keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
"which-key.scroll.up": keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
"which-key.scroll.down": keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
"which-key.page.up": keybind("ctrl+alt+pageup", "Page which-key up"),
"which-key.page.down": keybind("ctrl+alt+pagedown", "Page which-key down"),
"which-key.home": keybind("ctrl+alt+home", "Jump to first which-key binding"),
"which-key.end": keybind("ctrl+alt+end", "Jump to last which-key binding"),
} satisfies Record<string, Definition>
type KeybindName = keyof typeof Definitions
const KeybindNames = new Set<string>(Object.keys(Definitions))
export const KeybindOverrides = Schema.Struct(
Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
]),
),
).annotate({ description: "TUI keybinding overrides" })
export const Descriptions = Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
) as Record<KeybindName, string>
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
export type KeybindOverrides = Partial<Keybinds>
export type BindingLookupView = {
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
get(command: string): readonly Binding<Renderable, KeyEvent>[]
has(command: string): boolean
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
}
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
}
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
export function defaultValue(name: KeybindName) {
return Definitions[name].default
}
export function parse(keybinds: KeybindOverrides): Keybinds {
const invalid = unknownKeys(keybinds)
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
return Object.fromEntries(
Object.entries(Definitions).map(([name, item]) => [
name,
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
]),
) as Keybinds
}
export const Keybinds = { parse }
export function unknownKeys(input: object) {
return Object.keys(input).filter((key) => !KeybindNames.has(key))
}
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
return ({ command, binding }) => {
if (binding.desc !== undefined) return
return { desc: Descriptions[command as KeybindName] }
}
}
@@ -0,0 +1,10 @@
import type { LocationRef } from "@opencode-ai/client/promise"
export function newSessionLocation(
mode: "launch" | "inherit",
launchDirectory: string,
current?: LocationRef,
): LocationRef {
if (mode === "inherit" && current) return current
return { directory: launchDirectory }
}
+10
View File
@@ -7,6 +7,7 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
import { useEvent } from "./event"
import { useRoute } from "./route"
import { useConfig } from "../config"
import { useLocation } from "./location"
import { useStorage } from "./storage"
import { useTuiPaths } from "./runtime"
import {
@@ -48,6 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const data = useData()
const event = useEvent()
const config = useConfig().data
const location = useLocation()
const paths = useTuiPaths()
const enabled = () => config.tabs.enabled
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
@@ -249,6 +251,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
add() {
if (!enabled()) return
const sessionID = current()
route.navigate({
type: "home",
location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref,
})
},
close(sessionID?: string) {
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
@@ -3,7 +3,17 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"
export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
function Mcp(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? [])
const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length)
const count = createMemo(() => list().filter((item) => item.status.status === "connected").length)
@@ -30,13 +40,17 @@ function Mcp(props: { context: Plugin.Context }) {
</Match>
</Switch>
</text>
<text fg={props.context.theme.text.subdued}>/mcps</text>
<Show when={visibility().mcpCommand}>
<text fg={props.context.theme.text.subdued}>/mcps</text>
</Show>
</box>
</Show>
)
}
function Plugins(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const plugins = usePlugin()
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
@@ -47,7 +61,9 @@ function Plugins(props: { context: Plugin.Context }) {
<span style={{ fg: props.context.theme.text.feedback.error.default }}> </span>
{failed()} plugin{failed() === 1 ? "" : "s"} failed
</text>
<text fg={props.context.theme.text.subdued}>/plugins</text>
<Show when={visibility().pluginCommand}>
<text fg={props.context.theme.text.subdued}>/plugins</text>
</Show>
</box>
</Show>
)
@@ -55,6 +71,7 @@ function Plugins(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
return (
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
@@ -71,9 +88,11 @@ function View(props: { context: Plugin.Context }) {
<Mcp context={props.context} />
<Plugins context={props.context} />
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
<Show when={visibility().version}>
<box flexShrink={0}>
<text fg={props.context.theme.text.subdued}>{props.context.app.version}</text>
</box>
</Show>
</box>
</Show>
)
@@ -427,7 +427,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.down",
title: "Move diff viewer down",
group: "VCS",
bind: "j,down",
run: focusRunner({
files() {
moveFileSelection(1)
@@ -442,7 +441,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.up",
title: "Move diff viewer up",
group: "VCS",
bind: "k,up",
run: focusRunner({
files() {
moveFileSelection(-1)
@@ -457,7 +455,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.down",
title: "Page diff viewer down",
group: "VCS",
bind: "pagedown,ctrl+f",
run: focusRunner({
files() {
moveFileSelection(8)
@@ -472,7 +469,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.page.up",
title: "Page diff viewer up",
group: "VCS",
bind: "pageup,ctrl+b",
run: focusRunner({
files() {
moveFileSelection(-8)
@@ -578,7 +574,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
id: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
group: "VCS",
bind: "m",
run() {
toggleSelectedFileReviewed()
},

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