Compare commits

..

85 Commits

Author SHA1 Message Date
Aiden Cline 529106dae8 test(provider): synchronize model refresh event 2026-06-11 21:50:22 -05:00
Aiden Cline a03882ff48 test(provider): await background model refresh 2026-06-11 21:10:56 -05:00
Aiden Cline 72244e9e09 fix(provider): refresh models in background 2026-06-11 20:30:14 -05:00
Grant Martin 7e7ad37736 feat(opencode): support cwd on local MCP servers (#30676)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 16:05:45 -05:00
Aiden Cline ca8db315a9 fix(tui): show prompt submission errors (#31949) 2026-06-11 14:33:01 -05:00
opencode-agent[bot] 9eb07ab55a chore: generate 2026-06-11 17:49:51 +00:00
Adam e07e420637 fix(stats): align deep route metadata with /data (#31930) 2026-06-11 12:47:45 -05:00
opencode-agent[bot] 84f94e6143 chore: generate 2026-06-11 17:23:18 +00:00
Aiden Cline a150424c16 fix(tui): show terminal tool failure labels (#31934) 2026-06-11 12:19:31 -05:00
Linus Schlumberger 2e71292f2f fix(gemini): prevent gemini incompatibility with some tools (#31877) 2026-06-11 11:30:50 -05:00
Kevin Dawkins e2527db3c7 fix(opencode): surface content-filter finish reason as visible error (#31745)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-06-11 10:41:11 -05:00
opencode-agent[bot] a1dee8b273 chore: update nix node_modules hashes 2026-06-11 15:39:13 +00:00
Vladimir Glafirov 04e5ca9d1b chore: bump gitlab-ai-provider to 6.9.3 and opencode-gitlab-auth to 2.1.0 (#31913) 2026-06-11 10:24:20 -05:00
opencode-agent[bot] 6dd4d1473c chore: generate 2026-06-11 15:22:48 +00:00
Dax 31c5454ee6 refactor(server): serve raw filesystem content (#31911) 2026-06-11 11:20:11 -04:00
opencode-agent[bot] 31b233e9db chore: generate 2026-06-11 15:04:14 +00:00
Dax 567d6ed1a3 fix(tui): restore legacy sync consumers (#31908) 2026-06-11 15:01:20 +00:00
James Long 2bde20c2ba test(opencode): simplify snapshot race layer wiring (#31827) 2026-06-11 10:03:46 -04:00
tobwen 92c70c9c3d fix(tui): preserve exit epilogue during scoped shutdown (#31805) 2026-06-11 12:22:48 +00:00
opencode-agent[bot] 318dbe93ba chore: update nix node_modules hashes 2026-06-11 07:42:21 +00:00
Sebastian 8bd249d8cf upgrade opentui-spinner (#31561) 2026-06-11 09:20:59 +02:00
opencode-agent[bot] 710e408709 chore: generate 2026-06-11 06:32:44 +00:00
Dax dac0dd5309 feat(core): add connector authentication (#31837) 2026-06-11 06:31:17 +00:00
Aiden Cline bf05e8a122 fix(mcp): preserve headers during auth and debug (#31802) 2026-06-10 23:12:02 -05:00
opencode-agent[bot] 51b10b128e chore: generate 2026-06-11 03:36:12 +00:00
Dax 47a45601fd refactor(tui): replace v2 sync with data context (#31826) 2026-06-10 23:34:35 -04:00
opencode-agent[bot] 69623c2b79 chore: generate 2026-06-11 02:56:49 +00:00
James Long 38536cf733 test(opencode): simplify processor layer wiring (#31823) 2026-06-10 22:55:21 -04:00
Dax 8bf0675997 feat(server): add v2 session API endpoints (#31822) 2026-06-11 02:55:01 +00:00
opencode-agent[bot] ff967e582c chore: generate 2026-06-11 02:20:38 +00:00
James Long 20bf18ffb4 test(opencode): simplify share layer wiring (#31811) 2026-06-10 22:19:06 -04:00
Dmitriy Kovalenko 51891d56e7 fix(snapshot): reuse source git objects to avoid re-hashing huge repos (#31798) 2026-06-11 00:25:55 +00:00
Dax Raad cc22646467 docs: add branch naming guidance 2026-06-10 19:09:56 -04:00
James Long eb70b6137b test(opencode): simplify test registry layer wiring (#31761) 2026-06-10 17:21:40 -04:00
opencode-agent[bot] 6e2bcafd34 chore: update nix node_modules hashes 2026-06-10 20:09:04 +00:00
Aiden Cline 07b983e82f feat(mcp): support server log notifications (#31752) 2026-06-10 14:59:36 -05:00
Simon Klee c51a1588c7 tui: fix session list search filtering (#31748)
Fix #31182
Close #31211
2026-06-10 21:47:05 +02:00
Aiden Cline f43b0d3afd fix(mcp): apply timeouts to catalog requests (#31618) 2026-06-10 14:38:08 -05:00
Vladimir Glafirov 722f4dd416 chore: pin gitlab-ai-provider to 6.9.0 (#31741) 2026-06-10 14:33:31 -05:00
Dax Raad bb82aab5c8 update opencode.jsonc 2026-06-10 15:30:56 -04:00
opencode 936363e7a8 sync release versions for v1.17.3 2026-06-10 18:00:25 +00:00
Adam 8688ed7e3e feat(web): data link 2026-06-10 12:55:44 -05:00
opencode-agent[bot] 5b54203168 chore: update nix node_modules hashes 2026-06-10 17:44:15 +00:00
Vladimir Glafirov bed780fac9 chore: bump gitlab-ai-provider to 6.9.1 (#31728) 2026-06-10 12:30:43 -05:00
Dax Raad 14ec7ed5b3 fix fff disabling logic 2026-06-10 13:29:14 -04:00
opencode 2c65273204 sync release versions for v1.17.2 2026-06-10 16:42:54 +00:00
Filip 2e0f88d0e3 fix(desktop): restore linux launcher identity (#31709) 2026-06-10 15:54:49 +00:00
opencode-agent[bot] e1073e5d18 chore: generate 2026-06-10 15:48:14 +00:00
mridul 649618c50a fix(app): restore device attachment picker (#31707) 2026-06-10 15:46:09 +00:00
Ayush Thakur 02608a4e97 fix: recover from expired enterprise auth on remote config load (#31661) 2026-06-10 11:02:09 -04:00
Aiden Cline 3ad6923c61 fix(opencode): let subagents use their own permissions (#31696) 2026-06-10 09:26:08 -05:00
Dax Raad e4300e9b74 fix(core): disable fff by default on windows 2026-06-10 10:22:00 -04:00
Dax Raad 538cfaff0d feat(core): enable fff by default 2026-06-10 10:21:25 -04:00
Dax Raad 1dad38d1b5 fix(core): do not gate fff on initial scan 2026-06-10 10:19:52 -04:00
Aiden Cline c9e2a38bf4 ci: change model from gpt-5.4-nano to gpt-5.4-mini (#31695) 2026-06-10 09:14:27 -05:00
Dax Raad 5863e1254c put fff behind flag 2026-06-10 10:13:54 -04:00
opencode 4c9abff445 sync release versions for v1.17.1 2026-06-10 11:37:58 +00:00
opencode-agent[bot] 2cf68f32b5 chore: generate 2026-06-10 10:34:23 +00:00
Luke Parker 90fb32be30 fix(core): accept deprecated reference config key (#31659) 2026-06-10 10:32:35 +00:00
Frank 97e713e8aa zen: deepseek v4 pro 2026-06-10 04:05:03 -04:00
opencode-agent[bot] 826419127a chore: update nix node_modules hashes 2026-06-10 06:07:15 +00:00
Luke Parker e0449c0b96 fix(desktop): restore macOS auto-updates (#31621) 2026-06-10 05:49:11 +00:00
opencode-agent[bot] 5e342f7114 chore: update nix node_modules hashes 2026-06-10 05:00:09 +00:00
Aiden Cline 174ab58343 fix(mcp): apply timeouts to prompts and resources (#31612) 2026-06-09 23:58:40 -05:00
Aiden Cline 954d618790 fix(opencode): support Anthropic fallback responses (#31611) 2026-06-09 23:46:01 -05:00
Aiden Cline 91073360c6 fix(mcp): make client creation failure-safe (#31595) 2026-06-09 23:24:41 -05:00
Dax 8a2cfc00c9 feat(core): add project reference guidance (#31601) 2026-06-10 04:08:26 +00:00
Brendan Allan 0fc33e2a06 feat(app): /new-session route for new design (#31457) 2026-06-10 11:35:50 +08:00
opencode 6c6ed68b5a sync release versions for v1.17.0 2026-06-10 03:12:32 +00:00
opencode-agent[bot] e9106efbdc chore: update nix node_modules hashes 2026-06-10 02:47:36 +00:00
Dmitriy Kovalenko e9e2612706 chore: Update fff to 0.9.4 (#31583) 2026-06-09 22:28:34 -04:00
opencode-agent[bot] 4ae468ff06 chore: generate 2026-06-10 02:12:40 +00:00
Dax Raad 785918262a fix(core): expose partial filesystem scan results 2026-06-09 22:10:53 -04:00
opencode-agent[bot] be8fee5c2b chore: generate 2026-06-10 02:05:59 +00:00
Dax Raad 4597c687fa fix(core): prefer shorter paths for tied search scores 2026-06-09 22:04:17 -04:00
Dax c4aa049042 fix(tui): let gutter replace current marker (#31586) 2026-06-09 21:07:11 -04:00
opencode-agent[bot] 1d46b5c33c chore: update nix node_modules hashes 2026-06-10 00:51:11 +00:00
opencode-agent[bot] 6ae6f0fe8f chore: generate 2026-06-10 00:39:58 +00:00
Dax a0409e64d8 refactor(core): unify filesystem search service (#31566) 2026-06-09 20:38:02 -04:00
opencode-agent[bot] ce4e658e3f chore: update nix node_modules hashes 2026-06-09 23:55:49 +00:00
opencode-agent[bot] 215fb39fc5 chore: generate 2026-06-09 23:36:28 +00:00
Luke Parker 1cc94bb23e fix(desktop): update Electron stack and panel layout (#31571) 2026-06-09 23:34:35 +00:00
Frank bc42187c58 zen: update email 2026-06-09 19:31:54 -04:00
Songchao Wang 80c0b06980 feat: add X-Session-Id header for proxy cache routing affinity (#31511) 2026-06-09 17:01:17 -05:00
Aiden Cline 381eabb970 refactor(mcp): simplify service helpers (#31549) 2026-06-09 16:59:24 -05:00
278 changed files with 12233 additions and 8232 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/gpt-5.4-nano
model: opencode/gpt-5.4-mini
color: "#44BA81"
tools:
"*": false
+9 -2
View File
@@ -2,8 +2,15 @@
"$schema": "https://opencode.ai/config.json",
"provider": {},
"permission": {},
"reference": {
"effect": "github.com/Effect-TS/effect-smol",
"references": {
"effect": {
"repository": "github.com/Effect-TS/effect-smol",
"description": "Use for Effect v4 and effect-smol implementation details",
},
"opencode-local": {
"path": "~/.local/share/opencode",
"description": "Contains opencode logs and data",
},
},
"mcp": {},
"tools": {
+6
View File
@@ -2,6 +2,12 @@
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
Examples: `session-recovery`, `fix-scroll-state`, `regenerate-sdk`.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
+213 -87
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.16.2",
"version": "1.17.3",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -110,7 +110,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -146,7 +146,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -173,9 +173,9 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openauthjs/openauth": "0.0.0-20250322224806",
@@ -195,7 +195,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -219,7 +219,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -239,14 +239,14 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.16.2",
"version": "1.17.3",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
@@ -268,7 +268,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@ff-labs/fff-bun": "0.9.3",
"@ff-labs/fff-bun": "0.9.4",
"@lydell/node-pty": "catalog:",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
@@ -288,7 +288,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.8.0",
"gitlab-ai-provider": "6.9.3",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -330,14 +330,14 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
"electron-store": "11.0.2",
"electron-updater": "6.8.9",
"electron-window-state": "^5.0.3",
"marked": "^15",
},
@@ -356,8 +356,8 @@
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"@valibot/to-json-schema": "1.6.0",
"electron": "41.2.1",
"electron-builder": "^26",
"electron": "42.3.3",
"electron-builder": "26.15.2",
"electron-vite": "^5",
"solid-js": "catalog:",
"sury": "11.0.0-alpha.4",
@@ -384,7 +384,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -398,7 +398,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"effect": "catalog:",
},
@@ -410,7 +410,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -441,7 +441,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -457,7 +457,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "0.0.0",
"version": "1.17.3",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -476,7 +476,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -494,7 +494,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.16.2",
"version": "1.17.3",
"bin": {
"opencode": "./bin/opencode",
},
@@ -504,7 +504,7 @@
"@agentclientprotocol/sdk": "0.21.0",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
@@ -525,6 +525,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@ff-labs/fff-bun": "0.9.4",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@octokit/graphql": "9.0.2",
@@ -563,7 +564,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.8.0",
"gitlab-ai-provider": "6.9.3",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -575,7 +576,7 @@
"minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.1",
"opencode-gitlab-auth": "2.1.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "catalog:",
"partial-json": "0.1.7",
@@ -621,7 +622,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -659,7 +660,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -674,7 +675,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -688,7 +689,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -701,7 +702,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -734,7 +735,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -753,7 +754,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -793,7 +794,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "0.0.0",
"version": "1.17.3",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -802,7 +803,6 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@solid-primitives/scheduled": "1.5.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
@@ -821,7 +821,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -870,7 +870,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -974,7 +974,7 @@
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"opentui-spinner": "0.0.6",
"opentui-spinner": "0.0.7",
"remeda": "2.26.0",
"remend": "1.3.0",
"semver": "7.7.4",
@@ -1013,7 +1013,7 @@
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
@@ -1281,6 +1281,8 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.12.0", "", {}, "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA=="],
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.12.0", "", { "dependencies": { "@bufbuild/protobuf": "2.12.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow=="],
@@ -1333,7 +1335,7 @@
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
"@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="],
"@electron/get": ["@electron/get@5.0.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^3.0.0", "graceful-fs": "^4.2.11", "progress": "^2.0.3", "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "optionalDependencies": { "undici": "^7.24.4" } }, "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA=="],
"@electron/notarize": ["@electron/notarize@2.5.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.1", "promise-retry": "^2.0.1" } }, "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A=="],
@@ -1445,23 +1447,23 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-isGuuEbAo7D6psAllm4+TRONxmDfhlmm548IjsG5hEH4I/pwTTTtrRg4lpMDwQ/cD5I3kEL2KVEYdlwuyFod8w=="],
"@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vJMyCHtE5/CqCmvH7kEDSkUK9/YImoGZuIrRd6yLBjpSTtwyr0QIYjXDsFSj8a4eyxP3ieZWBw9z+uekPZ4YHw=="],
"@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-bapVTzIJZ40WmGYpAN+X3hIOqeynNTH1WPTp6S2pDMj6WQIG0lO4zWboNRAhVxIdsBq7vJwiBm4BKN+8Wp4wzg=="],
"@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-opdtJbCmDB/SjHx+IaM6DF6UpYUZ8saXbwiAHamqg8ywhBWQoGzTo66BBwbaf6kd7sv7hJsYrUVBqLJhZGfL4A=="],
"@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-74kucsnuCsp0daZQGtg0YYJL8h8ypt/efzSQjEuja2GPLdZrW9zVO1p+EWP9FZIt0bAf1o71W3PWjSEa3dTLUQ=="],
"@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.3", "", { "os": "linux", "cpu": "x64" }, "sha512-zgbzi24qWaE1l8bFweApM8Zd1ymxfP5tf9yX9k+PqmOGdGQhGWwbWTxB6UCUu+BiLPd+78Lxzp4oIBoSsZzejA=="],
"@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2ZB3LgEXWY0BJVpN6zr2JeuGYQbOZhNVZYYkKGY9g48L/nUuuB2X1HzQTLQ0zPipmFoPG7dUFlTjl+qmQhJPRw=="],
"@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.3", "", { "os": "win32", "cpu": "x64" }, "sha512-K6PycT3FluRUEtOqsySbq8oHxP8XeyvdWtnxMlnaSSLc5LKlWg3CKvc+kxfq7UkpySA9LlPk+Qp/C1IvJ890QA=="],
"@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.3", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.3", "@ff-labs/fff-bin-darwin-x64": "0.9.3", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.3", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.3", "@ff-labs/fff-bin-linux-x64-musl": "0.9.3", "@ff-labs/fff-bin-win32-arm64": "0.9.3", "@ff-labs/fff-bin-win32-x64": "0.9.3" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-PPSsmSf1+xD/8eLelBDYFcmlmQUPRCm+GO4K/PgtuLtLu0CWsoxyStykgjw+0GP3bTUVNdHK1FYwESk0hmY6lg=="],
"@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
@@ -1675,6 +1677,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -2109,6 +2113,14 @@
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.7.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg=="],
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.18", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-7ZF3YD9fxdbYsPnltz5cUqHacN7ztp8RX/fJLxwv8wIEORpP4+7dHz1h/qx3o4EW2xUrIhmbM8ImywLasB787Q=="],
"@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="],
@@ -2343,7 +2355,7 @@
"@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="],
@@ -2769,6 +2781,8 @@
"@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/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="],
"@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/mocker": ["@vitest/mocker@4.1.7", "", { "dependencies": { "@vitest/spy": "4.1.7", "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-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA=="],
@@ -2853,7 +2867,7 @@
"app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
"app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
"app-builder-lib": ["app-builder-lib@26.15.2", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.2", "electron-builder-squirrel-windows": "26.15.2" } }, "sha512-3mYfKOjr/ZY7gFESOcq8kylBMgGPpmlQYnpBVit4p6zIg0t/8bkWBILdMMtnjFyN2jllyBf225T8dLlz3D6oBQ=="],
"archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="],
@@ -2881,12 +2895,16 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg=="],
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
@@ -2917,6 +2935,8 @@
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="],
@@ -2975,6 +2995,8 @@
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
@@ -3007,9 +3029,9 @@
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"builder-util": ["builder-util@26.15.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-dUx+HxVbiNsNQ4mGe1PyoC/tBmsHwBNDLdBuqWCj+rhHFE9lHgrXiGYKAM1uNlznhAaUSyMlms84VeSSr3gOBA=="],
"builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
@@ -3021,6 +3043,8 @@
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
@@ -3133,7 +3157,7 @@
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
"conf": ["conf@14.0.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^9.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.4.0" } }, "sha512-L6BuueHTRuJHQvQVc6YXYZRtN5vJUtOdCTLn0tRYYV5azfbAFcPghB5zEE40mVrV6w7slMTqUfkDomutIK14fw=="],
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
@@ -3153,7 +3177,7 @@
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
@@ -3281,7 +3305,7 @@
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
"dmg-builder": ["dmg-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-fMkjRqKyPtsz4Kzu/qGP0BGjqzMCIgp+/7kw/u6YH6lvn/8hvL3c0TXhoFayBoYdpPCnEinnCHztd4bW7/jetA=="],
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
@@ -3315,6 +3339,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
@@ -3327,9 +3353,9 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="],
"electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="],
"electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="],
"electron-builder": ["electron-builder@26.15.2", "", { "dependencies": { "app-builder-lib": "26.15.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-veKM9+dCljaC5A74Pwc0ZWQ9arOHREXWh9hUIf8NGg49ch7x+IB4QhbMzIrV5ONZIXM2OEkaxW11cAPjPtoi4A=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="],
@@ -3341,13 +3367,13 @@
"electron-log": ["electron-log@5.4.4", "", {}, "sha512-istWgaXjBfURBSS8LWVW9C3jsc6+ac+tY1lXrQEOTp0lVj+a4OlO1Tmqb36GgnEUDv92DGC9VI1HNXwJinWpgA=="],
"electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-publish": ["electron-publish@26.15.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg=="],
"electron-store": ["electron-store@10.1.0", "", { "dependencies": { "conf": "^14.0.0", "type-fest": "^4.41.0" } }, "sha512-oL8bRy7pVCLpwhmXy05Rh/L6O93+k9t6dqSw0+MckIc3OmCTZm6Mp04Q4f/J0rtu84Ky6ywkR8ivtGOmrq+16w=="],
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.364", "", {}, "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw=="],
"electron-updater": ["electron-updater@6.8.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
"electron-vite": ["electron-vite@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^6.7.14", "esbuild": "^0.25.11", "magic-string": "^0.30.19", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ=="],
@@ -3363,6 +3389,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io-client": ["engine.io-client@6.6.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.20.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg=="],
@@ -3373,7 +3401,7 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
@@ -3603,7 +3631,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -3883,6 +3911,12 @@
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
@@ -4051,6 +4085,8 @@
"magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"make-fetch-happen": ["make-fetch-happen@15.0.6", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/agent": "^4.0.0", "@npmcli/redact": "^4.0.0", "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^6.0.0", "ssri": "^13.0.0" } }, "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw=="],
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
@@ -4295,6 +4331,8 @@
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
"node-releases": ["node-releases@2.0.46", "", {}, "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ=="],
@@ -4363,13 +4401,13 @@
"opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.1.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-ZCDYaY0V8Se6hOH2tqZqqcskrd0xLTgfiGhU0J1igkUP52oFtN9eSwxOPLT0ctvNXUq8b+zOmJ4sskAQoC/IUA=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
@@ -4489,6 +4527,8 @@
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
@@ -4571,6 +4611,10 @@
"pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="],
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
"qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
@@ -4977,6 +5021,8 @@
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
@@ -5171,6 +5217,8 @@
"unzip-stream": ["unzip-stream@0.3.4", "", { "dependencies": { "binary": "^0.3.0", "mkdirp": "^0.5.1" } }, "sha512-PyofABPVv+d7fL7GOpusx7eRT9YETY2X04PhwbSipdj6bMxVCFJrr+nm0Mxqbf9hUiTin/UsnuFWBXlDZFy0Cw=="],
"unzipper": ["unzipper@0.12.3", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "^11.2.0", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -5261,6 +5309,8 @@
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-sources": ["webpack-sources@3.5.0", "", {}, "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ=="],
@@ -5385,7 +5435,9 @@
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5627,9 +5679,7 @@
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
"@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -5743,8 +5793,6 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/core/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -5759,8 +5807,6 @@
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -5779,8 +5825,6 @@
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@poppinss/dumper/@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="],
@@ -5857,6 +5901,10 @@
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="],
"@vitest/coverage-v8/magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"@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=="],
@@ -5899,6 +5947,8 @@
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"ast-v8-to-istanbul/js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
@@ -5937,9 +5987,7 @@
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
"conf/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
@@ -5951,8 +5999,6 @@
"dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"dmg-builder/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -5961,6 +6007,8 @@
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
"duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
@@ -5971,16 +6019,24 @@
"electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"electron-builder-squirrel-windows/app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="],
"electron-builder-squirrel-windows/builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
"electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-store/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
"electron-updater/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-updater/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
"encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6017,6 +6073,8 @@
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"got/@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -6027,6 +6085,8 @@
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
"istanbul-reports/html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
@@ -6063,6 +6123,8 @@
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -6073,8 +6135,6 @@
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -6083,6 +6143,8 @@
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
"opencode-gitlab-auth/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=="],
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
@@ -6099,6 +6161,8 @@
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
@@ -6185,12 +6249,16 @@
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
"unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
"venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
"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.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
@@ -6347,8 +6415,6 @@
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
@@ -6597,6 +6663,8 @@
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
@@ -6619,6 +6687,8 @@
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -6657,6 +6727,8 @@
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"conf/dot-prop/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
@@ -6667,8 +6739,38 @@
"dmg-license/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"duplexer2/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"electron-builder-squirrel-windows/app-builder-lib/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"electron-builder-squirrel-windows/app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
"electron-builder-squirrel-windows/app-builder-lib/dmg-builder": ["dmg-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg=="],
"electron-builder-squirrel-windows/app-builder-lib/electron-publish": ["electron-publish@26.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"electron-builder-squirrel-windows/app-builder-lib/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-builder-squirrel-windows/app-builder-lib/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"electron-builder-squirrel-windows/app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"electron-builder-squirrel-windows/builder-util/builder-util-runtime": ["builder-util-runtime@9.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
"electron-builder-squirrel-windows/builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-builder-squirrel-windows/builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"electron-builder/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"electron-builder/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -6699,8 +6801,6 @@
"js-beautify/nopt/abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
"lazystream/readable-stream/core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"lazystream/readable-stream/isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
@@ -6711,6 +6811,8 @@
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
@@ -6741,6 +6843,8 @@
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
"venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6995,6 +7099,26 @@
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"electron-builder-squirrel-windows/app-builder-lib/dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"electron-builder-squirrel-windows/app-builder-lib/electron-publish/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"electron-builder-squirrel-windows/app-builder-lib/electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"electron-builder-squirrel-windows/app-builder-lib/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"electron-builder-squirrel-windows/app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"electron-builder-squirrel-windows/builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"electron-builder/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"electron-builder/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -7081,6 +7205,8 @@
"babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "gitlab-ai-provider", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2MWaL8yxBR7Lw7XH8TY2V11fqro3Ncn0+q/iEpOiX6g=",
"aarch64-linux": "sha256-IoiotWekTroxCFOjQPMteWkY26O+hAZnQtxHY/svJxQ=",
"aarch64-darwin": "sha256-hky+dzAQBENArDz7Hr1kfj+bbWjTySiHGK2LrRBKt2k=",
"x86_64-darwin": "sha256-tIO18GIjboqv0rWlsbhQ0JNSmzTFswVNUHuE7ZHFxzo="
"x86_64-linux": "sha256-u3CBUflGrE1unVRTvvLKaDkvx5ZHXyRLd/SJOUUsRdc=",
"aarch64-linux": "sha256-hUu70IlOXXQ9apQe3IPOdQGf3ZSVCpvRuAWSKxipk/k=",
"aarch64-darwin": "sha256-N6Mqmo94q0rsskLcXoUGWCMJjQ9wCuuo0Lby3j6+Dps=",
"x86_64-darwin": "sha256-5691VKK2zWqAwe94CCjZuRjXEaswedevFtHxensVLfg="
}
}
+2 -1
View File
@@ -52,7 +52,7 @@
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.18",
"opentui-spinner": "0.0.6",
"opentui-spinner": "0.0.7",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
@@ -140,6 +140,7 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.17.3",
"description": "",
"type": "module",
"exports": {
+84 -8
View File
@@ -9,7 +9,7 @@ import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
@@ -43,25 +43,88 @@ import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider } from "@/context/tabs"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout from "@/pages/directory-layout"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const NewSession = lazy(() => import("@/pages/new-session"))
const SessionRoute = Object.assign(
() => (
<SessionProviders>
<Session />
</SessionProviders>
),
() => {
const settings = useSettings()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const sdk = useSDK()
const server = useServer()
const tabs = useTabs()
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
// is replaced by a draft at /new-session?draftId=…
createEffect(() => {
if (!settings.general.newLayoutDesigns()) return
if (params.id || search.draftId) return
if (!tabs.ready() || !sdk.directory) return
tabs.newDraft({ server: server.key, directory: sdk.directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
},
{ preload: Session.preload },
)
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
return (
<Show when={tabs.ready()}>
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
</Show>
</Show>
)
}
function ResolvedDraftRoute(props: { draftID: string }) {
const server = useServer()
const tabs = useTabs()
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID),
)
createEffect(() => {
const current = draft()
if (current && current.server !== server.key) server.setActive(current.server)
})
// Key on the directory so retargeting the draft's project re-instantiates the
// SDK/data providers for the new directory while keeping the same draft id.
const directory = () => draft()?.directory
return (
<Show when={directory()} keyed>
{(dir) => (
<SDKProvider directory={dir}>
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
)}
</Show>
)
}
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -141,6 +204,18 @@ function SessionProviders(props: ParentProps) {
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
return (
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
@@ -335,6 +410,7 @@ export function AppInterface(props: {
)}
>
<Route path="/" component={HomeRoute} />
<Route path="/new-session" component={DraftRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
@@ -261,11 +261,7 @@ function createSessionEntries(props: {
return { sessions }
}
export function DialogSelectFile(props: {
mode?: DialogSelectFileMode
onOpenFile?: (path: string) => void
onSelectFile?: (path: string) => void
}) {
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
const layout = useLayout()
@@ -379,10 +375,6 @@ export function DialogSelectFile(props: {
}
if (!item.path) return
if (props.onSelectFile) {
props.onSelectFile(item.path)
return
}
open(item.path)
}
@@ -8,6 +8,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@/context/file", () => ({
useFile: () => ({
+26 -30
View File
@@ -31,9 +31,10 @@ import {
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { useNavigate, useSearchParams } from "@solidjs/router"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
@@ -52,7 +53,6 @@ import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { serverAttachmentFile } from "./prompt-input/server-attachment"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
@@ -144,6 +144,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const settings = useSettings()
const tabsStore = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const { params, tabs, view } = useSessionLayout()
let editorRef!: HTMLDivElement
let fileInputRef: HTMLInputElement | undefined
@@ -470,34 +472,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => {
if (server.isLocal()) {
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
return
}
void import("@/components/dialog-select-file").then((module) =>
dialog.show(() => (
<module.DialogSelectFile
mode="files"
onSelectFile={(path) => {
void sdk.client.v2.fs
.read({ path })
.then((response) => response.data?.data)
.then((data) => data && addAttachments([serverAttachmentFile(path, data)]))
}}
/>
)),
)
pickAttachmentFiles({
picker: platform.openAttachmentPickerDialog,
directory: () => sdk.directory,
fallback: () => fileInputRef?.click(),
onFile: addAttachment,
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
})
}
const setMode = (mode: "normal" | "shell") => {
@@ -1398,6 +1384,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
layout.projects.open(worktree)
server.projects.touch(worktree)
// On the draft route, retarget the existing draft in place so we keep the same
// draft id (and its tab/prompt) instead of spawning a new draft for the new directory.
const draftID = search.draftId
if (draftID) {
tabsStore.updateDraft(draftID, { server: server.key, directory: worktree })
restoreFocus()
return
}
navigate(`/${base64Encode(worktree)}/session`)
}
const addProject = () => {
@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test"
import { serverAttachmentFile } from "./server-attachment"
describe("serverAttachmentFile", () => {
test("creates a file from server text content", async () => {
const file = serverAttachmentFile("docs/readme.txt", {
uri: "file:///docs/readme.txt",
name: "readme.txt",
content: "hello",
encoding: "utf8",
mime: "text/plain",
})
expect(file.name).toBe("readme.txt")
expect(file.type).toBe("text/plain")
expect(await file.text()).toBe("hello")
})
test("creates a file from server base64 content", async () => {
const file = serverAttachmentFile("images/pixel.png", {
uri: "file:///images/pixel.png",
name: "pixel.png",
content: "aGVsbG8=",
encoding: "base64",
mime: "image/png",
})
expect(file.name).toBe("pixel.png")
expect(file.type).toBe("image/png")
expect(await file.text()).toBe("hello")
})
})
@@ -1,8 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSystemContent } from "@opencode-ai/sdk/v2"
export function serverAttachmentFile(path: string, data: FileSystemContent) {
const content =
data.encoding === "utf8" ? data.content : Uint8Array.from(atob(data.content), (char) => char.charCodeAt(0))
return new File([content], getFilename(path), { type: data.mime })
}
@@ -61,6 +61,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => params,
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/sdk/v2/client", () => ({
@@ -103,6 +105,16 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/server", () => ({
useServer: () => ({ key: "server-key" }),
}))
mock.module("@/context/tabs", () => ({
useTabs: () => ({
promoteDraft: () => undefined,
}),
}))
mock.module("@/context/prompt", () => ({
usePrompt: () => ({
current: () => promptValue,
@@ -2,9 +2,11 @@ import type { Message, Session } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServer } from "@/context/server"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
@@ -213,6 +215,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const server = useServer()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const errorMessage = (err: unknown) => {
@@ -381,7 +386,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
const draftID = search.draftId
if (draftID)
tabs.promoteDraft(draftID, {
server: server.key,
dirBase64: base64Encode(sessionDirectory),
sessionId: session.id,
})
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
if (!session) {
+79 -6
View File
@@ -280,7 +280,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "dir-new-sesssion") {
if (route.type === "draft") {
return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID)
}
if (route.type === "session") {
const main = tabsStore.find(
@@ -447,13 +448,33 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
refreshTabsAreOverflowing()
})
if (tab.type !== "session") return null
const divider = () =>
i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)
if (tab.type === "draft") {
return (
<>
{divider()}
<DraftTabItem
ref={ref}
href={tabHref(tab)}
title={language.t("command.session.new")}
active={currentTab() === tab}
onNavigate={() => {
navigateTab(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
onClose={() => tabsStoreActions.removeTab(i())}
/>
</>
)
}
return (
<>
{i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
{divider()}
<TabNavItem
ref={ref}
href={tabHref(tab)}
@@ -784,7 +805,6 @@ function TabNavItem(props: {
>
<Show when={session.latest}>
{(session) => {
console.log({ session: session() })
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
@@ -853,6 +873,59 @@ function ProjectTabAvatar(props: {
)
}
function DraftTabItem(props: {
ref?: HTMLDivElement
href: string
title: string
active?: boolean
onNavigate: () => void
onClose: () => void
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}
return (
<div
ref={props.ref}
data-active={props.active}
class="group relative shrink-0 flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
>
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -8,6 +8,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+9 -2
View File
@@ -77,6 +77,7 @@ export type ReviewDiffStyle = "unified" | "split"
export type LayoutRoute =
| { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key }
@@ -120,10 +121,16 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
}
}
const currentRoute = (pathname: string): LayoutRoute => {
const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
if (!draftID) return { type: "home" }
return { type: "draft", draftID }
}
const dirBase64 = parts[0]
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
@@ -145,7 +152,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname)
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
return { ...value, server: server.key }
})
+6
View File
@@ -375,6 +375,12 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (event.type === "provider.models.updated") {
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
}
if (directory === "global") {
applyGlobalEvent({
event,
+16 -9
View File
@@ -5,7 +5,7 @@ import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
@@ -65,6 +65,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const closing = new Set<string>()
@@ -123,14 +124,20 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
const active = `${location.pathname}${location.search}` === draftHref(draftID)
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session }
}),
)
if (active) navigateTab({ type: "session", ...session })
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
// replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session }
}),
)
if (active) navigateTab({ type: "session", ...session })
})
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
@@ -9,6 +9,8 @@ beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
useLocation: () => ({}),
useSearchParams: () => [{}, () => undefined],
}))
mock.module("@opencode-ai/ui/context", () => ({
createSimpleContext: () => ({
+3 -1
View File
@@ -10,7 +10,7 @@ import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) {
const location = useLocation()
const navigate = useNavigate()
const params = useParams()
@@ -18,6 +18,8 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
if (props.draftID) return
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
+78
View File
@@ -0,0 +1,78 @@
import { createEffect, createMemo, onMount, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useSearchParams } from "@solidjs/router"
import { NewSessionDesignView } from "@/components/session"
import { useComments } from "@/context/comments"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
* composer for a brand-new session — no terminal, review pane, file tree, or message
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
*/
export default function NewSessionPage() {
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const comments = useComments()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
let inputRef: HTMLDivElement | undefined
const composer = createSessionComposerState()
const [store, setStore] = createStore({
worktree: "main",
})
const newSessionWorktree = createMemo(() => {
if (store.worktree === "create") return "create"
const project = sync.project
if (project && sdk.directory !== project.worktree) return sdk.directory
return "main"
})
createEffect(() => {
if (!prompt.ready()) return
untrack(() => {
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
setSearchParams({ ...searchParams, prompt: undefined })
})
})
onMount(() => {
requestAnimationFrame(() => inputRef?.focus())
})
return (
<div class="relative size-full overflow-hidden flex flex-col">
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
<NewSessionDesignView>
<SessionComposerRegion
state={composer}
ready
centered={false}
placement="inline"
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
onSubmit={() => comments.clear()}
onResponseSubmit={() => {}}
setPromptDockRef={() => {}}
/>
</NewSessionDesignView>
</div>
</div>
</div>
</div>
)
}
+17 -14
View File
@@ -31,13 +31,14 @@ import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
@@ -51,6 +52,7 @@ import {
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
@@ -61,7 +63,6 @@ import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { shouldUseV2NewSessionPage } from "@/pages/session/new-session-layout"
import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
@@ -194,6 +195,7 @@ export default function Page() {
const sdk = useSDK()
const serverSDK = useServerSDK()
const settings = useSettings()
const platform = usePlatform()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
@@ -268,10 +270,16 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: platform.platform === "desktop" && settings.general.newLayoutDesigns(),
showFileTree: settings.general.showFileTree(),
opened: layout.fileTree.opened(),
}),
)
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
const sessionPanelWidth = createMemo(() => {
if (!desktopSidePanelOpen()) return "100%"
@@ -1745,10 +1753,9 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none": true,
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active() && !ui.reviewSnap,
"transition-[width]": !isV2NewSessionPage(),
}}
style={{
width: sessionPanelWidth(),
@@ -1756,9 +1763,7 @@ export default function Page() {
>
<div
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-deep": isV2NewSessionPage(),
"bg-background-stronger": !isV2NewSessionPage(),
"flex-1 min-h-0 flex flex-col bg-background-stronger": true,
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
@@ -1814,9 +1819,7 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
@@ -8,8 +8,17 @@ import {
focusTerminalById,
getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
shouldShowFileTree,
} from "./helpers"
describe("shouldShowFileTree", () => {
test("does not reserve space for a disabled v2 file tree", () => {
expect(shouldShowFileTree({ desktopV2: true, showFileTree: false, opened: true })).toBe(false)
expect(shouldShowFileTree({ desktopV2: false, showFileTree: false, opened: true })).toBe(true)
expect(shouldShowFileTree({ desktopV2: true, showFileTree: true, opened: true })).toBe(true)
})
})
describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => {
const calls: string[] = []
@@ -20,6 +20,10 @@ type TabsInput = {
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
export function shouldShowFileTree(input: { desktopV2: boolean; showFileTree: boolean; opened: boolean }) {
return input.opened && (!input.desktopV2 || input.showFileTree)
}
export const createSessionTabs = (input: TabsInput) => {
const review = input.review ?? (() => false)
const hasReview = input.hasReview ?? (() => false)
@@ -1,14 +0,0 @@
import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
})
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,6 +1,2 @@
/** Inline new-session content width — keep in sync with session composer `placement === "inline"`. */
export const NEW_SESSION_CONTENT_WIDTH = "w-full max-w-[720px] px-0"
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
}
@@ -24,7 +24,13 @@ import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
import {
createOpenSessionFileTab,
createSessionTabs,
getTabReorderIndex,
shouldShowFileTree,
type Sizing,
} from "@/pages/session/helpers"
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
@@ -59,10 +65,18 @@ export function SessionSidePanel(props: {
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const shown = createMemo(() => !desktopV2() || settings.general.showFileTree())
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const fileOpen = createMemo(
() =>
isDesktop() &&
shouldShowFileTree({
desktopV2: desktopV2(),
showFileTree: settings.general.showFileTree(),
opened: layout.fileTree.opened(),
}),
)
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.16.2",
"version": "1.17.3",
"type": "module",
"license": "MIT",
"bin": {
+2
View File
@@ -92,6 +92,8 @@ for (const item of targets) {
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.16.2",
"version": "1.17.3",
"type": "module",
"license": "MIT",
"scripts": {
+9 -14
View File
@@ -13,10 +13,9 @@ import copyLogoSvgLight from "../asset/lander/opencode-logo-light.svg"
import copyLogoSvgDark from "../asset/lander/opencode-logo-dark.svg"
import copyWordmarkSvgLight from "../asset/lander/opencode-wordmark-light.svg"
import copyWordmarkSvgDark from "../asset/lander/opencode-wordmark-dark.svg"
import { A, createAsync, useNavigate } from "@solidjs/router"
import { A, useNavigate } from "@solidjs/router"
import { createMemo, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import { config } from "~/config"
import { useI18n } from "~/context/i18n"
@@ -40,16 +39,6 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
const navigate = useNavigate()
const i18n = useI18n()
const language = useLanguage()
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
? new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 0,
}).format(githubData()?.stars)
: config.github.starsFormatted.compact,
)
const [store, setStore] = createStore({
mobileMenuOpen: false,
@@ -155,12 +144,15 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
<ul>
<li>
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
{i18n.t("nav.github")} <span>[{starCount()}]</span>
{i18n.t("nav.github")}
</a>
</li>
<li>
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
</li>
<li>
<a href={language.route("/data")}>{i18n.t("nav.data")}</a>
</li>
<li>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
</li>
@@ -252,12 +244,15 @@ export function Header(props: { zen?: boolean; go?: boolean; hideGetStarted?: bo
</li>
<li>
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
{i18n.t("nav.github")} <span>[{starCount()}]</span>
{i18n.t("nav.github")}
</a>
</li>
<li>
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
</li>
<li>
<a href={language.route("/data")}>{i18n.t("nav.data")}</a>
</li>
<Show when={!props.zen}>
<li>
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
+1
View File
@@ -1,6 +1,7 @@
export const dict = {
"nav.github": "GitHub",
"nav.docs": "Docs",
"nav.data": "Data",
"nav.changelog": "Changelog",
"nav.discord": "Discord",
"nav.x": "X",
+5 -1
View File
@@ -10,7 +10,11 @@ export async function statsProxy(evt: APIEvent) {
targetUrl.hostname = Resource.App.stage === "production" ? "stats.opencode.ai" : "stats.dev.opencode.ai"
targetUrl.port = ""
if (targetUrl.pathname.startsWith(`${dataPath}/_build/`) || targetUrl.pathname === `${dataPath}/banner.jpg`) {
if (
targetUrl.pathname.startsWith(`${dataPath}/_build/`) ||
targetUrl.pathname === `${dataPath}/banner.jpg` ||
targetUrl.pathname === `${dataPath}/banner.png`
) {
targetUrl.pathname = targetUrl.pathname.slice(dataPath.length)
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.16.2",
"version": "1.17.3",
"private": true,
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.16.2",
"version": "1.17.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -17,7 +17,7 @@
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@opencode-ai/console-core": "workspace:*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.16.2",
"version": "1.17.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.16.2",
"version": "1.17.3",
"type": "module",
"license": "MIT",
"scripts": {
@@ -0,0 +1,12 @@
CREATE TABLE `credential` (
`id` text PRIMARY KEY,
`connector_id` text NOT NULL,
`method_id` text NOT NULL,
`label` text NOT NULL,
`value` text NOT NULL,
`active` integer DEFAULT false NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `credential_connector_active_idx` ON `credential` (`connector_id`) WHERE "credential"."active" = 1;
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.16.2",
"version": "1.17.3",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -62,7 +62,7 @@
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
@@ -85,7 +85,7 @@
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@lydell/node-pty": "catalog:",
"@ff-labs/fff-bun": "0.9.3",
"@ff-labs/fff-bun": "0.9.4",
"@npmcli/arborist": "9.4.0",
"@npmcli/config": "10.8.1",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
@@ -104,7 +104,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.8.0",
"gitlab-ai-provider": "6.9.3",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
-340
View File
@@ -1,340 +0,0 @@
export * as Auth from "./auth"
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { FSUtil } from "./fs-util"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("Auth.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = typeof OrgID.Type
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = typeof AccessToken.Type
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = typeof RefreshToken.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("Auth.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("Auth.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "Auth.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("Auth.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("Auth.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly activeAll: () => Effect.Effect<Map<ServiceID, Info>, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* FSUtil.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("Auth.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("Auth.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("Auth.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("Auth.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
activeAll: Effect.fn("Auth.activeAll")(function* () {
const data = yield* SynchronizedRef.get(state)
const result = new Map<ServiceID, Info>()
for (const account of Object.values(data.accounts)) {
if (!result.has(account.serviceID)) result.set(account.serviceID, account)
}
for (const [serviceID, id] of Object.entries(data.active)) {
const account = data.accounts[id]
if (account) result.set(ServiceID.make(serviceID), account)
}
return result
}),
forService: Effect.fn("Auth.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("Auth.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("Auth.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("Auth.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
+43 -18
View File
@@ -10,6 +10,8 @@ import { Location } from "./location"
import { EventV2 } from "./event"
import { Policy } from "./policy"
import { State } from "./state"
import { Credential } from "./credential"
import { ConnectorSchema } from "./connector/schema"
export type ProviderRecord = {
provider: ProviderV2.Info
@@ -94,10 +96,26 @@ export const layer = Layer.effect(
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const policy = yield* Policy.Service
const credentials = yield* Credential.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => {
const provider = state.get().providers.get(model.providerID)!.provider
const project = (provider: ProviderV2.Info, active: Map<ConnectorSchema.ID, Credential.Info>) => {
const credential = active.get(ConnectorSchema.ID.make(provider.id))
if (!credential) return provider
const body = { ...provider.request.body }
if (credential.value.type === "key") {
body.apiKey = credential.value.key
Object.assign(body, credential.value.metadata ?? {})
}
if (credential.value.type === "oauth") body.apiKey = credential.value.access
return new ProviderV2.Info({
...provider,
enabled: { via: "credential", credentialID: credential.id },
request: { ...provider.request, body },
})
}
const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => {
const api =
model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0
? { ...provider.api, id: model.api.id }
@@ -193,8 +211,7 @@ export const layer = Layer.effect(
}
}),
})
const available = (model: ModelV2.Info) =>
state.get().providers.get(model.providerID)?.provider.enabled !== false && model.enabled
const active = () => credentials.activeAll().pipe(Effect.orDie)
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
@@ -214,17 +231,18 @@ export const layer = Layer.effect(
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
return project(record.provider, yield* active())
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
const credentials = yield* active()
return Array.fromIterable(state.get().providers.values()).map((record) =>
project(record.provider, credentials),
)
}),
available: Effect.fn("CatalogV2.provider.available")(function* () {
return Array.fromIterable(state.get().providers.values())
.map((record) => record.provider)
.filter((provider) => provider.enabled)
return (yield* result.provider.all()).filter((provider) => provider.enabled)
}),
},
@@ -233,29 +251,35 @@ export const layer = Layer.effect(
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model)
return resolve(model, project(record.provider, yield* active()))
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
const credentials = yield* active()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.flatMap((record) => Array.fromIterable(record.models.values())),
Array.map(resolve),
Array.flatMap((record) => {
const provider = project(record.provider, credentials)
return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider))
}),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
return (yield* result.model.all()).filter(available)
const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider]))
return (yield* result.model.all()).filter(
(model) => providers.get(model.providerID)?.enabled !== false && model.enabled,
)
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = state.get().providers.get(defaultModel.providerID)?.provider
if (provider?.enabled !== false) {
const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option)
if (Option.isSome(provider) && provider.value.enabled !== false) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && available(model.value)) return model
if (Option.isSome(model) && model.value.enabled) return model
}
}
@@ -269,10 +293,11 @@ export const layer = Layer.effect(
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
const record = state.get().providers.get(providerID)
if (!record) return Option.none<ModelV2.Info>()
const provider = project(record.provider, yield* active())
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider))
}
const candidates = pipe(
@@ -300,7 +325,7 @@ export const layer = Layer.effect(
return pipe(
items,
Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number),
Array.map((item) => resolve(item.model)),
Array.map((item) => resolve(item.model, provider)),
Array.head,
)
}
+3
View File
@@ -6,6 +6,9 @@ import { PositiveInt } from "../schema"
export class Local extends Schema.Class<Local>("ConfigV2.MCP.Local")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
timeout: PositiveInt.pipe(Schema.optional),
@@ -33,11 +33,15 @@ export const Plugin = {
path: AbsolutePath.make(
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
),
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
description: typeof entry === "string" ? undefined : entry.description,
hidden: typeof entry === "string" ? undefined : entry.hidden,
}),
)
}
+4
View File
@@ -5,10 +5,14 @@ import { Schema } from "effect"
export class Git extends Schema.Class<Git>("ConfigV2.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class Local extends Schema.Class<Local>("ConfigV2.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+492
View File
@@ -0,0 +1,492 @@
export * as Connector from "./connector"
import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { Credential } from "./credential"
import { ConnectorSchema } from "./connector/schema"
import { withStatics } from "./schema"
import { State } from "./state"
import { Identifier } from "./util/identifier"
import { KeyedMutex } from "./effect/keyed-mutex"
import { EventV2 } from "./event"
export const ID = ConnectorSchema.ID
export type ID = ConnectorSchema.ID
export const MethodID = ConnectorSchema.MethodID
export type MethodID = ConnectorSchema.MethodID
export const AttemptID = Schema.String.pipe(
Schema.brand("Connector.AttemptID"),
withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })),
)
export type AttemptID = typeof AttemptID.Type
export const When = Schema.Struct({
key: Schema.String,
op: Schema.Literals(["eq", "neq"]),
value: Schema.String,
}).annotate({ identifier: "Connector.When" })
export type When = typeof When.Type
export class TextPrompt extends Schema.Class<TextPrompt>("Connector.TextPrompt")({
type: Schema.Literal("text"),
key: Schema.String,
message: Schema.String,
placeholder: Schema.optional(Schema.String),
when: Schema.optional(When),
}) {}
export class SelectPrompt extends Schema.Class<SelectPrompt>("Connector.SelectPrompt")({
type: Schema.Literal("select"),
key: Schema.String,
message: Schema.String,
options: Schema.Array(
Schema.Struct({
label: Schema.String,
value: Schema.String,
hint: Schema.optional(Schema.String),
}),
),
when: Schema.optional(When),
}) {}
export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type"))
export type Prompt = typeof Prompt.Type
export class OAuthMethod extends Schema.Class<OAuthMethod>("Connector.OAuthMethod")({
id: MethodID,
type: Schema.Literal("oauth"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
export class KeyMethod extends Schema.Class<KeyMethod>("Connector.KeyMethod")({
id: MethodID,
type: Schema.Literal("key"),
label: Schema.String,
prompts: Schema.optional(Schema.Array(Prompt)),
}) {}
export const Method = Schema.Union([OAuthMethod, KeyMethod]).pipe(Schema.toTaggedUnion("type"))
export type Method = typeof Method.Type
export class Info extends Schema.Class<Info>("Connector.Info")({
id: ID,
name: Schema.String,
methods: Schema.Array(Method),
}) {}
export type Inputs = Readonly<{ [key: string]: string }>
export type OAuthAuthorization = {
readonly url: string
readonly instructions: string
} & (
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.Value, unknown>
}
| {
readonly mode: "code"
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
}
)
export interface OAuthImplementation {
readonly connectorID: ID
readonly method: OAuthMethod
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
}
export interface KeyImplementation {
readonly connectorID: ID
readonly method: KeyMethod
readonly authorize: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
}
export type Implementation = OAuthImplementation | KeyImplementation
function isKeyImplementation(implementation: Implementation): implementation is KeyImplementation {
return implementation.method.type === "key"
}
function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation {
return implementation.method.type === "oauth"
}
export class Attempt extends Schema.Class<Attempt>("Connector.Attempt")({
attemptID: AttemptID,
url: Schema.String,
instructions: Schema.String,
mode: Schema.Literals(["auto", "code"]),
time: Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
}),
}) {}
const Time = Schema.Struct({
created: Schema.Number,
expires: Schema.Number,
})
export const AttemptStatus = Schema.Union([
Schema.Struct({ status: Schema.Literal("pending"), time: Time }),
Schema.Struct({ status: Schema.Literal("complete"), time: Time }),
Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }),
Schema.Struct({ status: Schema.Literal("expired"), time: Time }),
]).pipe(Schema.toTaggedUnion("status"))
export type AttemptStatus = typeof AttemptStatus.Type
export class CodeRequiredError extends Schema.TaggedErrorClass<CodeRequiredError>()("Connector.CodeRequired", {
attemptID: AttemptID,
}) {}
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("Connector.Authorization", {
cause: Schema.Defect,
}) {}
export type Error = CodeRequiredError | AuthorizationError
export const Event = {
Updated: EventV2.define({
type: "connector.updated",
schema: {},
}),
}
type Entry = {
connector: Info
implementations: Map<MethodID, Implementation>
}
type Data = {
connectors: Map<ID, Entry>
}
export type Editor = {
list: () => readonly Info[]
get: (id: ID) => Info | undefined
update: (id: ID, update: (connector: Draft<Omit<Info, "methods">>) => void) => void
remove: (id: ID) => void
method: {
update: (implementation: Implementation) => void
remove: (connectorID: ID, methodID: MethodID) => void
}
}
export interface Interface {
/** Registers a scoped transform over the connector registry. */
readonly transform: State.Interface<Data, Editor>["transform"]
/** Registers and immediately applies a scoped connector registry update. */
readonly update: State.Interface<Data, Editor>["update"]
/** Returns one connector with its serializable login methods. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Returns all connectors with their serializable login methods. */
readonly list: () => Effect.Effect<Info[]>
/** Refreshes an OAuth credential with its originating method. */
readonly refresh: (credentialID: Credential.ID) => Effect.Effect<void, AuthorizationError>
readonly connect: {
/** Runs a key method and stores the resulting credential. */
readonly key: (input: {
/** Connector receiving the credential. */
readonly connectorID: ID
/** Key method selected by the caller. */
readonly methodID: MethodID
/** Secret entered by the user. */
readonly key: string
/** Answers to the method's optional prompts. */
readonly inputs: Inputs
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
readonly oauth: {
/** Starts a stateful OAuth attempt. */
readonly begin: (input: {
/** Connector being authenticated. */
readonly connectorID: ID
/** OAuth method selected by the caller. */
readonly methodID: MethodID
/** Answers to the method's optional prompts. */
readonly inputs: Inputs
/** User-facing label for the credential created on completion. */
readonly label?: string
}) => Effect.Effect<Attempt, AuthorizationError>
/** Returns the current state of an OAuth attempt. */
readonly status: (attemptID: AttemptID) => Effect.Effect<AttemptStatus>
/** Completes the attempt and stores its credential. */
readonly complete: (input: {
/** Opaque handle returned by `begin`. */
readonly attemptID: AttemptID
/** Authorization code required by attempts in code mode. */
readonly code?: string
}) => Effect.Effect<void, CodeRequiredError | AuthorizationError>
/** Cancels an attempt and releases its resources. */
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
}
}
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Connector") {}
enableMapSet()
const attemptLifetime = Duration.toMillis(Duration.minutes(10))
const terminalRetention = Duration.toMillis(Duration.minutes(1))
const scrubInterval = Duration.seconds(30)
type AttemptTime = { created: number; expires: number }
type PendingAttempt = {
status: "pending"
completing: boolean
authorization: OAuthAuthorization
connectorID: ID
methodID: MethodID
label?: string
scope: Scope.Closeable
time: AttemptTime
}
type TerminalAttempt = {
status: "complete" | "failed" | "expired"
message?: string
removeAt: number
time: AttemptTime
}
type AttemptEntry = PendingAttempt | TerminalAttempt
export const locationLayer = Layer.effect(
Service,
Effect.gen(function* () {
const credentials = yield* Credential.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const refreshLocks = KeyedMutex.makeUnsafe<Credential.ID>()
const state = State.create<Data, Editor>({
initial: () => ({ connectors: new Map<ID, Entry>() }),
editor: (draft) => ({
list: () => Array.from(draft.connectors.values(), (entry) => entry.connector) as Info[],
get: (id) => draft.connectors.get(id)?.connector as Info | undefined,
update: (id, update) => {
const current =
draft.connectors.get(id) ??
castDraft({ connector: new Info({ id, name: id, methods: [] }), implementations: new Map() })
if (!draft.connectors.has(id)) draft.connectors.set(id, current)
update(current.connector)
current.connector.id = id
},
remove: (id) => draft.connectors.delete(id),
method: {
update: (implementation) => {
const current =
draft.connectors.get(implementation.connectorID) ??
castDraft({
connector: new Info({ id: implementation.connectorID, name: implementation.connectorID, methods: [] }),
implementations: new Map<MethodID, Implementation>(),
})
if (!draft.connectors.has(implementation.connectorID)) {
draft.connectors.set(implementation.connectorID, current)
}
const index = current.connector.methods.findIndex((method) => method.id === implementation.method.id)
if (index === -1) current.connector.methods.push(castDraft(implementation.method))
else current.connector.methods[index] = castDraft(implementation.method)
current.implementations.set(implementation.method.id, castDraft(implementation))
},
remove: (connectorID, methodID) => {
const current = draft.connectors.get(connectorID)
if (!current) return
const index = current.connector.methods.findIndex((method) => method.id === methodID)
if (index !== -1) current.connector.methods.splice(index, 1)
current.implementations.delete(methodID)
},
},
}),
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
const close = (attemptScope: Scope.Closeable) =>
Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
const message = (cause: Cause.Cause<unknown>) => {
const error = Cause.squash(cause)
return error instanceof Error ? error.message : String(error)
}
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.Value, unknown>) {
const now = yield* Clock.currentTimeMillis
const result = yield* SynchronizedRef.modify(attempts, (current) => {
const attempt = current.get(attemptID)
if (!attempt || attempt.status !== "pending") return [undefined, current]
const terminal: TerminalAttempt = Exit.isSuccess(exit)
? { status: "complete", time: attempt.time, removeAt: now + terminalRetention }
: { status: "failed", message: message(exit.cause), time: attempt.time, removeAt: now + terminalRetention }
return [attempt, new Map(current).set(attemptID, terminal)]
})
if (!result) return
if (Exit.isSuccess(exit)) {
yield* credentials.create({
connectorID: result.connectorID,
methodID: result.methodID,
label: result.label,
value: exit.value,
})
}
yield* close(result.scope)
})
const scrub = Effect.fnUntraced(function* () {
const now = yield* Clock.currentTimeMillis
const expired = yield* SynchronizedRef.modify(attempts, (current) => {
const next = new Map(current)
const scopes: Scope.Closeable[] = []
for (const [id, attempt] of current) {
if (attempt.status === "pending" && attempt.time.expires <= now) {
scopes.push(attempt.scope)
next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention })
continue
}
if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id)
}
return [scopes, next]
})
yield* Effect.forEach(expired, close, { discard: true })
})
yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope))
return Service.of({
transform: state.transform,
update: state.update,
get: Effect.fn("Connector.get")(function* (id) {
return state.get().connectors.get(id)?.connector
}),
list: Effect.fn("Connector.list")(function* () {
return Array.from(state.get().connectors.values(), (record) => record.connector).toSorted((a, b) =>
a.name.localeCompare(b.name),
)
}),
refresh: Effect.fn("Connector.refresh")(function* (credentialID) {
yield* refreshLocks.withLock(credentialID)(
Effect.gen(function* () {
const credential = yield* credentials.get(credentialID)
if (!credential || credential.value.type !== "oauth") {
return yield* Effect.die(`OAuth credential not found: ${credentialID}`)
}
const implementation = state
.get()
.connectors.get(credential.connectorID)
?.implementations.get(credential.methodID)
if (!implementation || !isOAuthImplementation(implementation) || !implementation.refresh) {
return yield* Effect.die(
`OAuth refresh method not found: ${credential.connectorID}/${credential.methodID}`,
)
}
const value = yield* authorize(implementation.refresh(credential.value))
yield* credentials.update(credential.id, { value })
}),
)
}),
connect: {
key: Effect.fn("Connector.connect.key")(function* (input) {
const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID)
if (!method || !isKeyImplementation(method)) {
return yield* Effect.die(`Key method not found: ${input.connectorID}/${input.methodID}`)
}
const value = yield* authorize(method.authorize(input.key, input.inputs))
yield* credentials.create({
connectorID: input.connectorID,
methodID: input.methodID,
label: input.label,
value,
})
}),
oauth: {
begin: Effect.fn("Connector.connect.oauth.begin")(function* (input) {
const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID)
if (!method || !isOAuthImplementation(method)) {
return yield* Effect.die(`OAuth method not found: ${input.connectorID}/${input.methodID}`)
}
const attemptScope = yield* Scope.fork(scope)
const authorization = yield* authorize(method.authorize(input.inputs)).pipe(
Scope.provide(attemptScope),
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)),
)
const id = AttemptID.create()
const created = yield* Clock.currentTimeMillis
const time = { created, expires: created + attemptLifetime }
yield* SynchronizedRef.update(attempts, (current) =>
new Map(current).set(id, {
status: "pending",
completing: authorization.mode === "auto",
authorization,
connectorID: input.connectorID,
methodID: input.methodID,
label: input.label,
scope: attemptScope,
time,
}),
)
if (authorization.mode === "auto") {
yield* authorization.callback.pipe(
Effect.exit,
Effect.flatMap((exit) => settle(id, exit)),
Effect.forkIn(attemptScope, { startImmediately: true }),
)
}
return new Attempt({
attemptID: id,
url: authorization.url,
instructions: authorization.instructions,
mode: authorization.mode,
time,
})
}),
status: Effect.fn("Connector.connect.oauth.status")(function* (attemptID) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID)
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`)
if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
}
return { status: attempt.status, time: attempt.time }
}),
complete: Effect.fn("Connector.connect.oauth.complete")(function* (input) {
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(input.attemptID)
if (!match || match.status !== "pending" || match.completing) return [match, current]
if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
})
if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`)
if (attempt.status !== "pending") return
if (attempt.authorization.mode === "code" && input.code === undefined) {
return yield* new CodeRequiredError({ attemptID: input.attemptID })
}
if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`)
const callback =
attempt.authorization.mode === "auto"
? attempt.authorization.callback
: attempt.authorization.callback(input.code as string)
const exit = yield* authorize(callback).pipe(Effect.exit)
yield* settle(input.attemptID, exit)
if (Exit.isFailure(exit)) return yield* exit
}),
cancel: Effect.fn("Connector.connect.oauth.cancel")(function* (attemptID) {
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
const match = current.get(attemptID)
if (!match || match.status !== "pending") return [undefined, current]
const next = new Map(current)
next.delete(attemptID)
return [match, next]
})
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
}),
},
},
})
}),
)
+9
View File
@@ -0,0 +1,9 @@
export * as ConnectorSchema from "./schema"
import { Schema } from "effect"
export const ID = Schema.String.pipe(Schema.brand("Connector.ID"))
export type ID = typeof ID.Type
export const MethodID = Schema.String.pipe(Schema.brand("Connector.MethodID"))
export type MethodID = typeof MethodID.Type
+343
View File
@@ -0,0 +1,343 @@
export * as Credential from "./credential"
import { and, asc, eq, ne } from "drizzle-orm"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Database } from "./database/database"
import { ConnectorSchema } from "./connector/schema"
import { EventV2 } from "./event"
import { NonNegativeInt, withStatics } from "./schema"
import { CredentialTable } from "./credential/sql"
import { Identifier } from "./util/identifier"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { DataMigrationTable } from "./data-migration.sql"
import path from "path"
export const ID = Schema.String.pipe(
Schema.brand("Credential.ID"),
withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export class OAuth extends Schema.Class<OAuth>("Credential.OAuth")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export class Key extends Schema.Class<Key>("Credential.Key")({
type: Schema.Literal("key"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Value = Schema.Union([OAuth, Key])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Credential.Value" })
export type Value = Schema.Schema.Type<typeof Value>
const LegacyOAuth = Schema.Struct({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
accountId: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String),
})
const LegacyKey = Schema.Struct({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey])
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
connectorID: ConnectorSchema.ID,
methodID: ConnectorSchema.MethodID,
label: Schema.String,
value: Value,
}) {}
export const Event = {
Added: EventV2.define({
type: "credential.added",
schema: { credential: Info },
}),
Removed: EventV2.define({
type: "credential.removed",
schema: { credential: Info },
}),
Switched: EventV2.define({
type: "credential.switched",
schema: {
connectorID: ConnectorSchema.ID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly create: (input: {
connectorID: ConnectorSchema.ID
methodID: ConnectorSchema.MethodID
value: Value
label?: string
}) => Effect.Effect<Info>
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly activate: (id: ID) => Effect.Effect<void>
readonly active: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info | undefined>
readonly activeAll: () => Effect.Effect<Map<ConnectorSchema.ID, Info>>
readonly forConnector: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Credential") {}
export const legacyImportLayer = Layer.effectDiscard(
Effect.gen(function* () {
const { db } = yield* Database.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const name = "credential.auth-json"
if (yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get()) return
const raw = yield* fs.readJson(path.join(global.data, "auth.json")).pipe(Effect.option)
if (Option.isNone(raw) || typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)) return
const decode = Schema.decodeUnknownOption(LegacyValue)
const values = Object.entries(raw.value).flatMap(([connectorID, value]) => {
const decoded = decode(value)
if (Option.isNone(decoded)) return []
const credential = decoded.value
const id = ID.create()
const connector = ConnectorSchema.ID.make(connectorID.replace(/\/+$/, ""))
const methodID = ConnectorSchema.MethodID.make(
credential.type === "api"
? "api-key"
: connector === ConnectorSchema.ID.make("openai")
? "chatgpt-browser"
: "oauth",
)
const next: Value =
credential.type === "api"
? new Key({ type: "key", key: credential.key, metadata: credential.metadata })
: new OAuth({
type: "oauth",
refresh: credential.refresh,
access: credential.access,
expires: credential.expires,
metadata: {
...(credential.accountId ? { accountID: credential.accountId } : {}),
...(credential.enterpriseUrl ? { enterpriseURL: credential.enterpriseUrl } : {}),
},
})
return [{ id, connectorID: connector, methodID, value: next }]
})
yield* db.transaction((tx) =>
Effect.gen(function* () {
for (const item of values) {
if (
yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.connector_id, item.connectorID))
.get()
)
continue
yield* tx.insert(CredentialTable).values({
id: item.id,
connector_id: item.connectorID,
method_id: item.methodID,
label: "Imported",
value: item.value,
active: true,
})
}
yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run()
}),
)
}).pipe(Effect.orDie),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const decodeValue = Schema.decodeUnknownSync(Value)
const info = (row: typeof CredentialTable.$inferSelect) =>
new Info({
id: row.id,
connectorID: row.connector_id,
methodID: row.method_id,
label: row.label,
value: decodeValue(row.value),
})
const activate = Effect.fn("Credential.activate")(function* (id: ID) {
const switched = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential || credential.active) return
const current = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, credential.connector_id), eq(CredentialTable.active, true)))
.get()
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.connector_id, credential.connector_id))
.run()
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
return { connectorID: credential.connector_id, from: current?.id, to: id }
}),
)
.pipe(Effect.orDie)
if (switched) yield* events.publish(Event.Switched, switched)
})
return Service.of({
get: Effect.fn("Credential.get")(function* (id) {
const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie)
return row ? info(row) : undefined
}),
all: Effect.fn("Credential.all")(function* () {
return (yield* db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie)).map(info)
}),
active: Effect.fn("Credential.active")(function* (connectorID) {
const row = yield* db
.select()
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, connectorID), eq(CredentialTable.active, true)))
.get()
.pipe(Effect.orDie)
return row ? info(row) : undefined
}),
activeAll: Effect.fn("Credential.activeAll")(function* () {
const rows = yield* db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.active, true))
.all()
.pipe(Effect.orDie)
return new Map(rows.map((row) => [row.connector_id, info(row)]))
}),
forConnector: Effect.fn("Credential.forConnector")(function* (connectorID) {
return (yield* db
.select()
.from(CredentialTable)
.where(eq(CredentialTable.connector_id, connectorID))
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie)).map(info)
}),
create: Effect.fn("Credential.create")(function* (input) {
const credential = new Info({
id: ID.create(),
connectorID: input.connectorID,
methodID: input.methodID,
label: input.label ?? "default",
value: input.value,
})
const from = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const current = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, input.connectorID), eq(CredentialTable.active, true)))
.get()
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.connector_id, input.connectorID))
.run()
yield* tx
.insert(CredentialTable)
.values({
id: credential.id,
connector_id: credential.connectorID,
method_id: credential.methodID,
label: credential.label,
value: credential.value,
active: true,
})
.run()
return current?.id
}),
)
.pipe(Effect.orDie)
yield* events.publish(Event.Added, { credential })
yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: credential.id })
return credential
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (!updates.label && !updates.value) return
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.run()
.pipe(Effect.orDie)
}),
remove: Effect.fn("Credential.remove")(function* (id) {
const removed = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!row) return
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!row.active) return { credential: info(row) }
const replacement = yield* tx
.select()
.from(CredentialTable)
.where(and(eq(CredentialTable.connector_id, row.connector_id), ne(CredentialTable.id, id)))
.orderBy(asc(CredentialTable.time_created))
.get()
if (replacement) {
yield* tx
.update(CredentialTable)
.set({ active: true })
.where(eq(CredentialTable.id, replacement.id))
.run()
}
return {
credential: info(row),
switched: { connectorID: row.connector_id, from: id, to: replacement?.id },
}
}),
)
.pipe(Effect.orDie)
if (!removed) return
yield* events.publish(Event.Removed, { credential: removed.credential })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}),
activate,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(
legacyImportLayer.pipe(
Layer.provide(Database.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Global.defaultLayer),
),
),
)
+23
View File
@@ -0,0 +1,23 @@
import { sql } from "drizzle-orm"
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { ConnectorSchema } from "../connector/schema"
import type { Credential } from "../credential"
export const CredentialTable = sqliteTable(
"credential",
{
id: text().$type<Credential.ID>().primaryKey(),
connector_id: text().$type<ConnectorSchema.ID>().notNull(),
method_id: text().$type<ConnectorSchema.MethodID>().notNull(),
label: text().notNull(),
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
active: integer({ mode: "boolean" }).notNull().default(false),
...Timestamps,
},
(table) => [
uniqueIndex("credential_connector_active_idx")
.on(table.connector_id)
.where(sql`${table.active} = 1`),
],
)
+1
View File
@@ -34,5 +34,6 @@ export const migrations = (
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,25 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260611035744_credential",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`credential\` (
\`id\` text PRIMARY KEY,
\`connector_id\` text NOT NULL,
\`method_id\` text NOT NULL,
\`label\` text NOT NULL,
\`value\` text NOT NULL,
\`active\` integer DEFAULT false NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(
`CREATE UNIQUE INDEX \`credential_connector_active_idx\` ON \`credential\` (\`connector_id\`) WHERE "credential"."active" = 1;`,
)
})
},
} satisfies DatabaseMigration.Migration
+8 -1
View File
@@ -44,13 +44,20 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, E2>(
export function replaceWithNode<A, E, E2>(
source: Node<A, E>,
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement<A> {
return { source, replacement }
}
export function replace<A, E, E2>(
source: Node<A, E>,
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement<A> {
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
}
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
const cache = new Map<AnyNode, RuntimeLayer>()
+39 -117
View File
@@ -1,13 +1,14 @@
export * as FileSystem from "./filesystem"
import path from "path"
import { pathToFileURL } from "url"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Search } from "./filesystem/search"
import { PositiveInt, RelativePath } from "./schema"
import { FileSystemSearch } from "./filesystem/search"
import { Entry, Match } from "./filesystem/schema"
export { Entry, Match, Submatch } from "./filesystem/schema"
export const ReadInput = Schema.Struct({
path: RelativePath,
@@ -28,39 +29,23 @@ export const ListInput = Schema.Struct({
})
export type ListInput = typeof ListInput.Type
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
uri: Schema.String,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const FindInput = Schema.Struct({
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
query: Schema.String,
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type FindInput = typeof FindInput.Type
}) {}
export const GrepInput = Schema.Struct({
export class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
pattern: Schema.String,
path: RelativePath.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
}) {}
export class GrepInput extends Schema.Class<GrepInput>("FileSystem.GrepInput")({
pattern: Schema.String,
path: RelativePath.pipe(Schema.optional),
include: Schema.String.pipe(Schema.optional),
limit: PositiveInt.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type
export class GrepMatch extends Schema.Class<GrepMatch>("LocationFileSystem.GrepMatch")({
path: RelativePath,
lines: Schema.String,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}) {}
export const Event = {
@@ -73,20 +58,21 @@ export const Event = {
}
export interface Interface {
readonly read: (input: ReadInput) => Effect.Effect<Content>
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem") {}
export const layer = Layer.effect(
const baseLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const search = yield* Search.Service
const search = yield* FileSystemSearch.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const resolve = Effect.fnUntraced(function* (input?: RelativePath) {
const absolute = path.resolve(location.directory, input ?? ".")
@@ -96,47 +82,17 @@ export const layer = Layer.effect(
if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location"))
return { absolute, real, directory: location.directory, root }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!real) return
if (!FSUtil.contains(selected.root, real)) return
const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void))
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new Entry({
path: RelativePath.make(path.relative(selected.directory, absolute)),
uri: pathToFileURL(real).href,
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real),
})
})
return Service.of({
find: search.find,
glob: search.glob,
grep: search.grep,
read: Effect.fn("FileSystem.read")(function* (input) {
const target = yield* resolve(input.path)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const bytes = yield* fs.readFile(target.real).pipe(Effect.orDie)
const mime = FSUtil.mimeType(target.real)
if (!bytes.includes(0)) {
const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe(
Effect.option,
)
if (Option.isSome(content))
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: content.value,
encoding: "utf8" as const,
mime,
}
}
return {
uri: pathToFileURL(target.real).href,
name: path.basename(target.real),
content: Buffer.from(bytes).toString("base64"),
encoding: "base64" as const,
mime,
content: yield* fs.readFile(target.real).pipe(Effect.orDie),
mime: FSUtil.mimeType(target.real),
}
}),
list: Effect.fn("FileSystem.list")(function* (input = {}) {
@@ -145,62 +101,28 @@ export const layer = Layer.effect(
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
return yield* fs.readDirectoryEntries(target.real).pipe(
Effect.orDie,
Effect.flatMap((items) =>
Effect.forEach(items, (item) => entry(path.join(target.absolute, item.name), target), {
concurrency: "unbounded",
}),
),
Effect.map((items) =>
items
.filter((item): item is Entry => item !== undefined)
.flatMap((item) => {
if (item.type !== "file" && item.type !== "directory") return []
const absolute = path.join(target.absolute, item.name)
const relative = path.relative(target.directory, absolute)
return [
new Entry({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
}),
]
})
.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)),
),
)
}),
find: Effect.fn("FileSystem.find")(function* (input) {
const found = yield* search
.file({
cwd: location.directory,
query: input.query,
limit: input.limit,
kind: input.type ?? "all",
})
.pipe(Effect.orDie)
return found.map(
(item) =>
new Entry({
path: RelativePath.make(item.path),
uri: pathToFileURL(path.join(location.directory, item.path)).href,
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(item.path),
}),
)
}),
grep: Effect.fn("FileSystem.grep")(function* (input) {
return (yield* search
.search({
cwd: location.directory,
pattern: input.pattern,
glob: input.include ? [input.include] : undefined,
limit: input.limit,
})
.pipe(Effect.orDie)).items.map(
(item) =>
new GrepMatch({
path: RelativePath.make(item.path.text),
lines: item.lines.text,
line: item.line_number,
offset: item.absolute_offset,
submatches: item.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
}),
)
}),
})
}),
)
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer
+4
View File
@@ -12,6 +12,10 @@ import {
type SearchResult,
} from "@ff-labs/fff-bun"
declare global {
const FFF_LIBC: "gnu" | "musl"
}
export type Result<T> = { ok: true; value: T } | { ok: false; error: string }
export type Init = InitOptions
-487
View File
@@ -1,487 +0,0 @@
import path from "path"
import { serviceUse } from "../effect/service-use"
import { FSUtil } from "../fs-util"
import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global"
import { NonNegativeInt } from "../schema"
import { which } from "../util/which"
import { LayerNode } from "../effect/layer-node"
import { httpClient } from "../effect/layer-node-platform"
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
const TimeStats = Schema.Struct({
secs: NonNegativeInt,
nanos: NonNegativeInt,
human: Schema.String,
})
const Stats = Schema.Struct({
elapsed: TimeStats,
searches: NonNegativeInt,
searches_with_match: NonNegativeInt,
bytes_searched: NonNegativeInt,
bytes_printed: NonNegativeInt,
matched_lines: NonNegativeInt,
matches: NonNegativeInt,
})
const PathText = Schema.Struct({
text: Schema.String,
})
const Begin = Schema.Struct({
type: Schema.Literal("begin"),
data: Schema.Struct({
path: PathText,
}),
})
export const SearchMatch = Schema.Struct({
path: PathText,
lines: Schema.Struct({
text: Schema.String,
}),
line_number: NonNegativeInt,
absolute_offset: NonNegativeInt,
submatches: Schema.Array(
Schema.Struct({
match: Schema.Struct({
text: Schema.String,
}),
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
})
export const Match = Schema.Struct({
type: Schema.Literal("match"),
data: SearchMatch,
})
const End = Schema.Struct({
type: Schema.Literal("end"),
data: Schema.Struct({
path: PathText,
binary_offset: Schema.NullOr(NonNegativeInt),
stats: Stats,
}),
})
const Summary = Schema.Struct({
type: Schema.Literal("summary"),
data: Schema.Struct({
elapsed_total: TimeStats,
stats: Stats,
}),
})
const Result = Schema.Union([Begin, Match, End, Summary])
const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result))
export type Result = Schema.Schema.Type<typeof Result>
export type Match = Schema.Schema.Type<typeof Match>
export type Item = Match["data"]
export type Begin = Schema.Schema.Type<typeof Begin>
export type End = Schema.Schema.Type<typeof End>
export type Summary = Schema.Schema.Type<typeof Summary>
export type Row = Match["data"]
export interface SearchResult {
items: Item[]
partial: boolean
}
export interface FilesInput {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
signal?: AbortSignal
}
export interface SearchInput {
cwd: string
pattern: string
glob?: string[]
limit?: number
follow?: boolean
file?: string[]
signal?: AbortSignal
}
export interface TreeInput {
cwd: string
limit?: number
signal?: AbortSignal
}
export interface Interface {
readonly filepath: Effect.Effect<string, Error>
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const use = serviceUse(Service)
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
delete env.RIPGREP_CONFIG_PATH
return env
}
function aborted(signal?: AbortSignal) {
const err = signal?.reason
if (err instanceof Error) return err
const out = new Error("Aborted")
out.name = "AbortError"
return out
}
function waitForAbort(signal?: AbortSignal) {
if (!signal) return Effect.never
if (signal.aborted) return Effect.fail(aborted(signal))
return Effect.callback<never, Error>((resume) => {
const onabort = () => resume(Effect.fail(aborted(signal)))
signal.addEventListener("abort", onabort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onabort))
})
}
function error(stderr: string, code: number) {
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
err.name = "RipgrepError"
return err
}
function clean(file: string) {
return path.normalize(file.replace(/^\.[\\/]/, ""))
}
function row(data: Row): Row {
return {
...data,
path: {
...data.path,
text: clean(data.path.text),
},
}
}
function parse(line: string) {
return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause })))
}
function fail(queue: Queue.Queue<string, PlatformError | Error | Cause.Done>, err: PlatformError | Error) {
Queue.failCauseUnsafe(queue, Cause.fail(err))
}
function filesArgs(input: FilesInput) {
const args = ["--no-config", "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.hidden === false) args.push("--glob=!.*")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
args.push(".")
return args
}
function searchArgs(input: SearchInput) {
const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"]
if (input.follow) args.push("--follow")
if (input.glob) {
for (const glob of input.glob) args.push(`--glob=${glob}`)
}
if (input.limit) args.push(`--max-count=${input.limit}`)
args.push("--", input.pattern, ...(input.file ?? ["."]))
return args
}
function raceAbort<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
}
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return { stdout, stderr, code }
}, Effect.scoped)
const extract = Effect.fnUntraced(function* (
archive: string,
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
"-Command",
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
if (config.extension === "tar.gz") {
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
if (result.code !== 0) {
return yield* Effect.fail(error(result.stderr || result.stdout, result.code))
}
}
const extracted = path.join(
dir,
`ripgrep-${VERSION}-${config.platform}`,
process.platform === "win32" ? "rg.exe" : "rg",
)
if (!(yield* fs.isFile(extracted))) {
return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`))
}
yield* fs.copyFile(extracted, target)
if (process.platform === "win32") return
yield* fs.chmod(target, 0o755)
}, Effect.scoped)
const filepath = yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) {
return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`))
}
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
)
if (bytes.byteLength === 0) {
return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`))
}
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
yield* extract(archive, config, target)
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
return target
}),
)
const check = Effect.fnUntraced(function* (cwd: string) {
if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return
return yield* Effect.fail(
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
code: "ENOENT",
errno: -2,
path: cwd,
}),
)
})
const command = Effect.fnUntraced(function* (cwd: string, args: string[]) {
const binary = yield* filepath
return ChildProcess.make(binary, args, {
cwd,
env: env(),
extendEnv: true,
stdin: "ignore",
})
})
const files: Interface["files"] = (input) =>
Stream.callback<string, PlatformError | Error>((queue) =>
Effect.gen(function* () {
yield* Effect.forkScoped(
Effect.gen(function* () {
yield* check(input.cwd)
const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input)))
const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped)
const stdout = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))),
Effect.forkScoped,
)
const code = yield* raceAbort(handle.exitCode, input.signal)
yield* Fiber.join(stdout)
if (code === 0 || code === 1) {
Queue.endUnsafe(queue)
return
}
fail(queue, error(yield* Fiber.join(stderr), code))
}).pipe(
Effect.catch((err) =>
Effect.sync(() => {
fail(queue, err)
}),
),
),
)
}),
)
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
yield* check(input.cwd)
const program = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input)))
const [items, stderr, code] = yield* Effect.all(
[
Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(parse),
Stream.filter((item): item is Match => item.type === "match"),
Stream.map((item) => row(item.data)),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
if (code !== 0 && code !== 1 && code !== 2) {
return yield* Effect.fail(error(stderr, code))
}
return {
items: code === 1 ? [] : items,
partial: code === 2,
}
}),
)
return yield* raceAbort(program, input.signal)
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
yield* Effect.logInfo("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
name: string
children: Map<string, Node>
}
function child(node: Node, name: string) {
const item = node.children.get(name)
if (item) return item
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
function count(node: Node): number {
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
}
const root: Node = { name: "", children: new Map() }
for (const file of list) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = child(node, part)
}
}
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: node.name }))
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const item = queue[i]
lines.push(item.path)
used++
queue.push(
...Array.from(item.node.children.values())
.sort((a, b) => a.name.localeCompare(b.name))
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
)
}
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
})
return Service.of({ filepath, files, tree, search })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export const node = LayerNode.make(layer, [FSUtil.node, CrossSpawnSpawner.node, httpClient])
export * as Ripgrep from "./ripgrep"
+23
View File
@@ -0,0 +1,23 @@
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
export class Entry extends Schema.Class<Entry>("FileSystem.Entry")({
path: RelativePath,
type: Schema.Literals(["file", "directory"]),
mime: Schema.String,
}) {}
export const Submatch = Schema.Struct({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
})
export type Submatch = typeof Submatch.Type
export class Match extends Schema.Class<Match>("FileSystem.Match")({
entry: Entry,
line: PositiveInt,
offset: NonNegativeInt,
text: Schema.String,
submatches: Schema.Array(Submatch),
}) {}
+216 -546
View File
@@ -1,567 +1,237 @@
export * as FileSystemSearch from "./search"
import path from "path"
import { Context, Deferred, Effect, Layer, Option, Stream } from "effect"
import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util"
import { Glob } from "../util/glob"
import { Global } from "../global"
import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime"
import { Context, Effect, Layer, Scope } from "effect"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
import { LayerNode } from "../effect/layer-node"
const root = path.join(Global.Path.cache, "fff")
export type Item = Ripgrep.Item
export type SearchError = PlatformError | globalThis.Error
export interface Result {
readonly items: Item[]
readonly partial: boolean
readonly hasNextPage: boolean
readonly engine: "fff" | "ripgrep"
readonly regexFallbackError?: string
}
export interface FileInput {
readonly cwd: string
readonly query: string
readonly limit?: number
readonly current?: string
readonly kind?: "file" | "directory" | "all"
}
export interface FileResult {
readonly path: string
readonly type: "file" | "directory"
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
readonly limit?: number
readonly signal?: AbortSignal
}
interface Query {
readonly dir: string
readonly text: string
readonly files: string[]
}
// A created picker plus its cached scan-readiness gate. The picker is created
// (and its native background scan kicked off) eagerly; `ready` is only awaited
// when the picker is actually used.
interface Picker {
readonly pick: Fff.Picker
readonly ready: Effect.Effect<void, Error>
}
interface State {
readonly pick: Map<string, Picker>
readonly wait: Map<string, Deferred.Deferred<Picker, Error>>
readonly recent: Query[]
}
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Flag } from "../flag/flag"
export interface Interface {
readonly files: Ripgrep.Interface["files"]
readonly tree: Ripgrep.Interface["tree"]
readonly search: (input: Ripgrep.SearchInput) => Effect.Effect<Result, SearchError>
readonly file: (input: FileInput) => Effect.Effect<readonly FileResult[], SearchError>
readonly glob: (input: GlobInput) => Effect.Effect<{ files: string[]; truncated: boolean }, SearchError>
readonly open: (input: { cwd?: string; file: string }) => Effect.Effect<void, SearchError>
readonly warm: (cwd: string) => Effect.Effect<void>
// Destroy the picker for a directory and drop its cached state. Called when a
// directory's instance is disposed so fff's native watcher thread is torn
// down instead of leaking until process exit.
readonly release: (cwd: string) => Effect.Effect<void>
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Search") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileSystem/Search") {}
export const use = serviceUse(Service)
function key(dir: string) {
return Buffer.from(dir).toString("base64url")
}
function fffSync<A>(action: string, run: () => A) {
return Effect.try({
try: run,
catch: (cause) => new Error(`fff ${action} failed`, { cause }),
})
}
function normalize(text: string) {
return text.replaceAll("\\", "/")
}
// fff supports glob narrowing for any search out of the box
function fffGlobbedQuery(query: string, glob?: string | string[]) {
if (query && glob) {
const resolvedGlob = Array.isArray(glob) ? glob.join(" ") : glob
return `${resolvedGlob} ${query}`
}
return query ?? glob
}
function remember(state: State, dir: string, text: string, files: string[]) {
if (!files.length) return
const next = Array.from(new Set(files.map(FSUtil.resolve))).slice(0, 64)
if (!next.length) return
const idx = state.recent.findIndex((item) => item.dir === dir && item.text === text)
if (idx >= 0) state.recent.splice(idx, 1)
state.recent.unshift({ dir, text, files: next })
if (state.recent.length > 32) state.recent.length = 32
}
function item(hit: Fff.Hit): Item {
const line = Buffer.from(hit.lineContent)
return {
path: { text: normalize(hit.relativePath) },
lines: { text: hit.lineContent },
line_number: hit.lineNumber,
absolute_offset: hit.byteOffset,
submatches: hit.matchRanges
.map(([start, end]) => {
const text = line.subarray(start, end).toString("utf8")
if (!text) return undefined
return {
match: { text },
start,
end,
}
})
.filter((row): row is Item["submatches"][number] => Boolean(row)),
}
}
function collectPaths<T>(
items: T[],
scores: Array<{ total: number }>,
toResult: (item: T) => FileResult,
): FileResult[] {
const rows = items.flatMap((item, index): Array<FileResult & { score: number }> => {
const result = toResult(item)
if (!result.path) return []
return [{ ...result, score: scores[index]?.total ?? 0 }]
})
rows.sort(
(a, b) => b.score - a.score || a.path.length - b.path.length || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
)
const seen = new Set<string>()
return rows.flatMap((item) => {
if (seen.has(item.path)) return []
seen.add(item.path)
return [{ path: item.path, type: item.type }]
})
}
function searchFff(
pick: Fff.Picker,
kind: "file" | "directory" | "all",
query: string,
opts: { currentFile?: string; pageIndex?: number; pageSize?: number },
): Fff.Result<FileResult[]> {
if (kind === "directory") {
const out = pick.directorySearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.relativePath),
type: "directory",
})),
}
}
if (kind === "all") {
const out = pick.mixedSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.item.relativePath),
type: entry.type,
})),
}
}
const out = pick.fileSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => ({
path: normalize(entry.relativePath),
type: "file",
})),
}
}
export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service> = Layer.effect(
export const ripgrepLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const rg = yield* Ripgrep.Service
const state: State = {
pick: new Map<string, Picker>(),
wait: new Map<string, Deferred.Deferred<Picker, Error>>(),
recent: [] as Query[],
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const state = {
files: [] as string[],
directories: [] as string[],
}
yield* fs.ensureDir(root).pipe(Effect.ignore)
yield* Effect.addFinalizer(() =>
Effect.forEach(
state.pick.values(),
(entry) => fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore),
{ discard: true },
),
)
const rip = Effect.fn("Search.rip")(function* (input: Ripgrep.SearchInput) {
const out = yield* rg.search(input)
return {
items: out.items,
partial: out.partial,
hasNextPage: false,
engine: "ripgrep" as const,
}
})
// Lazy, shared scan-wait for a picker. Preserves the original behavior: if
// the scan does not finish within the budget the picker is destroyed and
// dropped from the cache so callers fall back to ripgrep (and the next
// request recreates a fresh picker).
const scanReady = (dir: string, pick: Fff.Picker) =>
Effect.gen(function* () {
const scanned = yield* Effect.tryPromise({
try: () => pick.waitForScan(5_000),
catch: (cause) => new Error("fff waitForScan failed", { cause }),
})
if (!scanned.ok || !scanned.value) {
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
state.pick.delete(dir)
yield* Effect.logWarning("fff scan not ready", { dir })
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
}
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
if (!git.ok) {
yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error })
}
const directories = new Set<string>()
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
state.files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
state.directories = Array.from(directories)
}),
})
// Create (or return) the picker for a directory. Creation is synchronous
// and does not await the scan; the native background scan starts as soon as
// the picker exists. The `wait` gate dedupes concurrent creation.
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
const dir = FSUtil.resolve(cwd)
const existing = state.pick.get(dir)
if (existing) return existing
const pending = state.wait.get(dir)
if (pending) return yield* Deferred.await(pending)
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))),
)
if (!available) return undefined
const gate = yield* Deferred.make<Picker, Error>()
state.wait.set(dir, gate)
return yield* Effect.gen(function* () {
const id = key(dir)
const isFirstPicker = state.pick.size === 0
const made = yield* fffSync("create picker", () =>
Fff.create({
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
aiMode: true,
// only the first toolcall picker can accumulate resources to index
// home directory, if the user specifically opened opencode at the
// $HOME level or asked it to search there on purpose, otherwise fallback
enableHomeDirScanning: isFirstPicker,
// on unix system it is 99.9% that you do not need to search for the
// content at the / so make fff fail creation and fallback to rg
enableFsRootScanning: isFirstPicker && process.platform === "win32",
}),
)
if (!made.ok) {
yield* Effect.logWarning("fff init failed", { dir, error: made.error })
const err = new Error(made.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
}
const pick = made.value
const entry: Picker = { pick, ready: yield* Effect.cached(scanReady(dir, pick)) }
state.pick.set(dir, entry)
yield* Deferred.succeed(gate, entry)
return entry
}).pipe(
Effect.ensuring(
Effect.gen(function* () {
if (state.wait.get(dir) === gate) state.wait.delete(dir)
yield* Deferred.fail(gate, new Error("fff init interrupted")).pipe(Effect.ignore)
}),
),
)
})
// Resolve a usable, scanned picker for a directory, or undefined when fff is
// unavailable or the scan did not become ready.
const picker = Effect.fn("Search.picker")(function* (cwd: string) {
const entry = yield* acquire(cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
if (!entry) return undefined
const ready = yield* entry.ready.pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
if (!ready) return undefined
return entry.pick
})
const files: Interface["files"] = (input) => rg.files(input)
const tree: Interface["tree"] = (input) => rg.tree(input)
// in 99% of use cases user that is opened opencode at certain directory will
// conduct a file search in this direcotry, it could be switched later but
// mostly always we will need a file picker for cwd
// so synchronously start FFF scan for a cwd so it is ready before first toolcall generated
const warm: Interface["warm"] = Effect.fn("Search.warm")(function* (cwd) {
yield* acquire(cwd).pipe(Effect.ignore)
})
// Tear down the picker for a directory. fff pickers own a native background
// watcher thread that otherwise lives until the runtime scope closes (i.e.
// process exit), so disposing the instance that warmed it must destroy it
// here or the thread leaks against a directory that may already be gone.
const release: Interface["release"] = Effect.fn("Search.release")(function* (cwd) {
const dir = FSUtil.resolve(cwd)
const pending = state.wait.get(dir)
if (pending) {
state.wait.delete(dir)
yield* Deferred.fail(pending, new Error("fff picker released")).pipe(Effect.ignore)
}
const entry = state.pick.get(dir)
if (entry) {
state.pick.delete(dir)
yield* fffSync("destroy picker", () => entry.pick.destroy()).pipe(Effect.ignore)
}
const remaining = state.recent.filter((item) => item.dir !== dir)
state.recent.splice(0, state.recent.length, ...remaining)
})
const file: Interface["file"] = Effect.fn("Search.file")(function* (input) {
const query = input.query.trim()
const kind = input.kind ?? "file"
const entry = yield* acquire(input.cwd)
if (!entry) return yield* Effect.fail(new Error("fff is unavailable"))
yield* entry.ready
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffResult = yield* fffSync(`${kind} search`, () =>
searchFff(entry.pick, kind, query, {
pageIndex: 0,
currentFile: input.current, // supports both relative and absolute (relative preferred)
pageSize: limit,
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
glob: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
Effect.orDie,
)
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
Effect.andThen(Effect.fail(error)),
),
),
)
if (!fffResult.ok) {
yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return yield* Effect.fail(new Error(fffResult.error))
}
const rows = fffResult.value
remember(
state,
dir,
query,
rows.map((row) => path.join(dir, row.path)),
)
return rows.slice(0, limit)
})
const search: Interface["search"] = Effect.fn("Search.search")(function* (input) {
input.signal?.throwIfAborted()
if (input.file?.length) return yield* rip(input)
const pick = yield* picker(input.cwd)
if (!pick) return yield* rip(input)
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffGrep = yield* fffSync("grep", () =>
pick.grep(fffGlobbedQuery(input.pattern, input.glob), {
mode: "regex",
pageSize: limit,
timeBudgetMs: 1_500,
grep: (input) =>
Effect.gen(function* () {
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.orDie)
const cwd = info.type === "File" ? path.dirname(target) : target
return yield* ripgrep
.grep({
cwd,
pattern: input.pattern,
file: info.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))),
}),
}),
),
),
Effect.orDie,
)
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe(
Effect.as<Fff.Result<Fff.Grep> | undefined>(undefined),
),
),
)
if (!fffGrep) return yield* rip(input)
if (!fffGrep.ok) {
yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
return yield* rip(input)
}
const rows: Item[] = fffGrep.value.items.map(item)
const regexFallbackError = fffGrep.value.regexFallbackError
remember(state, dir, input.pattern, Array.from(new Set(rows.map((row) => path.join(dir, row.path.text)))))
return {
items: rows,
partial: false,
hasNextPage: !!fffGrep.value.nextCursor,
engine: "fff" as const,
regexFallbackError,
}
})
const glob: Interface["glob"] = Effect.fn("Search.glob")(function* (input) {
input.signal?.throwIfAborted()
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const pick = yield* picker(dir)
if (pick) {
const fffGlob = yield* fffSync("glob file search", () =>
pick.glob(normalize(input.pattern), {
pageIndex: 0,
pageSize: limit,
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe(
Effect.as<Fff.Result<Fff.Search> | undefined>(undefined),
),
),
)
if (fffGlob?.ok) {
const rows: string[] = Array.from(new Set(fffGlob.value.items.map((item) => normalize(item.relativePath))))
remember(
state,
dir,
input.pattern,
rows.map((row) => path.join(dir, row)),
)
return {
files: rows.slice(0, limit).map((row) => path.join(dir, row)),
truncated: fffGlob.value.totalMatched > rows.length,
}
} else if (fffGlob) {
yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
// fall through to the fallback
}
}
const rows = yield* rg.files({ cwd: dir, glob: [input.pattern], signal: input.signal }).pipe(
Stream.take(limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
const truncated = rows.length > limit
if (truncated) rows.length = limit
const output = yield* Effect.forEach(
rows,
Effect.fnUntraced(function* (file) {
const full = path.join(dir, file)
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
const time =
info?.mtime.pipe(
Option.map((item) => item.getTime()),
Option.getOrElse(() => 0),
) ?? 0
return { file: full, time }
find: (input) =>
Effect.gen(function* () {
const items =
input.type === "file"
? state.files
: input.type === "directory"
? state.directories
: [...state.files, ...state.directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative
const absolute = path.resolve(location.directory, clean)
return new FileSystem.Entry({
path: RelativePath.make(relative),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
{ concurrency: 16 },
)
output.sort((a, b) => b.time - a.time)
return {
files: output.map((item) => item.file),
truncated,
}
})
const open: Interface["open"] = Effect.fn("Search.open")(function* (input) {
const file = input.cwd
? FSUtil.resolve(path.isAbsolute(input.file) ? input.file : path.join(input.cwd, input.file))
: FSUtil.resolve(input.file)
const idx = state.recent.findIndex((item) => item.files.includes(file))
if (idx < 0) return
const row = state.recent[idx]
state.recent.splice(idx, 1)
const entry = state.pick.get(row.dir)
if (!entry) return
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
Effect.catch((error) =>
Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe(
Effect.as<Fff.Result<boolean> | undefined>(undefined),
),
),
)
if (!out) return
if (!out.ok) {
yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
}
})
return Service.of({ files, tree, search, file, glob, open, warm, release })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provide(FSUtil.defaultLayer),
export const fffLayer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const result = yield* Effect.try({
try: () =>
Fff.create({
basePath: location.directory,
aiMode: true,
enableFsRootScanning: true,
enableHomeDirScanning: true,
}),
catch: (cause) => cause,
}).pipe(Effect.orDie)
if (!result.ok) return yield* Effect.die(result.error)
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
return Service.of({
glob: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, {
pageIndex: 0,
pageSize: input.limit,
})
if (!found.ok) throw found.error
return found.value.items.map((item) => {
const absolute = path.resolve(location.directory, item.relativePath)
return new FileSystem.Entry({
path: RelativePath.make(item.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(absolute),
})
})
}),
grep: (input) =>
Effect.sync(() => {
const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "")
const found = result.value.grep(
[prefix ? `${prefix}/**` : undefined, input.include, input.pattern]
.filter((value) => value !== undefined)
.join(" "),
{ mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 },
)
if (!found.ok) throw found.error
return found.value.items.map((match) => {
const bytes = Buffer.from(match.lineContent)
return new FileSystem.Match({
entry: new FileSystem.Entry({
path: RelativePath.make(match.relativePath.replaceAll("\\", "/")),
type: "file",
mime: FSUtil.mimeType(match.relativePath),
}),
line: match.lineNumber,
offset: match.byteOffset,
text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent,
submatches: match.matchRanges.map(([start, end]) => ({
text: bytes.subarray(start, end).toString("utf8"),
start,
end,
})),
})
})
}),
find: (input) =>
Effect.sync(() => {
const options = { pageIndex: 0, pageSize: input.limit ?? 50 }
const items = (() => {
if (input.type === "file") {
const found = result.value.fileSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "file" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
if (input.type === "directory") {
const found = result.value.directorySearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.relativePath,
type: "directory" as const,
score: found.value.scores[index]?.total ?? 0,
}))
}
const found = result.value.mixedSearch(input.query.trim(), options)
if (!found.ok) throw found.error
return found.value.items.map((item, index) => ({
path: item.item.relativePath,
type: item.type,
score: found.value.scores[index]?.total ?? 0,
}))
})()
return items
.sort((a, b) => b.score - a.score || a.path.length - b.path.length)
.map((item) => {
const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "")
const absolute = path.resolve(location.directory, relative)
return new FileSystem.Entry({
path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")),
type: item.type,
mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute),
})
})
}),
})
}),
)
export const node = LayerNode.make(layer, [FSUtil.node, Ripgrep.node])
const { runPromise } = makeRuntime(Service, defaultLayer)
export function tree(input: Ripgrep.TreeInput) {
return runPromise((svc) => svc.tree(input))
}
export function search(input: Ripgrep.SearchInput) {
return runPromise((svc) => svc.search(input))
}
export function file(input: FileInput) {
return runPromise((svc) => svc.file(input))
}
export function glob(input: GlobInput) {
return runPromise((svc) => svc.glob(input))
}
export function open(input: { cwd?: string; file: string }) {
return runPromise((svc) => svc.open(input))
}
export * as Search from "./search"
export const defaultLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
)
+2
View File
@@ -6,6 +6,7 @@ export function truthy(key: string) {
}
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
const fff = process.env["OPENCODE_DISABLE_FFF"]
function enabledByExperimental(key: string) {
return process.env[key] === undefined ? truthy("OPENCODE_EXPERIMENTAL") : truthy(key)
@@ -30,6 +31,7 @@ export const Flag = {
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"),
// Experimental
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
+15 -10
View File
@@ -1,15 +1,16 @@
import { Layer, LayerMap } from "effect"
import { Effect, Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
import { PluginV2 } from "./plugin"
import { Catalog } from "./catalog"
import { Connector } from "./connector"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginBoot } from "./plugin/boot"
import { Project } from "./project"
import { EventV2 } from "./event"
import { Auth } from "./auth"
import { Credential } from "./credential"
import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { FSUtil } from "./fs-util"
@@ -18,12 +19,12 @@ import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { FileSystem } from "./filesystem"
import { Ripgrep } from "./ripgrep"
import { Watcher } from "./filesystem/watcher"
import { Search } from "./filesystem/search"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
@@ -34,7 +35,6 @@ import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
import { AppProcess } from "./process"
import { Ripgrep } from "./ripgrep"
import { SessionStore } from "./session/store"
import { SessionTodo } from "./session/todo"
import { QuestionV2 } from "./question"
@@ -47,6 +47,9 @@ import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
)
const location = Location.layer(ref)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
@@ -56,6 +59,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Reference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
Connector.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginBoot.locationLayer,
@@ -75,14 +79,13 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
Layer.provide(services),
Layer.provide(mutation),
Layer.provide(searches),
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
@@ -93,30 +96,33 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
)
return Layer.mergeAll(
boot,
services,
image,
mutation,
searches,
resources,
todos,
questions,
model,
runner,
builtInTools,
referenceGuidance,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
Project.defaultLayer,
EventV2.defaultLayer,
Auth.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
@@ -125,6 +131,5 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
Search.defaultLayer,
],
}) {}
-219
View File
@@ -1,219 +0,0 @@
export * as LocationSearch from "./location-search"
import path from "path"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { ToolOutputStore } from "./tool-output-store"
/**
* Location-scoped raw search substrate. Search authority is selected only by
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
* share the same bounded filesystem behavior.
*
* TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints.
* TODO: Reuse this substrate for instruction and skill discovery where suitable.
*/
export const DEFAULT_RESULT_LIMIT = 100
export const MAX_RESULT_LIMIT = 100
export const MAX_LINE_PREVIEW_LENGTH = 2_000
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
export const FilesInput = Schema.Struct({
pattern: Schema.String,
path: Schema.String.pipe(Schema.optional),
limit: ResultLimit.pipe(Schema.optional),
})
export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal }
export const GrepInput = Schema.Struct({
pattern: Schema.String,
include: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
limit: ResultLimit.pipe(Schema.optional),
})
export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal }
export class File extends Schema.Class<File>("LocationSearch.File")({
path: RelativePath,
canonical: Schema.String,
resource: Schema.String,
mtime: Schema.Number,
}) {}
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
text: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}) {}
export class Match extends Schema.Class<Match>("LocationSearch.Match")({
path: RelativePath,
canonical: Schema.String,
resource: Schema.String,
lines: Schema.String,
linePreviewTruncated: Schema.Boolean,
line: PositiveInt,
offset: NonNegativeInt,
submatches: Schema.Array(Submatch),
mtime: Schema.Number,
}) {}
export class FilesResult extends Schema.Class<FilesResult>("LocationSearch.FilesResult")({
items: Schema.Array(File),
truncated: Schema.Boolean,
partial: Schema.Boolean,
}) {}
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
items: Schema.Array(Match),
truncated: Schema.Boolean,
partial: Schema.Boolean,
}) {}
export interface Interface {
readonly files: (input: FilesInput) => Effect.Effect<FilesResult, Ripgrep.Error>
readonly grep: (input: GrepInput) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationSearch") {}
const slash = (value: string) => value.replaceAll("\\", "/")
const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const ripgrep = yield* Ripgrep.Service
const resolve = Effect.fnUntraced(function* (input?: string) {
const directory = input && path.isAbsolute(input) ? path.dirname(input) : location.directory
const absolute = path.resolve(location.directory, input ?? ".")
if (!path.isAbsolute(input ?? "") && !FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new globalThis.Error("Path escapes the location"))
if (path.isAbsolute(input ?? "")) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (directory !== managed || !path.basename(absolute).startsWith("tool_"))
return yield* Effect.die(new globalThis.Error("Absolute path is not managed tool output"))
}
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
const root = yield* fs.realPath(directory).pipe(Effect.orDie)
if (!FSUtil.contains(root, real)) return yield* Effect.die(new globalThis.Error("Path escapes the search root"))
const info = yield* fs.stat(real).pipe(Effect.orDie)
const type =
info.type === "File" ? ("file" as const) : info.type === "Directory" ? ("directory" as const) : undefined
if (!type) return yield* Effect.die(new globalThis.Error("Search root is not a file or directory"))
return { real, root, resource: slash(path.relative(root, real)) || ".", type }
})
const candidate = Effect.fnUntraced(function* (
root: { readonly real: string; readonly root: string; readonly type: "file" | "directory" },
cwd: string,
value: string,
) {
const absolute = path.resolve(cwd, value)
const lexicallyContained =
root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real
if (!lexicallyContained) return
const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void))
if (!canonical || !FSUtil.contains(root.root, canonical)) return
const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void))
if (!info || info.type !== "File") return
const relative = slash(path.relative(root.root, canonical))
return {
path: RelativePath.make(relative),
canonical,
resource: relative,
mtime: info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
),
}
})
return Service.of({
files: Effect.fn("LocationSearch.files")(function* (input) {
const root = yield* resolve(input.path)
if (root.type !== "directory")
return yield* Effect.die(new globalThis.Error("Files search path must be a directory"))
const result = yield* ripgrep.files({
cwd: root.real,
pattern: input.pattern,
limit: cap(input.limit),
signal: input.signal,
})
const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), {
concurrency: 16,
})
const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item))
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
return new FilesResult({
items,
truncated: result.truncated,
partial: result.partial || items.length !== result.items.length,
})
}),
grep: Effect.fn("LocationSearch.grep")(function* (input) {
const root = yield* resolve(input.path)
const cwd = root.type === "directory" ? root.real : path.dirname(root.real)
const result = yield* ripgrep.grep({
cwd,
pattern: input.pattern,
include: input.include,
file: root.type === "file" ? path.basename(root.real) : undefined,
limit: cap(input.limit),
signal: input.signal,
})
const candidates = new Map<string, ReturnType<typeof candidate>>()
for (const item of result.items) {
if (!candidates.has(item.path.text)) {
candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text)))
}
}
const mapped = yield* Effect.forEach(
result.items,
(item) =>
candidates.get(item.path.text)!.pipe(
Effect.map(
(file) =>
file &&
new Match({
...file,
lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH),
linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH,
line: item.line_number,
offset: item.absolute_offset,
submatches: item.submatches.map(
(submatch) =>
new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }),
),
}),
),
),
{ concurrency: 16 },
)
const items = mapped.filter((item): item is Match => item !== undefined)
// TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering.
// TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical.
return new GrepResult({
items,
truncated: result.truncated,
partial: result.partial || items.length !== result.items.length,
})
}),
})
}),
)
+3 -4
View File
@@ -5,11 +5,10 @@ import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
export const Ref = Schema.Struct({
export class Ref extends Schema.Class<Ref>("Location.Ref")({
directory: AbsolutePath,
workspaceID: Schema.optional(WorkspaceV2.ID),
}).annotate({ identifier: "Location.Ref" })
export type Ref = typeof Ref.Type
workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))),
}) {}
export class Info extends Schema.Class<Info>("Location.Info")({
directory: AbsolutePath,
+1 -10
View File
@@ -84,16 +84,7 @@ const profiles = new Map<string, Profile>([
]),
},
],
[
"@ai-sdk/anthropic",
{
namespace: "anthropic",
semantics: new Map([
["thinking", "thinking"],
["effort", "effort"],
]),
},
],
["@ai-sdk/anthropic", { namespace: "anthropic", semantics: new Map([["thinking", "thinking"]]) }],
])
export const namespace = (packageName: string) => profiles.get(packageName)?.namespace
-25
View File
@@ -43,30 +43,6 @@ const Cost = Schema.Struct({
),
})
// models.dev curates reasoning_options per provider model as a discriminated
// union. The shape is expected to evolve, so stay lenient where it can grow:
// effort values are open strings (tiers like "xhigh" were added over time) and
// budget bounds are optional. The api.json payload is cast, never decoded, so
// option types this union doesn't know about yet can appear at runtime;
// consumers must filter for the types they understand instead of matching
// exhaustively.
export const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("effort"),
// null means the provider accepts an explicit "no reasoning" effort.
values: Schema.Array(Schema.NullOr(Schema.String)),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export type ReasoningOption = Schema.Schema.Type<typeof ReasoningOption>
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@@ -74,7 +50,6 @@ export const Model = Schema.Struct({
release_date: Schema.String,
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
interleaved: Schema.optional(
-8
View File
@@ -25,14 +25,6 @@ type HookSpec = {
input: Catalog.Editor
output: {}
}
"account.switched": {
input: {
serviceID: import("./auth").Auth.ServiceID
from?: import("./auth").Auth.ID
to?: import("./auth").Auth.ID
}
output: {}
}
"aisdk.language": {
input: {
model: ModelV2.Info
-45
View File
@@ -1,45 +0,0 @@
import { Effect, Scope, Stream } from "effect"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
import { Auth } from "../auth"
// Depending on what account is active, enable matching providers for that
// service
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
const accounts = yield* Auth.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
yield* events.subscribe(Auth.Event.Switched).pipe(
Stream.runForEach((event) =>
PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
),
Effect.forkIn(scope, { startImmediately: true }),
)
return {
"catalog.transform": Effect.fn(function* (evt) {
const active = yield* accounts.activeAll().pipe(Effect.orDie)
if (active.size === 0) return
for (const item of evt.provider.list()) {
const account = active.get(Auth.ServiceID.make(item.provider.id))
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "account",
service: account.serviceID,
}
if (account.credential.type === "api") {
provider.request.body.apiKey = account.credential.key
Object.assign(provider.request.body, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") provider.request.body.apiKey = account.credential.access
})
}
}),
"account.switched": Effect.fn(function* () {}),
}
}),
})
+1 -1
View File
@@ -79,7 +79,7 @@ Your output must be:
"implement rate limiting" -> Rate limiting implementation
"how do I connect postgres to my API" -> Postgres API connection
"best practices for React hooks" -> React hooks best practices
"@src/auth.ts can you add refresh token support" -> Auth refresh token support
"@src/credential.ts can you add refresh token support" -> Credential refresh token support
"@utils/parser.ts this is broken" -> Parser bug fix
"look at @config.json" -> Config review
"@App.tsx add dark mode toggle" -> Dark mode toggle in App
+9 -6
View File
@@ -1,7 +1,8 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { Auth } from "../auth"
import { Credential } from "../credential"
import { Connector } from "../connector"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { CommandV2 } from "../command"
@@ -17,7 +18,6 @@ import { Location } from "../location"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { SkillPlugin } from "./skill"
@@ -33,7 +33,8 @@ type Plugin = {
effect: PluginV2.Effect<
| Catalog.Service
| CommandV2.Service
| Auth.Service
| Credential.Service
| Connector.Service
| AgentV2.Service
| Npm.Service
| EventV2.Service
@@ -60,7 +61,8 @@ export const layer = Layer.effect(
const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service
const plugin = yield* PluginV2.Service
const accounts = yield* Auth.Service
const credentials = yield* Credential.Service
const connectors = yield* Connector.Service
const agents = yield* AgentV2.Service
const config = yield* Config.Service
const location = yield* Location.Service
@@ -79,7 +81,8 @@ export const layer = Layer.effect(
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(CommandV2.Service, commands),
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Credential.Service, credentials),
Effect.provideService(Connector.Service, connectors),
Effect.provideService(AgentV2.Service, agents),
Effect.provideService(Config.Service, config),
Effect.provideService(Location.Service, location),
@@ -97,7 +100,6 @@ export const layer = Layer.effect(
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AccountPlugin)
yield* add(AgentPlugin.Plugin)
yield* add(CommandPlugin.Plugin)
yield* add(SkillPlugin.Plugin)
@@ -125,6 +127,7 @@ export const layer = Layer.effect(
)
export const locationLayer = layer.pipe(
Layer.provideMerge(Connector.locationLayer),
Layer.provideMerge(Catalog.locationLayer),
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
+23 -18
View File
@@ -1,12 +1,13 @@
import { DateTime, Effect, Scope, Stream } from "effect"
import { Catalog } from "../catalog"
import { Connector } from "../connector"
import { Credential } from "../credential"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelRequest } from "../model-request"
import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
import { ReasoningVariants } from "../reasoning-variants"
function released(date: string) {
const time = Date.parse(date)
@@ -40,8 +41,8 @@ function cost(input: ModelsDev.Model["cost"]) {
]
}
function variants(model: ModelsDev.Model, providerID: string, packageName?: string) {
const modes = Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => {
function variants(model: ModelsDev.Model, packageName?: string) {
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => {
const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {})
return {
id: ModelV2.VariantID.make(id),
@@ -49,32 +50,36 @@ function variants(model: ModelsDev.Model, providerID: string, packageName?: stri
...request,
}
})
// reasoning_options effort data generates effort variants with the same wire
// encodings the v1 catalog uses; curated experimental modes win on id collision.
const efforts = ReasoningVariants.fromOptions(
{ npm: packageName, apiID: model.id, modelID: model.id, providerID },
model.reasoning_options,
)
const fromEfforts = Object.entries(efforts ?? {})
.filter(([id]) => !modes.some((mode) => mode.id === id))
.map(([id, body]) => ({
id: ModelV2.VariantID.make(id),
headers: {},
...ModelRequest.normalizeAiSdkOptions(packageName, body),
}))
return [...modes, ...fromEfforts]
}
export const ModelsDevPlugin = PluginV2.define({
id: PluginV2.ID.make("models-dev"),
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const connectors = yield* Connector.Service
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const transform = yield* catalog.transform()
const connectorTransform = yield* connectors.transform()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
yield* connectorTransform((connectors) => {
for (const item of Object.values(data)) {
if (item.env.length === 0) continue
const connectorID = Connector.ID.make(item.id)
connectors.update(connectorID, (connector) => (connector.name = item.name))
connectors.method.update({
connectorID,
method: new Connector.KeyMethod({
id: Connector.MethodID.make("api-key"),
type: "key",
label: "API Key",
}),
authorize: (key: string) => Effect.succeed(new Credential.Key({ type: "key", key })),
})
}
})
yield* transform((catalog) => {
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
@@ -117,7 +122,7 @@ export const ModelsDevPlugin = PluginV2.define({
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = variants(model, item.id, model.provider?.npm ?? item.npm)
draft.variants = variants(model, model.provider?.npm ?? item.npm)
draft.time.released = released(model.release_date)
draft.cost = cost(model.cost)
draft.status = model.status ?? "active"
@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
// AccountPlugin copies CLI prompt metadata into options. The prompt stores the
// Credential projection copies key metadata into options. The prompt stores the
// gateway as gatewayId, while older config examples may use gateway.
const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
@@ -24,9 +24,15 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
if (evt.model.providerID !== providerID) return
if (evt.package !== "@ai-sdk/openai-compatible") return
if (!hasWorkersEndpoint(evt.model.api)) return
const accountId = resolveAccountId(evt.options)
if (!hasWorkersEndpoint(evt.model.api) && !accountId) return
const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible"))
evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any)
evt.sdk = mod.createOpenAICompatible(
sdkOptions({
...evt.options,
baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined),
}) as any,
)
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
@@ -0,0 +1,252 @@
import { createServer } from "node:http"
import { Deferred, Effect } from "effect"
import { Connector } from "../../connector"
import { Credential } from "../../credential"
import { InstallationVersion } from "../../installation/version"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
const issuer = "https://auth.openai.com"
const callbackPort = 1455
const pollingSafetyMargin = 3000
type Pkce = {
verifier: string
challenge: string
}
type TokenResponse = {
id_token: string
access_token: string
refresh_token: string
expires_in?: number
}
type Claims = {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
"https://api.openai.com/auth"?: { chatgpt_account_id?: string }
}
export const browser = {
connectorID: Connector.ID.make("openai"),
method: new Connector.OAuthMethod({
id: Connector.MethodID.make("chatgpt-browser"),
type: "oauth",
label: "ChatGPT Pro/Plus (browser)",
}),
authorize: () =>
Effect.gen(function* () {
const pkce = yield* Effect.promise(generatePKCE)
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const code = yield* Deferred.make<string, Error>()
const redirect = `http://localhost:${callbackPort}/auth/callback`
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
if (url.pathname !== "/auth/callback") {
response.writeHead(404).end("Not found")
return
}
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
const value = url.searchParams.get("code")
if (error) {
Effect.runFork(Deferred.fail(code, new Error(error)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error))
return
}
if (!value || url.searchParams.get("state") !== state) {
const message = value ? "Invalid OAuth state" : "Missing authorization code"
Effect.runFork(Deferred.fail(code, new Error(message)))
response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message))
return
}
Effect.runFork(Deferred.succeed(code, value))
response.writeHead(200, { "Content-Type": "text/html" }).end(successPage)
})
yield* Effect.callback<void, Error>((resume) => {
server.once("error", (error) => resume(Effect.fail(error)))
server.listen(callbackPort, "localhost", () => resume(Effect.void))
})
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
server.close()
}),
)
return {
mode: "auto" as const,
url: authorizeURL(redirect, pkce, state),
instructions: "Complete authorization in your browser. This window will close automatically.",
callback: Deferred.await(code).pipe(
Effect.flatMap((value) => exchange(value, redirect, pkce)),
Effect.map(credential),
),
}
}),
refresh: (value) => refresh(value),
} satisfies Connector.OAuthImplementation
export const headless = {
connectorID: Connector.ID.make("openai"),
method: new Connector.OAuthMethod({
id: Connector.MethodID.make("chatgpt-headless"),
type: "oauth",
label: "ChatGPT Pro/Plus (headless)",
}),
authorize: () =>
Effect.gen(function* () {
const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>(
`${issuer}/api/accounts/deviceauth/usercode`,
{
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ client_id: clientID }),
},
)
const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000
return {
mode: "auto" as const,
url: `${issuer}/codex/device`,
instructions: `Enter code: ${device.user_code}`,
callback: Effect.gen(function* () {
while (true) {
const response = yield* Effect.tryPromise({
try: (signal) =>
fetch(`${issuer}/api/accounts/deviceauth/token`, {
method: "POST",
headers: headers("application/json"),
body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }),
signal,
}),
catch: (cause) => cause,
})
if (response.ok) {
const data = (yield* Effect.promise(() => response.json())) as {
authorization_code: string
code_verifier: string
}
return credential(
yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, {
verifier: data.code_verifier,
challenge: "",
}),
)
}
if (response.status !== 403 && response.status !== 404) {
return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`))
}
yield* Effect.sleep(interval + pollingSafetyMargin)
}
}),
}
}),
refresh: (value) => refresh(value),
} satisfies Connector.OAuthImplementation
function headers(contentType: string) {
return { "Content-Type": contentType, "User-Agent": `opencode/${InstallationVersion}` }
}
function exchange(code: string, redirect: string, pkce: Pkce) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirect,
client_id: clientID,
code_verifier: pkce.verifier,
}).toString(),
})
}
function refresh(value: Credential.OAuth) {
return request<TokenResponse>(`${issuer}/oauth/token`, {
method: "POST",
headers: headers("application/x-www-form-urlencoded"),
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: value.refresh,
client_id: clientID,
}).toString(),
}).pipe(
Effect.map((tokens) => {
const next = credential(tokens)
return new Credential.OAuth({
...next,
metadata: next.metadata ?? value.metadata,
})
}),
)
}
function request<A>(url: string, init: RequestInit) {
return Effect.tryPromise({
try: async (signal) => {
const response = await fetch(url, { ...init, signal })
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
return response.json() as Promise<A>
},
catch: (cause) => cause,
})
}
function credential(tokens: TokenResponse) {
const accountID = extractAccountID(tokens)
return new Credential.OAuth({
type: "oauth",
refresh: tokens.refresh_token,
access: tokens.access_token,
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
metadata: accountID ? { accountID } : undefined,
})
}
async function generatePKCE(): Promise<Pkce> {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer) {
return Buffer.from(buffer).toString("base64url")
}
function authorizeURL(redirect: string, pkce: Pkce, state: string) {
return `${issuer}/oauth/authorize?${new URLSearchParams({
response_type: "code",
client_id: clientID,
redirect_uri: redirect,
scope: "openid profile email offline_access",
code_challenge: pkce.challenge,
code_challenge_method: "S256",
id_token_add_organizations: "true",
codex_cli_simplified_flow: "true",
state,
originator: "opencode",
})}`
}
function extractAccountID(tokens: TokenResponse) {
return claim(tokens.id_token) ?? claim(tokens.access_token)
}
function claim(token: string) {
const part = token.split(".")[1]
if (!part) return
try {
const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims
return (
claims.chatgpt_account_id ??
claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
claims.organizations?.[0]?.id
)
} catch {
return
}
}
const successPage =
"<!doctype html><title>OpenCode</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>OpenCode</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
@@ -2,10 +2,17 @@ import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
import { Connector } from "../../connector"
import { browser, headless } from "./openai-auth"
export const OpenAIPlugin = PluginV2.define({
id: PluginV2.ID.make("openai"),
effect: Effect.gen(function* () {
const connectors = yield* Connector.Service
yield* connectors.update((editor) => {
editor.method.update(browser)
editor.method.update(headless)
})
return {
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/openai") return
@@ -14,7 +14,7 @@ export const OpencodePlugin = PluginV2.define({
process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) ||
item.provider.request.body.apiKey ||
(item.provider.enabled && item.provider.enabled.via === "account"),
(item.provider.enabled && item.provider.enabled.via === "credential"),
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.request.body.apiKey = "public"
@@ -73,6 +73,19 @@ Every field is optional.
"urls": ["https://example.com/.well-known/skills/"]
},
"references": {
"docs": {
"path": "../docs",
"description": "Use for product behavior and documentation conventions"
},
"sdk": {
"repository": "owner/sdk",
"branch": "main",
"description": "Use for SDK implementation details",
"hidden": true
}
},
"agent": {
"my-agent": {
"model": "anthropic/claude-sonnet-4-6",
@@ -136,6 +149,7 @@ Shape notes worth being explicit about:
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
@@ -172,6 +186,38 @@ Register skills from non-default locations via `skills.paths` (scanned
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
skills).
## References
References make local directories and Git repositories outside the active
project available as supporting context. Configure them under `references`,
keyed by the alias used in `@` autocomplete:
```json
{
"references": {
"docs": {
"path": "../product-docs",
"description": "Use for product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
"description": "Use for Effect implementation details"
}
}
}
```
Local `path` values may be relative to the declaring config, absolute, or use
`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub
`owner/repo` shorthand; `branch` is optional. Both forms support optional
`description` and `hidden` fields.
- Only references with a `description` are advertised to agents in system context.
- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path.
- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply.
- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories.
## Agents
Two ways to define an agent. Use the file form for anything non-trivial.
+3 -2
View File
@@ -2,6 +2,7 @@ export * as ProviderV2 from "./provider"
import { withStatics } from "./schema"
import { Schema } from "effect"
import { Credential } from "./credential"
export const ID = Schema.String.pipe(
Schema.brand("ProviderV2.ID"),
@@ -54,8 +55,8 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("account"),
service: Schema.String,
via: Schema.Literal("credential"),
credentialID: Credential.ID,
}),
Schema.Struct({
via: Schema.Literal("custom"),
+2 -3
View File
@@ -32,7 +32,8 @@ class SessionModelValidation extends Context.Service<
}
>()("@opencode/public/OpenCode/SessionModelValidation") {}
const LocationServicesLayer = LocationServiceMap.layer
const ApplicationToolsLayer = ApplicationTools.layer
const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer))
const SessionModelValidationLayer = Layer.effect(
SessionModelValidation,
Effect.gen(function* () {
@@ -78,8 +79,6 @@ const SessionsLayer = Layer.merge(
),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
const ApplicationToolsLayer = ApplicationTools.layer
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
export const layer = Layer.effect(
Service,
-187
View File
@@ -1,187 +0,0 @@
export * as ReasoningVariants from "./reasoning-variants"
// Generates reasoning variants from models.dev `reasoning_options` data. The
// data only says WHICH efforts a model supports - the wire encoding for each
// SDK package lives here so the v1 provider catalog and the v2 catalog plugin
// stay in lockstep. Null effort values and option types we don't understand
// (toggle, budget_tokens, future additions) are ignored, so models without
// usable effort data return undefined and callers fall back to their own
// defaults.
// OpenAI Responses `include` value that returns the encrypted reasoning state
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
// branch that requests it stays in lockstep.
export const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
export interface Target {
readonly npm?: string
readonly apiID: string
readonly modelID: string
readonly providerID: string
}
export function fromOptions(
target: Target,
options: ReadonlyArray<{ readonly type: string; readonly values?: ReadonlyArray<string | null> }> | undefined,
): Record<string, Record<string, unknown>> | undefined {
const efforts = [
...new Set(
(options ?? [])
.flatMap((option) => (option.type === "effort" ? (option.values ?? []) : []))
.filter((value): value is string => typeof value === "string"),
),
]
if (efforts.length === 0) return undefined
return effortVariants(target, efforts)
}
export function anthropicOpus47OrLater(apiID: string) {
// Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted).
// Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility.
const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiID)
if (!version) return false
const major = Number(version[1] ?? version[3])
const minor = Number(version[2] ?? version[4])
return major > 4 || (major === 4 && minor >= 7)
}
export function anthropicAdaptiveEfforts(apiID: string): string[] | null {
if (anthropicOpus47OrLater(apiID) || apiID.includes("fable-5")) {
return ["low", "medium", "high", "xhigh", "max"]
}
if (
["opus-4-6", "opus-4.6", "4-6-opus", "4.6-opus", "sonnet-4-6", "sonnet-4.6", "4-6-sonnet", "4.6-sonnet"].some((v) =>
apiID.includes(v),
)
) {
return ["low", "medium", "high", "max"]
}
return null
}
export function anthropicOmitsThinking(apiID: string) {
return anthropicOpus47OrLater(apiID) || apiID.includes("fable-5")
}
// SAP's Zod schema drops unknown top-level keys; reasoning controls survive
// only via `modelParams` (catchall), forwarded verbatim by the SAP SDKs.
export function wrapInSapModelParams(
variants: Record<string, Record<string, unknown>>,
): Record<string, Record<string, unknown>> {
return Object.fromEntries(Object.entries(variants).map(([k, v]) => [k, { modelParams: v }]))
}
function copilotAnthropicEfforts(apiID: string, efforts: string[]) {
// Efforts currently supported by copilot are: low, medium, high
if (apiID.includes("opus-4.7")) return ["medium"]
return efforts.filter((v) => v !== "max" && v !== "xhigh")
}
function anthropicEffortVariants(target: Target, efforts: string[]): Record<string, Record<string, unknown>> {
const filtered = target.providerID === "github-copilot" ? copilotAnthropicEfforts(target.apiID, efforts) : efforts
const adaptive = anthropicAdaptiveEfforts(target.apiID) !== null
return Object.fromEntries(
filtered.map((effort) => [
effort,
adaptive
? {
thinking: {
type: "adaptive",
// Newer adaptive-only models default `display` to "omitted", which
// returns empty thinking blocks. Force "summarized" so summaries
// survive (4.6/Sonnet 4.6 already default to "summarized").
...(anthropicOmitsThinking(target.apiID) ? { display: "summarized" } : {}),
},
effort,
}
: { effort },
]),
)
}
function effortVariants(target: Target, efforts: string[]): Record<string, Record<string, unknown>> {
const fromEffort = (encode: (effort: string) => Record<string, unknown>) =>
Object.fromEntries(efforts.map((effort) => [effort, encode(effort)]))
switch (target.npm) {
case "@openrouter/ai-sdk-provider":
return fromEffort((effort) => ({ reasoning: { effort } }))
case "@ai-sdk/gateway":
if (target.modelID.includes("anthropic")) return anthropicEffortVariants(target, efforts)
if (target.modelID.includes("google"))
return fromEffort((effort) => ({ includeThoughts: true, thinkingLevel: effort }))
return fromEffort((effort) => ({ reasoningEffort: effort }))
case "@ai-sdk/github-copilot":
// currently github copilot only returns thinking
if (target.modelID.includes("gemini")) return {}
if (target.modelID.includes("claude")) return fromEffort((effort) => ({ reasoningEffort: effort }))
return fromEffort((effort) => ({
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
}))
case "@ai-sdk/azure":
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/openai":
return fromEffort((effort) => ({
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
}))
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return anthropicEffortVariants(target, efforts)
case "@ai-sdk/amazon-bedrock":
if (anthropicAdaptiveEfforts(target.apiID)) {
return fromEffort((effort) => ({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(anthropicOmitsThinking(target.apiID) ? { display: "summarized" } : {}),
},
}))
}
return fromEffort((effort) => ({
reasoningConfig: {
type: "enabled",
maxReasoningEffort: effort,
},
}))
case "@ai-sdk/google-vertex":
case "@ai-sdk/google":
return fromEffort((effort) => ({ thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }))
case "@jerome-benoit/sap-ai-provider-v2": {
if (target.modelID.toLowerCase().includes("anthropic")) {
const adaptive = anthropicAdaptiveEfforts(target.apiID) !== null
// Bedrock-flavored Anthropic splits `effort` out into `output_config` (vs
// Anthropic native which inlines it).
return wrapInSapModelParams(
fromEffort((effort) =>
adaptive
? {
thinking: {
type: "adaptive",
...(anthropicOmitsThinking(target.apiID) ? { display: "summarized" } : {}),
},
output_config: { effort },
}
: { output_config: { effort } },
),
)
}
return wrapInSapModelParams(fromEffort((effort) => ({ reasoning_effort: effort })))
}
}
// OpenAI-compatible `reasoning_effort` is the dominant convention; it covers
// @ai-sdk/openai-compatible, cerebras, togetherai, xai, deepinfra, venice,
// mistral, groq, ai-gateway-provider, and unknown future packages.
return fromEffort((effort) => ({ reasoningEffort: effort }))
}
+32 -8
View File
@@ -9,21 +9,19 @@ import { RepositoryCache } from "./repository-cache"
import { AbsolutePath } from "./schema"
import { State } from "./state"
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
source: Schema.suspend(() => Source),
}) {}
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
type: Schema.Literal("local"),
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
type: Schema.Literal("git"),
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
}) {}
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
@@ -33,6 +31,14 @@ export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
}
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
source: Source,
}) {}
type Data = {
sources: Map<string, Source>
}
@@ -71,7 +77,16 @@ export const layer = Layer.effect(
const seen = new Map<string, string | undefined>()
for (const [name, source] of editor.list()) {
if (source.type === "local") {
materialized.set(name, new Info({ name, path: source.path, source }))
materialized.set(
name,
new Info({
name,
path: source.path,
description: source.description,
hidden: source.hidden,
source,
}),
)
continue
}
const repository = Repository.parse(source.repository)
@@ -86,7 +101,16 @@ export const layer = Layer.effect(
const target = Repository.cachePath(global.repos, repository)
if (seen.has(target) && seen.get(target) !== source.branch) continue
seen.set(target, source.branch)
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
materialized.set(
name,
new Info({
name,
path: AbsolutePath.make(target),
description: source.description,
hidden: source.hidden,
source,
}),
)
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
+69
View File
@@ -0,0 +1,69 @@
export * as ReferenceGuidance from "./guidance"
import { Context, Effect, Layer, Schema } from "effect"
import { PluginBoot } from "../plugin/boot"
import { Reference } from "../reference"
import { SystemContext } from "../system-context/index"
const Summary = Schema.Struct({
name: Schema.String,
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
})
const render = (references: ReadonlyArray<typeof Summary.Type>) =>
[
"Project references provide additional directories that can be accessed when relevant.",
"<available_references>",
...references.flatMap((reference) => [
" <reference>",
` <name>${reference.name}</name>`,
` <path>${reference.path}</path>`,
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
" </reference>",
]),
"</available_references>",
].join("\n")
export interface Interface {
readonly load: () => Effect.Effect<SystemContext.SystemContext>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ReferenceGuidance") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const boot = yield* PluginBoot.Service
const references = yield* Reference.Service
return Service.of({
load: Effect.fn("ReferenceGuidance.load")(function* () {
yield* boot.wait()
const available = (yield* references.list())
.filter((reference) => reference.description !== undefined)
.map((reference) => ({
name: reference.name,
path: reference.path,
description: reference.description,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
if (available.length === 0) return SystemContext.empty
return SystemContext.make({
key: SystemContext.Key.make("core/reference-guidance"),
codec: Schema.toCodecJson(Schema.Array(Summary)),
load: Effect.succeed(available),
baseline: render,
update: (_previous, current) =>
[
"The available project references have changed. This list supersedes the previous reference list.",
render(current),
].join("\n"),
removed: () => "Project reference guidance is no longer available. Do not use previously listed references.",
})
}),
})
}),
)
export const locationLayer = layer
+125 -30
View File
@@ -2,20 +2,23 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep"
import path from "path"
import { Entry, Match } from "./filesystem/schema"
import { FSUtil } from "./fs-util"
import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt } from "./schema"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary"
/**
* Small core-owned ripgrep execution adapter. It deliberately exposes raw
* process-oriented rows, not model text or permission behavior. LocationSearch
* supplies read authority and bounded substrate results; future leaf tools own
* process-oriented rows, not model text or permission behavior. Search maps
* these rows into filesystem results; leaf tools own
* presentation and permission prompts.
*/
const ERROR_BYTES = 8 * 1024
export const MAX_RECORD_BYTES = 64 * 1024
export const MAX_SUBMATCHES = 100
const MAX_RECORD_BYTES = 64 * 1024
const MAX_SUBMATCHES = 100
const RawMatch = Schema.Struct({
type: Schema.Literal("match"),
@@ -34,7 +37,7 @@ const RawMatch = Schema.Struct({
}),
})
export type Match = (typeof RawMatch.Type)["data"]
type RawMatchData = (typeof RawMatch.Type)["data"]
export class Error extends Schema.TaggedErrorClass<Error>()("Ripgrep.Error", {
message: Schema.String,
@@ -46,16 +49,22 @@ export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternE
message: Schema.String,
}) {}
export interface Result<A> {
readonly items: A[]
readonly truncated: boolean
readonly partial: boolean
}
export interface FilesInput {
export interface FindInput {
readonly cwd: string
readonly pattern: string
readonly limit: number
readonly hidden?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
readonly onEntry?: (entry: Entry) => Effect.Effect<void>
}
export interface GlobInput {
readonly cwd: string
readonly pattern: string
readonly limit: number
readonly hidden?: boolean
readonly follow?: boolean
readonly signal?: AbortSignal
}
@@ -69,8 +78,9 @@ export interface GrepInput {
}
export interface Interface {
readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[], Error>
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[], Error | InvalidPatternError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Ripgrep") {}
@@ -84,7 +94,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const process = yield* AppProcess.Service
const binary = yield* FileSystemRipgrep.Service
const binary = yield* RipgrepBinary.Service
const run = <A>(input: {
readonly cwd: string
@@ -93,6 +103,7 @@ export const layer = Layer.effect(
readonly signal?: AbortSignal
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
readonly pattern?: string
readonly onItem?: (item: A) => Effect.Effect<void>
}) => {
const program = Effect.scoped(
Effect.gen(function* () {
@@ -103,11 +114,16 @@ export const layer = Layer.effect(
Effect.map((output) => output.buffer.toString("utf8")),
Effect.forkScoped,
)
let observed = 0
const rows = yield* Stream.decodeText(handle.stdout).pipe(
Stream.splitLines,
Stream.filter((line) => line.length > 0),
Stream.mapEffect(input.parse),
Stream.filter((row): row is A => row !== undefined),
Stream.tap((row) => {
if (!input.onItem || observed++ >= input.limit) return Effect.void
return input.onItem(row)
}),
Stream.take(input.limit + 1),
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
@@ -137,31 +153,82 @@ export const layer = Layer.effect(
}
return Service.of({
files: (input) =>
glob: (input) =>
run<string>({
...input,
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
`--glob=${input.pattern}`,
"--glob=!.*",
"--glob=!**/.*",
"--glob=!**/.git/**",
".",
],
parse: (line) => Effect.succeed(line.replace(/^\.\//, "")),
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
parse: (line) =>
Effect.succeed(
line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/"),
),
}).pipe(
Effect.map((result) =>
result.items.map((relative) => {
const absolute = path.resolve(input.cwd, relative)
return new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(absolute),
})
}),
),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
find: (input) =>
run<Entry>({
cwd: input.cwd,
limit: input.limit,
signal: input.signal,
args: [
"--no-config",
"--files",
...(input.hidden ? ["--hidden"] : []),
...(input.follow ? ["--follow"] : []),
...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]),
"--glob=!**/.git/**",
".",
],
parse: (line) => {
const relative = line
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
return Effect.succeed(
new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(path.resolve(input.cwd, relative)),
}),
)
},
onItem: input.onEntry,
}).pipe(
Effect.map((result) => result.items),
Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))),
),
grep: (input) =>
run<Match>({
run<RawMatchData>({
...input,
args: [
"--no-config",
"--json",
"--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure.
"--hidden",
"--no-messages",
...(input.include ? [`--glob=${input.include}`] : []),
"--glob=!.*",
"--glob=!**/.*",
"--glob=!**/.git/**",
"--",
input.pattern,
input.file ?? ".",
@@ -180,13 +247,41 @@ export const layer = Layer.effect(
return Schema.decodeUnknownEffect(RawMatch)(json).pipe(
Effect.map((match) => ({
...match.data,
path: { text: match.data.path.text.replace(/^\.[\\/]/, "") },
submatches: match.data.submatches.slice(0, MAX_SUBMATCHES),
})),
Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)),
)
}),
),
}),
}).pipe(
Effect.map((result) =>
result.items.map((match) => {
const relative = match.path.text
.replace(/^(?:\.[\\/])+/u, "")
.replace(/^[\\/]+/u, "")
.replaceAll("\\", "/")
const absolute = path.resolve(input.cwd, relative)
return new Match({
entry: new Entry({
path: RelativePath.make(relative),
type: "file",
mime: FSUtil.mimeType(absolute),
}),
line: match.line_number,
offset: match.absolute_offset,
text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text,
submatches: match.submatches.map((submatch) => ({
text: submatch.match.text,
start: submatch.start,
end: submatch.end,
})),
})
}),
),
),
})
}),
).pipe(Layer.provide(FileSystemRipgrep.defaultLayer))
)
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
+130
View File
@@ -0,0 +1,130 @@
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { which } from "../util/which"
export namespace RipgrepBinary {
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
"arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
"ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" },
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
} as const
interface Interface {
readonly filepath: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/RipgrepBinary") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const spawner = yield* ChildProcessSpawner
const run = Effect.fnUntraced(function* (command: string, args: string[]) {
const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" }))
const [stdout, stderr, code] = yield* Effect.all(
[
Stream.mkString(Stream.decodeText(handle.stdout)),
Stream.mkString(Stream.decodeText(handle.stderr)),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return { stdout, stderr, code }
}, Effect.scoped)
const extract = Effect.fnUntraced(function* (
archive: string,
config: (typeof PLATFORM)[keyof typeof PLATFORM],
target: string,
) {
const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" })
if (config.extension === "zip") {
const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe"
const result = yield* run(shell, [
"-NoProfile",
"-NonInteractive",
"-Command",
`$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`,
])
if (result.code !== 0)
throw new Error(
result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`,
)
}
if (config.extension === "tar.gz") {
const result = yield* run("tar", ["-xzf", archive, "-C", dir])
if (result.code !== 0)
throw new Error(
result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`,
)
}
const extracted = path.join(
dir,
`ripgrep-${VERSION}-${config.platform}`,
process.platform === "win32" ? "rg.exe" : "rg",
)
if (!(yield* fs.isFile(extracted))) throw new Error(`ripgrep archive did not contain executable: ${extracted}`)
yield* fs.copyFile(extracted, target)
if (process.platform !== "win32") yield* fs.chmod(target, 0o755)
}, Effect.scoped)
return Service.of({
filepath: yield* Effect.cached(
Effect.gen(function* () {
const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg"))
if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system
const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`)
if (yield* fs.isFile(target).pipe(Effect.orDie)) return target
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
const config = PLATFORM[platformKey]
if (!config) throw new Error(`unsupported platform for ripgrep: ${platformKey}`)
const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}`
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))),
)
if (bytes.byteLength === 0) throw new Error(`failed to download ripgrep from ${url}`)
yield* fs.writeWithDirs(archive, new Uint8Array(bytes))
yield* extract(archive, config, target)
yield* fs.remove(archive, { force: true }).pipe(Effect.ignore)
return target
}),
),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
}
+5 -3
View File
@@ -19,6 +19,7 @@ import { QuestionV2 } from "../../question"
import { SystemContext } from "../../system-context/index"
import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ReferenceGuidance } from "../../reference/guidance"
import { ToolRegistry } from "../../tool/registry"
import { ToolOutputStore } from "../../tool-output-store"
import { SessionContextEpoch } from "../context-epoch"
@@ -98,6 +99,7 @@ export const layer = Layer.effect(
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const referenceGuidance = yield* ReferenceGuidance.Service
const config = yield* Config.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
@@ -166,9 +168,9 @@ export const layer = Layer.effect(
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
const loadSystemContext = (agent: AgentV2.Selection) =>
Effect.all([systemContext.load(), skillGuidance.load(agent)], { concurrency: "unbounded" }).pipe(
Effect.map(SystemContext.combine),
)
Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load()], {
concurrency: "unbounded",
}).pipe(Effect.map(SystemContext.combine))
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
+43 -23
View File
@@ -2,7 +2,11 @@ export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { LocationSearch } from "../location-search"
import path from "path"
import { FileSystem } from "../filesystem"
import { Location } from "../location"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
import { Tools } from "./tools"
@@ -10,38 +14,30 @@ import { Tools } from "./tools"
export const name = "glob"
export const Input = Schema.Struct({
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: LocationSearch.FilesInput.fields.path.annotate({
pattern: FileSystem.GlobInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
limit: LocationSearch.FilesInput.fields.limit.annotate({
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
limit: FileSystem.GlobInput.fields.limit.annotate({
description: "Maximum results to return",
}),
})
type ModelOutput = typeof LocationSearch.FilesResult.Encoded
export const Output = Schema.Array(FileSystem.Entry)
type ModelOutput = typeof Output.Encoded
/** Format raw Location search results into the concise line-oriented output models expect. */
/** Format raw search results into the concise line-oriented output models expect. */
export const toModelOutput = (output: ModelOutput) => {
const lines = output.items.length === 0 ? ["No files found"] : output.items.map((item) => item.resource)
if (output.truncated) {
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} results. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Results may be incomplete because some discovered files could not be read.)")
const lines = output.length === 0 ? ["No files found"] : output.map((item) => item.path)
return lines.join("\n")
}
/**
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and traversal.
*/
/** Glob leaf that defaults its filesystem root to the active Location. */
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const search = yield* LocationSearch.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* tools
@@ -50,8 +46,15 @@ export const layer = Layer.effectDiscard(
description:
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((entry) => ({ ...entry, path: path.resolve(location.directory, entry.path) })),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -67,7 +70,24 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
return yield* search.files(input)
const cwd = path.resolve(location.directory, input.path ?? ".")
return yield* ripgrep
.glob({
cwd,
pattern: input.pattern,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map(
(entry) =>
new FileSystem.Entry({
...entry,
path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))),
}),
),
),
)
}).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
),
+72 -42
View File
@@ -2,61 +2,58 @@ export * as GrepTool from "./grep"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect"
import { LocationSearch } from "../location-search"
import { Ripgrep } from "../ripgrep"
import path from "path"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Location } from "../location"
import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema"
import { Tool } from "./tool"
import { Tools } from "./tools"
export const name = "grep"
export const Input = Schema.Struct({
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
pattern: FileSystem.GrepInput.fields.pattern.annotate({
description: "Regex pattern to search for in file contents",
}),
path: LocationSearch.GrepInput.fields.path.annotate({
description: "Relative file or directory to search. Defaults to the active Location.",
path: RelativePath.pipe(Schema.optional).annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
include: LocationSearch.GrepInput.fields.include.annotate({
include: FileSystem.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
limit: LocationSearch.GrepInput.fields.limit.annotate({
description: `Maximum matches to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
limit: FileSystem.GrepInput.fields.limit.annotate({
description: "Maximum matches to return",
}),
})
type Output = typeof LocationSearch.GrepResult.Encoded
export const Output = Schema.Array(FileSystem.Match)
type ModelOutput = typeof Output.Encoded
/** Format raw Location search matches into the familiar concise model output. */
export const toModelOutput = (output: Output) => {
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
/** Format raw search matches into the familiar concise model output. */
export const toModelOutput = (output: ModelOutput) => {
const lines = output.length === 0 ? ["No files found"] : [`Found ${output.length} matches`]
let current = ""
for (const match of output.items) {
if (current !== match.resource) {
for (const match of output) {
if (current !== match.entry.path) {
if (current) lines.push("")
current = match.resource
lines.push(`${match.resource}:`)
current = match.entry.path
lines.push(`${match.entry.path}:`)
}
lines.push(` Line ${match.line}: ${match.lines}${match.linePreviewTruncated ? "..." : ""}`)
lines.push(` Line ${match.line}: ${match.text}`)
}
if (output.truncated) {
lines.push(
"",
`(Results are truncated: showing first ${output.items.length} matches. Consider using a more specific path or pattern.)`,
)
}
if (output.partial) lines.push("", "(Some paths were inaccessible and skipped)")
return lines.join("\n")
}
/**
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and ripgrep execution.
*/
/** Grep leaf that defaults its filesystem root to the active Location. */
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const search = yield* LocationSearch.Service
const fs = yield* FSUtil.Service
const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service
const permission = yield* PermissionV2.Service
yield* tools
@@ -65,8 +62,18 @@ export const layer = Layer.effectDiscard(
description:
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
output: Output,
toModelOutput: ({ output }) => [
{
type: "text",
text: toModelOutput(
output.map((match) => ({
...match,
entry: { ...match.entry, path: path.resolve(location.directory, match.entry.path) },
})),
),
},
],
execute: (input, context) =>
Effect.gen(function* () {
yield* permission.assert({
@@ -74,7 +81,7 @@ export const layer = Layer.effectDiscard(
resources: [input.pattern],
save: ["*"],
metadata: {
root: input.path ?? ".",
root: ".",
path: input.path,
include: input.include,
limit: input.limit,
@@ -83,16 +90,39 @@ export const layer = Layer.effectDiscard(
agent: context.agent,
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
})
return yield* search.grep(input)
}).pipe(
Effect.mapError((error) => {
const message =
error instanceof Ripgrep.InvalidPatternError
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
: `Unable to grep for ${input.pattern}`
return new ToolFailure({ message })
}),
),
const target = path.resolve(location.directory, input.path ?? ".")
const info = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
return yield* ripgrep
.grep({
cwd: info?.type === "Directory" ? target : path.dirname(target),
pattern: input.pattern,
file: info?.type === "File" ? path.basename(target) : undefined,
include: input.include,
limit: input.limit ?? Number.MAX_SAFE_INTEGER,
})
.pipe(
Effect.map((result) =>
result.map(
(match) =>
new FileSystem.Match({
...match,
entry: new FileSystem.Entry({
...match.entry,
path: RelativePath.make(
path.relative(
location.directory,
path.resolve(
info?.type === "Directory" ? target : path.dirname(target),
match.entry.path,
),
),
),
}),
}),
),
),
)
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to grep for ${input.pattern}` }))),
}),
})
.pipe(Effect.orDie)
+1 -2
View File
@@ -276,8 +276,7 @@ export const list = Effect.fn("ReadTool.list")(function* (fs: FSUtil.Interface,
const type = info?.type === "Directory" ? "directory" : info?.type === "File" ? "file" : undefined
if (!type) return
return new FileSystem.Entry({
path: RelativePath.make(item.name),
uri: pathToFileURL(target).href,
path: RelativePath.make(item.name + (type === "directory" ? path.sep : "")),
type,
mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(target),
})
+5 -2
View File
@@ -3,6 +3,7 @@ export * as ConfigV1 from "./config"
import { Schema } from "effect"
import { NonNegativeInt, PositiveInt, type DeepMutable } from "../../schema"
import { ConfigExperimental } from "../../config/experimental"
import { ConfigReference } from "../../config/reference"
import { ConfigAgentV1 } from "./agent"
import { ConfigAttachmentV1 } from "./attachment"
import { ConfigCommandV1 } from "./command"
@@ -13,7 +14,6 @@ import { ConfigMCPV1 } from "./mcp"
import { ConfigPermissionV1 } from "./permission"
import { ConfigPluginV1 } from "./plugin"
import { ConfigProviderV1 } from "./provider"
import { ConfigReferenceV1 } from "./reference"
import { ConfigServerV1 } from "./server"
import { ConfigSkillsV1 } from "./skills"
@@ -42,9 +42,12 @@ export const Info = Schema.Struct({
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkillsV1.Info).annotate({ description: "Additional skill folder paths" }),
reference: Schema.optional(ConfigReferenceV1.Info).annotate({
references: Schema.optional(ConfigReference.Info).annotate({
description: "Named git or local directory references",
}),
reference: Schema.optional(ConfigReference.Info).annotate({
description: "@deprecated Use 'references' field instead. Named git or local directory references",
}),
watcher: Schema.optional(Schema.Struct({ ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))) })),
snapshot: Schema.optional(Schema.Boolean).annotate({
description:
+5
View File
@@ -32,3 +32,8 @@ export const DirectoryTypoError = NamedError.create("ConfigDirectoryTypoError",
dir: Schema.String,
suggestion: Schema.String,
})
export const RemoteAuthError = NamedError.create("ConfigRemoteAuthError", {
url: Schema.String,
remote: Schema.String,
})
+3
View File
@@ -8,6 +8,9 @@ export const Local = Schema.Struct({
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Command and arguments to run the MCP server",
}),
cwd: Schema.optional(Schema.String).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Environment variables to set when running the MCP server",
}),
+9 -2
View File
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: info.command,
instructions: info.instructions,
references: info.reference,
references: info.references ?? info.reference,
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
@@ -139,7 +139,14 @@ function mcp(info: typeof ConfigV1.Info.Type) {
function migrateMcp(info: ConfigMCPV1.Info) {
const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local")
return { type: info.type, command: info.command, environment: info.environment, disabled, timeout: info.timeout }
return {
type: info.type,
command: info.command,
cwd: info.cwd,
environment: info.environment,
disabled,
timeout: info.timeout,
}
return {
type: info.type,
url: info.url,
-22
View File
@@ -12,28 +12,6 @@ export const Model = Schema.Struct({
release_date: Schema.optional(Schema.String),
attachment: Schema.optional(Schema.Boolean),
reasoning: Schema.optional(Schema.Boolean),
reasoning_options: Schema.optional(
// Mirrors the models.dev reasoning_options union; effort values stay open
// strings so new tiers don't break configs.
Schema.mutable(
Schema.Array(
Schema.Union([
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.mutable(Schema.Array(Schema.NullOr(Schema.String))),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
]),
),
),
).annotate({ description: "Reasoning controls this model supports; effort values drive reasoning variants" }),
temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
-24
View File
@@ -1,24 +0,0 @@
export * as ConfigReferenceV1 from "./reference"
import { Schema } from "effect"
const Git = Schema.Struct({
repository: Schema.String.annotate({
description: "Git repository URL, host/path reference, or GitHub owner/repo shorthand",
}),
branch: Schema.optional(Schema.String).annotate({
description: "Branch or ref to clone and inspect",
}),
})
const Local = Schema.Struct({
path: Schema.String.annotate({
description: "Absolute path, ~/ path, or workspace-relative path to a local reference directory",
}),
})
export const Entry = Schema.Union([Schema.String, Git, Local]).annotate({ identifier: "ReferenceConfigEntry" })
export type Entry = Schema.Schema.Type<typeof Entry>
export const Info = Schema.Record(Schema.String, Entry).annotate({ identifier: "ReferenceConfig" })
export type Info = Schema.Schema.Type<typeof Info>
+4
View File
@@ -52,6 +52,9 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
export const ContentFilterError = NamedError.create("ContentFilterError", {
message: Schema.String,
})
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
type: Schema.Literal("text"),
@@ -388,6 +391,7 @@ const AssistantErrorSchema = Schema.Union([
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
ContentFilterError.EffectSchema,
APIError.EffectSchema,
]).annotate({ discriminator: "name" })
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
-284
View File
@@ -1,284 +0,0 @@
import path from "path"
import { describe, expect } from "bun:test"
import { produce } from "immer"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { Auth } from "@opencode-ai/core/auth"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
function context(
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
): Catalog.Editor {
return {
provider: {
list: () => records,
get: (providerID) => records.find((item) => item.provider.id === providerID),
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
if (record) record.provider = provider
else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
updates.push({
id: providerID,
enabled: provider.enabled,
apiKey: typeof provider.request.body.apiKey === "string" ? provider.request.body.apiKey : undefined,
})
},
remove: (providerID) => {
const index = records.findIndex((item) => item.provider.id === providerID)
if (index !== -1) records.splice(index, 1)
},
},
model: {
get: () => undefined,
update: () => {},
remove: () => {},
default: {
get: () => undefined,
set: () => {},
},
},
}
}
function testLayer(dir: string) {
return Auth.layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Global.layerWith({
data: dir,
cache: path.join(dir, "cache"),
config: path.join(dir, "config"),
state: path.join(dir, "state"),
tmp: path.join(dir, "tmp"),
bin: path.join(dir, "bin"),
log: path.join(dir, "log"),
repos: path.join(dir, "repos"),
}),
),
)
}
describe("Auth", () => {
it.live("emits account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* Auth.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(Auth.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(Auth.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(Auth.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.description).toBe("default")
expect(first.credential.type).toBe("api")
if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
yield* accounts.update(first.id, { description: "keep" })
const updated = yield* accounts.get(first.id)
expect(updated?.description).toBe("keep")
expect(updated?.credential.type).toBe("api")
if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
const second = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* accounts.remove(second.id)
const added = Array.from(yield* Fiber.join(addedFiber))
const switched = Array.from(yield* Fiber.join(switchedFiber))
const removed = Array.from(yield* Fiber.join(removedFiber))
expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
expect(switched.map((event) => event.data)).toEqual([
{ serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: Auth.ServiceID.make("provider"), from: second.id, to: first.id },
])
expect(removed[0]?.data.account.id).toBe(second.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("always switches to newly created accounts", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* Auth.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(Auth.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
})
const second = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
})
const third = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* accounts.active(Auth.ServiceID.make("provider")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ serviceID: Auth.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: Auth.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: Auth.ServiceID.make("provider"), from: second.id, to: third.id },
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("account plugin refreshes providers on account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* Auth.Service
const plugin = yield* PluginV2.Service
const records = [
{
provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
models: new Map<ModelV2.ID, ModelV2.Info>(),
},
]
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
transform: () => Effect.die("unexpected catalog.transform"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})
const eventSvc = yield* EventV2.Service
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, eventSvc),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "first-key" }),
})
expect(first).toBeDefined()
if (!first) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: Auth.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
const second = yield* accounts.create({
serviceID: Auth.ServiceID.make("provider"),
credential: new Auth.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: Auth.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.activate(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: Auth.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
yield* accounts.remove(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: Auth.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.remove(second.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})
+51 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -17,10 +19,58 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
)
const it = testEffect(
Catalog.locationLayer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer)),
Catalog.locationLayer.pipe(
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map()) })),
),
)
describe("CatalogV2", () => {
it.effect("projects active credentials without rebuilding catalog state", () => {
const connectorID = Connector.ID.make("test")
const methodID = Connector.MethodID.make("api-key")
const first = new Credential.Info({
id: Credential.ID.create(),
connectorID,
methodID,
label: "First",
value: new Credential.Key({ type: "key", key: "first", metadata: { tenant: "one" } }),
})
const second = new Credential.Info({
id: Credential.ID.create(),
connectorID,
methodID,
label: "Second",
value: new Credential.Key({ type: "key", key: "second", metadata: { tenant: "two" } }),
})
let active = first
const layer = Catalog.locationLayer.pipe(
Layer.fresh,
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(
Layer.mock(Credential.Service)({ activeAll: () => Effect.succeed(new Map([[connectorID, active]])) }),
),
)
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: first.id },
request: { body: { apiKey: "first", tenant: "one" } },
})
active = second
expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({
enabled: { via: "credential", credentialID: second.id },
request: { body: { apiKey: "second", tenant: "two" } },
})
}).pipe(Effect.provide(layer))
})
it.effect("normalizes provider baseURL into api url", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+45 -8
View File
@@ -70,7 +70,9 @@ describe("Config", () => {
Effect.sync(() => {
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
}),
)
@@ -431,6 +433,42 @@ describe("Config", () => {
),
)
it.live("migrates the deprecated reference key into references", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
reference: {
local: { path: "../library" },
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
},
}),
),
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents).toHaveLength(1)
expect(documents[0]?.info.references).toEqual({
local: { path: "../library" },
sdk: { repository: "github.com/example/sdk", branch: "main" },
shorthand: "github.com/example/docs",
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
),
)
it.live("migrates v1 configuration when a v1-only key is present", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -464,7 +502,9 @@ describe("Config", () => {
["@my-org/audit-plugin", { endpoint: "https://audit.example.com" }],
],
skills: { paths: ["./skills"], urls: ["https://example.com/.well-known/skills/"] },
reference: { docs: { path: "../docs" } },
references: {
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
},
attachment: { image: { auto_resize: false, max_width: 1200 } },
provider: {
custom: {
@@ -540,7 +580,9 @@ describe("Config", () => {
{ package: "@my-org/audit-plugin", options: { endpoint: "https://audit.example.com" } },
])
expect(documents[0]?.info.skills).toEqual(["./skills", "https://example.com/.well-known/skills/"])
expect(documents[0]?.info.references).toEqual({ docs: { path: "../docs" } })
expect(documents[0]?.info.references).toEqual({
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
})
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.providers?.custom).toMatchObject({
request: { body: { apiKey: "secret" } },
@@ -568,12 +610,7 @@ describe("Config", () => {
model: {
request: {
body: {
// `effort` stays aisdk-shaped: the catalog re-partitions it
// into the semantic anthropic effort option, which lowers to
// output_config.effort plus the effort beta header. taskBudget
// has no semantic and lowers to a raw output_config overlay.
effort: "high",
output_config: { task_budget: 4096 },
output_config: { effort: "high", task_budget: 4096 },
metadata: { user_id: "user-1" },
},
},
+401
View File
@@ -0,0 +1,401 @@
import { describe, expect } from "bun:test"
import { Duration, Effect, Exit, Layer, Scope } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Connector } from "@opencode-ai/core/connector"
import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
const layer = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: () => Effect.die("unexpected credential creation"),
}),
),
)
function connectionLayer(
created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}>,
) {
return Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
create: (input) =>
Effect.sync(() => {
created.push(input)
return new Credential.Info({ id: Credential.ID.create(), ...input, label: input.label ?? "default" })
}),
}),
),
)
}
describe("Connector", () => {
it.effect("registers connectors through the editor", () =>
Effect.gen(function* () {
const connectors = yield* Connector.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const openai = Connector.ID.make("openai")
yield* connectors
.update((editor) => editor.update(openai, (connector) => (connector.name = "OpenAI")))
.pipe(Scope.provide(scope))
expect(yield* connectors.get(openai)).toEqual(new Connector.Info({ id: openai, name: "OpenAI", methods: [] }))
yield* Scope.close(scope, Exit.void)
expect(yield* connectors.get(openai)).toBeUndefined()
}).pipe(Effect.provide(layer)),
)
it.effect("reveals the previous registration when an override closes", () =>
Effect.gen(function* () {
const connectors = yield* Connector.Service
const id = Connector.ID.make("openai")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
yield* connectors
.update((editor) => editor.update(id, (connector) => (connector.name = "OpenAI")))
.pipe(Scope.provide(first))
yield* connectors
.update((editor) => editor.update(id, (connector) => (connector.name = "OpenAI Override")))
.pipe(Scope.provide(second))
expect((yield* connectors.get(id))?.name).toBe("OpenAI Override")
yield* Scope.close(second, Exit.void)
expect((yield* connectors.get(id))?.name).toBe("OpenAI")
expect((yield* connectors.list()).map((connector) => connector.id)).toEqual([id])
}).pipe(Effect.provide(layer)),
)
it.effect("registers and overrides methods independently", () =>
Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
const authorize = () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
})
yield* connectors
.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize,
}),
)
.pipe(Scope.provide(first))
yield* connectors
.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }),
authorize,
}),
)
.pipe(Scope.provide(second))
expect((yield* connectors.get(connectorID))?.name).toBe("openai")
expect((yield* connectors.get(connectorID))?.methods[0]?.label).toBe("ChatGPT Override")
yield* Scope.close(second, Exit.void)
expect((yield* connectors.get(connectorID))?.methods[0]?.label).toBe("ChatGPT")
expect((yield* connectors.get(connectorID))?.methods.map((method) => method.id)).toEqual([methodID])
}).pipe(Effect.provide(layer)),
)
it.effect("connects with a key and stores the credential", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("api-key")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.KeyMethod({ id: methodID, type: "key", label: "API key" }),
authorize: (key, inputs) =>
Effect.succeed(
new Credential.Key({ type: "key", key, metadata: { organization: inputs.organization ?? "" } }),
),
}),
)
yield* connectors.connect.key({
connectorID,
methodID,
key: "secret",
inputs: { organization: "acme" },
label: "Work",
})
expect(created).toEqual([
{
connectorID,
methodID,
label: "Work",
value: new Credential.Key({ type: "key", key: "secret", metadata: { organization: "acme" } }),
},
])
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("refreshes OAuth with the originating method", () => {
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
const credentialID = Credential.ID.create()
const current = new Credential.OAuth({
type: "oauth",
access: "old-access",
refresh: "old-refresh",
expires: 1,
metadata: { accountID: "account" },
})
const updated: Array<{ id: Credential.ID; value: Credential.Value }> = []
const refreshLayer = Connector.locationLayer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(
Layer.mock(Credential.Service)({
get: () =>
Effect.succeed(
new Credential.Info({
id: credentialID,
connectorID,
methodID,
label: "Personal",
value: current,
}),
),
update: (id, input) =>
Effect.sync(() => {
if (input.value) updated.push({ id, value: input.value })
}),
}),
),
)
return Effect.gen(function* () {
const connectors = yield* Connector.Service
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () => Effect.die("unexpected authorization"),
refresh: (value) =>
Effect.succeed(
new Credential.OAuth({
type: "oauth",
access: "new-access",
refresh: "new-refresh",
expires: 2,
metadata: value.metadata,
}),
),
}),
)
yield* connectors.refresh(credentialID)
expect(updated).toEqual([
{
id: credentialID,
value: new Credential.OAuth({
type: "oauth",
access: "new-access",
refresh: "new-refresh",
expires: 2,
metadata: { accountID: "account" },
}),
},
])
}).pipe(Effect.provide(refreshLayer))
})
it.effect("completes code OAuth once and stores the credential", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () =>
Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: (code: string) =>
Effect.succeed(
new Credential.OAuth({
type: "oauth",
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code },
}),
),
}),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {}, label: "Personal" })
expect(attempt.mode).toBe("code")
yield* connectors.connect.oauth.complete({ attemptID: attempt.attemptID, code: "1234" })
expect(created[0]).toEqual({
connectorID,
methodID,
label: "Personal",
value: new Credential.OAuth({
type: "oauth",
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code: "1234" },
}),
})
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("chatgpt")
let closed = false
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }),
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () => Effect.die("unexpected callback"),
}),
),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
expect(
yield* connectors.connect.oauth.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip),
).toBeInstanceOf(Connector.CodeRequiredError)
expect(closed).toBe(false)
yield* connectors.connect.oauth.cancel(attempt.attemptID)
expect(closed).toBe(true)
expect(created).toEqual([])
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("completes auto OAuth in the background", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("browser")
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
authorize: () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.succeed(
new Credential.OAuth({ type: "oauth", access: "access", refresh: "refresh", expires: 1 }),
),
}),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toEqual({
status: "complete",
time: attempt.time,
})
expect(created).toHaveLength(1)
}).pipe(Effect.provide(connectionLayer(created)))
})
it.effect("expires abandoned OAuth attempts", () => {
const created: Array<{
connectorID: Connector.ID
methodID: Connector.MethodID
label?: string
value: Credential.Value
}> = []
return Effect.gen(function* () {
const connectors = yield* Connector.Service
const connectorID = Connector.ID.make("openai")
const methodID = Connector.MethodID.make("browser")
let closed = false
yield* connectors.update((editor) =>
editor.method.update({
connectorID,
method: new Connector.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }),
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
}),
),
}),
)
const attempt = yield* connectors.connect.oauth.begin({ connectorID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
expect(yield* connectors.connect.oauth.status(attempt.attemptID)).toEqual({
status: "expired",
time: attempt.time,
})
expect(closed).toBe(true)
expect(created).toEqual([])
}).pipe(Effect.provide(connectionLayer(created)))
})
})
+206
View File
@@ -0,0 +1,206 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Credential } from "@opencode-ai/core/credential"
import { Connector } from "@opencode-ai/core/connector"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.locationLayer.pipe(Layer.provide(EventV2.defaultLayer)))
function testLayer(directory: string) {
return Credential.layer.pipe(
Layer.fresh,
Layer.provide(Database.layerFromPath(path.join(directory, "credential.db")).pipe(Layer.fresh)),
Layer.provideMerge(EventV2.defaultLayer),
)
}
describe("Credential", () => {
it.live("imports supported legacy auth.json credentials once", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() =>
Bun.write(
path.join(tmp.path, "auth.json"),
JSON.stringify({
openai: {
type: "oauth",
refresh: "refresh",
access: "access",
expires: 123,
accountId: "account",
},
azure: { type: "api", key: "key", metadata: { resourceName: "resource" } },
ignored: { type: "wellknown", key: "TOKEN", token: "secret" },
}),
),
)
const database = Database.layerFromPath(path.join(tmp.path, "credential.db")).pipe(Layer.fresh)
const global = Global.layerWith({ data: tmp.path })
const importer = Credential.legacyImportLayer.pipe(
Layer.provide(database),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(global),
)
const credentials = Credential.layer.pipe(
Layer.provide(database),
Layer.provide(EventV2.defaultLayer),
Layer.provideMerge(importer),
)
const result = yield* Effect.gen(function* () {
const service = yield* Credential.Service
return yield* service.all()
}).pipe(Effect.provide(credentials), Effect.scoped)
expect(result).toHaveLength(2)
expect(result).toContainEqual(
expect.objectContaining({
connectorID: Connector.ID.make("openai"),
methodID: Connector.MethodID.make("chatgpt-browser"),
label: "Imported",
value: expect.objectContaining({
type: "oauth",
refresh: "refresh",
access: "access",
expires: 123,
metadata: { accountID: "account" },
}),
}),
)
expect(result).toContainEqual(
expect.objectContaining({
connectorID: Connector.ID.make("azure"),
methodID: Connector.MethodID.make("api-key"),
value: expect.objectContaining({ type: "key", key: "key", metadata: { resourceName: "resource" } }),
}),
)
yield* importer.pipe(Layer.build, Effect.scoped)
const after = yield* Effect.gen(function* () {
return yield* (yield* Credential.Service).all()
}).pipe(Effect.provide(credentials), Effect.scoped)
expect(after).toHaveLength(2)
}),
),
),
)
it.live("emits credential lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(Credential.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(Credential.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(Credential.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* credentials.create({
connectorID: Connector.ID.make("lifecycle"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.label).toBe("default")
expect(first.value.type).toBe("key")
if (first.value.type === "key") expect(first.value.key).toBe("raw-key")
yield* credentials.update(first.id, { label: "keep" })
const updated = yield* credentials.get(first.id)
expect(updated?.label).toBe("keep")
expect(updated?.value.type).toBe("key")
if (updated?.value.type === "key") expect(updated.value.key).toBe("raw-key")
const second = yield* credentials.create({
connectorID: Connector.ID.make("lifecycle"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* credentials.remove(second.id)
const added = Array.from(yield* Fiber.join(addedFiber))
const switched = Array.from(yield* Fiber.join(switchedFiber))
const removed = Array.from(yield* Fiber.join(removedFiber))
expect(added.map((event) => event.data.credential.id)).toEqual([first.id, second.id])
expect(switched.map((event) => event.data)).toEqual([
{ connectorID: Connector.ID.make("lifecycle"), from: undefined, to: first.id },
{ connectorID: Connector.ID.make("lifecycle"), from: first.id, to: second.id },
{ connectorID: Connector.ID.make("lifecycle"), from: second.id, to: first.id },
])
expect(removed[0]?.data.credential.id).toBe(second.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("always switches to newly created credentials", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(Credential.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* credentials.create({
connectorID: Connector.ID.make("switch"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "first-key" }),
})
const second = yield* credentials.create({
connectorID: Connector.ID.make("switch"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "second-key" }),
})
const third = yield* credentials.create({
connectorID: Connector.ID.make("switch"),
methodID: Connector.MethodID.make("key"),
value: new Credential.Key({ type: "key", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* credentials.active(Connector.ID.make("switch")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ connectorID: Connector.ID.make("switch"), from: undefined, to: first.id },
{ connectorID: Connector.ID.make("switch"), from: first.id, to: second.id },
{ connectorID: Connector.ID.make("switch"), from: second.id, to: third.id },
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})

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