Compare commits

...

93 Commits

Author SHA1 Message Date
Sebastian Herrlinger ed720d29cf cleanup 2026-04-17 23:58:02 +02:00
Sebastian Herrlinger 9eb8aad84b cleanup 2026-04-17 23:51:57 +02:00
Sebastian Herrlinger 0ff63d9fc8 comment 2026-04-17 23:47:02 +02:00
Sebastian Herrlinger 36826a45b3 atomic write 2026-04-17 23:47:02 +02:00
Sebastian Herrlinger 62a65206b0 flock 2026-04-17 23:47:02 +02:00
Sebastian Herrlinger 022c93f9df mitigate kv corruption 2026-04-17 23:47:02 +02:00
Sebastian Herrlinger 9ca16a323a stash 2026-04-17 23:47:02 +02:00
Sebastian Herrlinger 7b93488551 use otui waitFor 2026-04-17 23:47:02 +02:00
Sebastian Herrlinger 176f9ad081 opentui snapshot 2026-04-17 23:47:02 +02:00
Dax cded68a2e2 refactor(npm): use object-based package spec for install API (#23181) 2026-04-17 17:30:50 -04:00
Aiden Cline 0068ccec35 fix: ensure copilot model list filters out disabled models (#23176) 2026-04-17 16:27:32 -05:00
opencode-agent[bot] 89e8994fd1 chore: generate 2026-04-17 21:08:00 +00:00
Kit Langton 5980b0a5ee feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178) 2026-04-17 21:06:55 +00:00
opencode-agent[bot] 89029a20ef chore: generate 2026-04-17 21:00:20 +00:00
Kit Langton ce69bd97b9 refactor(config): migrate model-id and command to Effect Schema (#23175) 2026-04-17 20:59:24 +00:00
Kit Langton 999d8651aa feat(server): wrap remaining route handlers in request spans (#23169) 2026-04-17 16:52:40 -04:00
opencode-agent[bot] ed0f022502 chore: generate 2026-04-17 20:50:37 +00:00
Kit Langton b1307d5c2a refactor(config): migrate skills, formatter, console-state to Effect Schema (#23162) 2026-04-17 20:49:36 +00:00
opencode-agent[bot] dc16013b4f chore: generate 2026-04-17 20:47:05 +00:00
Kit Langton e7686dbd64 feat(effect-zod): translate Schema.check filters into zod .superRefine + promote LSP refinement to Effect layer (#23173) 2026-04-17 20:46:05 +00:00
James Long 47f553f9ba fix(core): more explicit routing to fix workspace instance issue (#23171) 2026-04-17 16:39:34 -04:00
Kit Langton d11268ece7 refactor(config): migrate permission Action/Object/Rule leaves to Effect Schema (#23168) 2026-04-17 20:35:42 +00:00
Kit Langton 650a13a690 refactor(config): migrate lsp schemas to Effect Schema (#23167) 2026-04-17 20:34:47 +00:00
opencode-agent[bot] 54435325b6 chore: generate 2026-04-17 20:26:43 +00:00
Kit Langton 11fa257549 refactor(config): migrate mcp schemas to Effect Schema.Class (#23163) 2026-04-17 20:25:49 +00:00
Kit Langton 6af8ab0df2 docs(http-api): refresh bridge inventory and clarify Schema.Class vs Struct (#23164) 2026-04-17 16:20:57 -04:00
Kit Langton 984f5ed6eb fix(opencode): skip share sync for unshared sessions (#23159) 2026-04-17 20:15:24 +00:00
opencode-agent[bot] c2061c6bbf chore: generate 2026-04-17 20:13:34 +00:00
Dax b708e8431e docs(opencode): annotate plugin loader flow (#23160) 2026-04-17 20:13:34 +00:00
opencode 9b6c397171 release: v1.4.10 2026-04-17 20:13:25 +00:00
opencode-agent[bot] 9b0659d4f9 chore: generate 2026-04-17 19:30:28 +00:00
Kit Langton f83cecaaf6 fix(opencode): untrace streaming event hot paths (#23156) 2026-04-17 15:29:32 -04:00
James Long aa05b9abe5 fix(core): pass OTEL config to workspace env (#23154) 2026-04-17 15:25:58 -04:00
Kit Langton 68834cfcc3 fix(opencode): normalize provider metadata and tag otel runs (#23140) 2026-04-17 15:22:08 -04:00
James Long 5621373bc2 fix(core): move instance middleware after control plane routes (#23150) 2026-04-17 15:20:11 -04:00
opencode-agent[bot] 88582566bf chore: update nix node_modules hashes 2026-04-17 19:18:55 +00:00
opencode-agent[bot] d6e1362fee chore: generate 2026-04-17 19:15:07 +00:00
James Long b275b8580d feat(tui): minor UX improvements for workspaces (#23146) 2026-04-17 15:14:05 -04:00
Dax 467be08e67 refactor: consolidate npm exports and trace flock acquisition (#23151) 2026-04-17 18:58:37 +00:00
Aiden Cline bbb422d125 chore: bump ai to 6.0.168 and @ai-sdk/gateway to 3.0.104 (#23145) 2026-04-17 13:47:22 -05:00
Dax Raad b1f076558c test: align plugin loader npm mocks
- switch plugin loader tests to the effect npm module
- return Option.none() for mocked npm entrypoints
- keep test fixtures aligned with the current Npm.add contract
2026-04-17 14:33:02 -04:00
Dax Raad 992435aaf8 do not flock until reify 2026-04-17 14:18:48 -04:00
Dax Raad 2f73e73e9d trace npm fully 2026-04-17 14:08:45 -04:00
James Long 4c30a78cd9 fix: revert sdk generation script change (#23133) 2026-04-17 13:33:11 -04:00
James Long a8c78fc005 fix(core): add historical sync on workspace connect (#23121) 2026-04-17 13:30:09 -04:00
opencode-agent[bot] fcb473ff64 chore: update nix node_modules hashes 2026-04-17 17:25:44 +00:00
James Long 797953c88d when generating sdk only format sdk, much faster (#23122) 2026-04-17 13:01:22 -04:00
opencode-agent[bot] ce0cfb0ea5 chore: generate 2026-04-17 16:46:34 +00:00
Kit Langton 13dfe569ef tui: fix agent cycling and prompt metadata polish (#23115) 2026-04-17 12:45:29 -04:00
Aiden Cline c491161c0c chore: bump @ai-sdk/anthropic to 3.0.71 and dependents (#23120) 2026-04-17 11:40:24 -05:00
rasdani fde3d9133b fix(opencode): pass EXA_API_KEY to websearch tool to avoid rate limits (#16362)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-17 11:28:23 -05:00
Vladimir Glafirov 0d582f9d3f chore: bump gitlab-ai-provider to 6.6.0 (#23057) 2026-04-17 11:22:43 -05:00
Dax Raad 1a59133168 Improve light mode dark mode copy 2026-04-17 16:19:57 +00:00
opencode 803d9eb7ad release: v1.4.9 2026-04-17 16:19:46 +00:00
Dax Raad a27d3c1623 tui: fix session resumption with --session-id flag to navigate after app initialization
Previously when passing a session ID directly, the route was set during initial
render which could cause navigation issues before the router was fully ready.
Now the session navigation happens after initialization completes, ensuring
the TUI properly loads the requested session when users resume with --session-id.
2026-04-17 11:41:24 -04:00
Dax Raad 551216a452 fix incorrect light mode in ghostty 2026-04-17 11:32:17 -04:00
opencode-agent[bot] 38cd3979f2 chore: update nix node_modules hashes 2026-04-17 15:31:50 +00:00
Ismail Ghallou 3fe602cda3 feat: add LLM Gateway provider (#7847)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-17 10:29:31 -05:00
opencode-agent[bot] 3a4b49095c chore: generate 2026-04-17 15:26:45 +00:00
Jen Person ac5b395c5d docs: adding Mistral to docs as a provider (it is already a provider, just docs update) #23070 (#23072) 2026-04-17 10:25:42 -05:00
OpeOginni 8fbbca5f4b fix(opencode): rescrict github copilot opus 4.7 variants to "medium" (#23097) 2026-04-17 10:25:12 -05:00
Brendan Allan 2415820ecd fix: conditionally show file tree in beta channel (#23099) 2026-04-17 15:13:59 +00:00
Frank 20103eb97b sync
deploy / deploy (push) Has been cancelled
2026-04-17 10:53:45 -04:00
Dax Raad 10c4ab9a3d roll back opentui 2026-04-17 10:51:02 -04:00
Dax Raad 7e39c9b950 back to opentui 0.1.99 2026-04-17 10:43:17 -04:00
opencode-agent[bot] cc063d4c32 chore: generate 2026-04-17 13:56:17 +00:00
Frank 3707e4a49c zen: routing logic 2026-04-17 09:54:47 -04:00
opencode-agent[bot] cb425ac927 chore: generate 2026-04-17 13:53:11 +00:00
James Long 0f80c827ed feat(core): exponential backoff of workspace reconnect (#23083) 2026-04-17 09:52:10 -04:00
Dax Raad fffc496f41 remove log 2026-04-17 09:46:35 -04:00
opencode 06ae43920b release: v1.4.8 2026-04-17 13:37:06 +00:00
opencode-agent[bot] e78d75a003 chore: update nix node_modules hashes 2026-04-17 13:07:11 +00:00
Sebastian ec3ac0c4b0 upgrade opentui to 0.1.100 (#22928) 2026-04-17 14:29:46 +02:00
opencode-agent[bot] c57c5315c1 chore: generate 2026-04-17 07:27:13 +00:00
Brendan Allan a726530735 fix(app): workspace loading and persist ready state (#23046) 2026-04-17 07:26:14 +00:00
Dax d9950598d0 core: migrate config loading to Effect framework (#23032)
compliance-close / close-non-compliant (push) Has been cancelled
beta / sync (push) Has been cancelled
docs-update / update-docs (push) Has been cancelled
deploy / deploy (push) Has been cancelled
docs-locale-sync / sync-locales (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / version (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-ubuntu-2404 target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-4vcpu-windows-2025 target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-tauri (map[host:blacksmith-8vcpu-ubuntu-2404-arm target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:macos-latest target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-tauri (map[host:windows-2025 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:macos-latest platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:macos-latest platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
storybook / storybook build (push) Has been cancelled
test / unit (linux) (push) Has been cancelled
test / unit (windows) (push) Has been cancelled
test / e2e (linux) (push) Has been cancelled
test / e2e (windows) (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
stats / stats (push) Has been cancelled
daily-pr-recap / pr-recap (push) Has been cancelled
daily-issues-recap / daily-recap (push) Has been cancelled
close-issues / close (push) Has been cancelled
close-stale-prs / close-stale-prs (push) Has been cancelled
2026-04-17 06:44:01 +00:00
opencode-agent[bot] 81f0885879 chore: generate 2026-04-17 06:13:42 +00:00
Dax 65b2a10e97 fade in prompt metadata transitions (#23037) 2026-04-17 06:12:41 +00:00
James Long 7605acff65 refactor(core): move server routes around to clarify workspacing (#23031) 2026-04-17 02:06:20 -04:00
Dax Raad e7f8f7fa3b fix crash on experimental 2026-04-17 01:14:08 -04:00
James Long 72d7cb717d remove accidental commit of daytona plugin (#23030) 2026-04-17 00:42:45 -04:00
opencode-agent[bot] f0caeb9b25 chore: generate 2026-04-17 04:32:17 +00:00
Aiden Cline 76a141090e chore: delete filetime module (#22999) 2026-04-16 23:31:21 -05:00
Dax 4bd5a158a5 fix: preserve prompt input across unmount/remount cycles (#22508) 2026-04-17 04:23:30 +00:00
opencode-agent[bot] dfaae14544 chore: update nix node_modules hashes 2026-04-17 04:14:26 +00:00
Brendan Allan 79e9baf55a fix(app): use fetchQuery instead of ensureQueryData in global sync (#23025) 2026-04-17 03:54:19 +00:00
Kit Langton 9ee89f7868 refactor: move project read routes onto HttpApi (#23003) 2026-04-17 03:48:12 +00:00
opencode-agent[bot] 67dbb3cf18 chore: generate 2026-04-17 03:37:21 +00:00
Kit Langton 4260c40efa refactor(tui): inline final Go shimmer settings (#23017) 2026-04-17 03:36:21 +00:00
James Long 0bedea52b1 fix(tui): tui resiliency when workspace is dead, disable directory filter in session list (#23013) 2026-04-16 23:35:36 -04:00
Jay fbbab9d6c8 feat(app): hide desktop titlebar tools behind settings (#19029)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-04-17 03:31:00 +00:00
Kit Langton cccb907a9b feat(tui): animated GO logo + radial pulse in free-limit upsell dialog (#22976) 2026-04-16 23:19:18 -04:00
Kit Langton ee7339f2c6 refactor: move provider and config provider routes onto HttpApi (#23004) 2026-04-16 23:10:45 -04:00
197 changed files with 8359 additions and 5064 deletions
-1
View File
@@ -594,7 +594,6 @@ OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE
+1 -5
View File
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"opencode": {
"options": {},
},
},
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
+82 -48
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.7",
"version": "1.4.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -322,15 +322,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -365,8 +365,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.0.0-20260417-7aaea62b",
"@opentui/solid": "0.0.0-20260417-7aaea62b",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -386,7 +386,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -458,23 +458,23 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.0.0-20260417-7aaea62b",
"@opentui/solid": "0.0.0-20260417-7aaea62b",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.99",
"@opentui/solid": ">=0.1.99",
"@opentui/core": ">=0.0.0-20260417-7aaea62b",
"@opentui/solid": ">=0.0.0-20260417-7aaea62b",
},
"optionalPeers": [
"@opentui/core",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.7",
"version": "1.4.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -675,6 +675,8 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@solid-primitives/storage": "4.3.3",
@@ -690,7 +692,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
@@ -738,7 +740,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.94", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@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-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@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-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
"@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=="],
@@ -758,11 +760,11 @@
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -1586,7 +1588,7 @@
"@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
"@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="],
@@ -1598,21 +1600,21 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="],
"@opentui/core": ["@opentui/core@0.0.0-20260417-7aaea62b", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20260417-7aaea62b", "@opentui/core-darwin-x64": "0.0.0-20260417-7aaea62b", "@opentui/core-linux-arm64": "0.0.0-20260417-7aaea62b", "@opentui/core-linux-x64": "0.0.0-20260417-7aaea62b", "@opentui/core-win32-arm64": "0.0.0-20260417-7aaea62b", "@opentui/core-win32-x64": "0.0.0-20260417-7aaea62b", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-LF7yVr71aTawpLkSjT8MvSQRF6CJXPX3AllpWGXC5G8mLvaPxeROrDNyBofkcCebjusVhY4DYppC5b49xYbxVA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.99", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20260417-7aaea62b", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM73d1OHfonbrsBV/UxgP6LSsnIifB5KzwEF+VuMtGCc1p7KDpPKGauPbcosNpz7be8T0ZvYAhUlH1UVYkIXOg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.99", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20260417-7aaea62b", "", { "os": "darwin", "cpu": "x64" }, "sha512-w1lOoqMXnkXDBg9N5Hn7s3PI+Z//ihxKeKdv5jpQKrnahp1vIqkeC5a6rqQb28OhOW8p32uQDdUYnx//nx0nPg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.99", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20260417-7aaea62b", "", { "os": "linux", "cpu": "arm64" }, "sha512-RKcC7dSO441BXvXkyUFiSj/5hGLtPTvtvTDMEuTZIYjpLi9jtuRhBtKPrPKBrNvisqpKqZMf1cy1ocb09nu71w=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.99", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20260417-7aaea62b", "", { "os": "linux", "cpu": "x64" }, "sha512-KJzv/R5vh/DKbC5LNFqKD6zNpyxWzk8Vio3m4GZikTg+aD0lZfhT+0pozLkMxWXqTDE6q/Iu1ea8tgXVU4H0xQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.99", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20260417-7aaea62b", "", { "os": "win32", "cpu": "arm64" }, "sha512-S6V8/kDlSDgkhij2gOpLOOt9QmYGWYg0OaOrU3xTjDCztlKIylZZkf0vP2tYIRLZ7rTv8xrqT/hxXbw3S5g+cQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.99", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20260417-7aaea62b", "", { "os": "win32", "cpu": "x64" }, "sha512-n+TxVJjj/7dtSwH7lgTWjmZLN5bC9IiDZlrthnV5pGQ955rfjFeV20NPT31/1jpjl6+ivMLJn7ebMCRe26ZPHQ=="],
"@opentui/solid": ["@opentui/solid@0.1.99", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20260417-7aaea62b", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20260417-7aaea62b", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-nxYIphzPWjVVm6ohTEIU+NL867yg03tjFxf/HZqrBuACWqWTd8Ka2soim6kZdjY+WAXMtO7BbsnXLTgzlhc9YQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2454,7 +2456,7 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -2514,7 +2516,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@@ -3312,7 +3314,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "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-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.6.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-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5152,7 +5154,7 @@
"@ai-sdk/alibaba/@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=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/amazon-bedrock/@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=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
@@ -5170,7 +5172,7 @@
"@ai-sdk/fireworks/@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=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/google-vertex/@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=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
@@ -5582,6 +5584,18 @@
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="],
"@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
@@ -5688,8 +5702,6 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@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-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
@@ -5908,7 +5920,7 @@
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"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=="],
@@ -5934,6 +5946,10 @@
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.99", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="],
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"ora/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -6690,6 +6706,22 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.99", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.99", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.99", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.99", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.99", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.99", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -7032,6 +7064,8 @@
"opencontrol/@modelcontextprotocol/sdk/express/type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"ora/bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
"aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
"aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
"x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
}
}
+3 -1
View File
@@ -34,6 +34,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -51,7 +53,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.7",
"version": "1.4.10",
"description": "",
"type": "module",
"exports": {
@@ -8,7 +8,7 @@ import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/shared/util/path"
import { createEffect, createMemo, For, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
@@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
@@ -134,6 +135,7 @@ export function SessionHeader() {
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
@@ -151,6 +153,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -262,12 +269,16 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
setRightMount(document.getElementById("opencode-titlebar-right"))
})
return (
<>
<Show when={centerMount()}>
<Show when={search() && centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<Button
@@ -415,24 +426,28 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
@@ -451,30 +466,32 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
@@ -106,6 +106,7 @@ export const SettingsGeneral: Component = () => {
permission.disableAutoAccept(params.id, value)
}
const desktop = createMemo(() => platform.platform === "desktop")
const check = () => {
if (!platform.checkUpdate) return
@@ -279,6 +280,74 @@ export const SettingsGeneral: Component = () => {
</div>
)
const AdvancedSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.advanced")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showNavigation.title")}
description={language.t("settings.general.row.showNavigation.description")}
>
<div data-action="settings-show-navigation">
<Switch
checked={settings.general.showNavigation()}
onChange={(checked) => settings.general.setShowNavigation(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
>
<div data-action="settings-show-search">
<Switch
checked={settings.general.showSearch()}
onChange={(checked) => settings.general.setShowSearch(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
>
<div data-action="settings-show-status">
<Switch
checked={settings.general.showStatus()}
onChange={(checked) => settings.general.setShowStatus(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
@@ -527,6 +596,7 @@ export const SettingsGeneral: Component = () => {
</div>
)
console.log(import.meta.env)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -609,6 +679,10 @@ export const SettingsGeneral: Component = () => {
)
}}
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<AdvancedSection />
</Show>
</div>
</div>
)
+5 -3
View File
@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
@@ -40,6 +41,7 @@ export function Titlebar() {
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const navigate = useNavigate()
const location = useLocation()
@@ -78,6 +80,7 @@ export function Titlebar() {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const back = () => {
const next = backPath(history)
@@ -255,13 +258,12 @@ export function Titlebar() {
<div
class="flex items-center shrink-0"
classList={{
"translate-x-0": !layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened(),
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects()}>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
+1 -2
View File
@@ -204,7 +204,7 @@ function createGlobalSync() {
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.ensureQueryData({
.fetchQuery({
...loadSessionsQuery(directory),
queryFn: () =>
loadRootSessionsWithFallback({
@@ -264,7 +264,6 @@ function createGlobalSync() {
children.pin(directory)
const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory)
child[1]("bootstrapPromise", promise!)
const cache = children.vcsCache.get(directory)
if (!cache) return
const sdk = sdkFor(directory)
@@ -182,7 +182,6 @@ export function createChildStoreManager(input: {
limit: 5,
message: {},
part: {},
bootstrapPromise: Promise.resolve(),
})
children[directory] = child
disposers.set(directory, dispose)
@@ -72,7 +72,6 @@ export type State = {
part: {
[messageID: string]: Part[]
}
bootstrapPromise: Promise<void>
}
export type VcsCache = {
+30
View File
@@ -23,6 +23,11 @@ export interface Settings {
autoSave: boolean
releaseNotes: boolean
followup: "queue" | "steer"
showFileTree: boolean
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showTerminal: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
@@ -89,6 +94,11 @@ const defaultSettings: Settings = {
autoSave: true,
releaseNotes: true,
followup: "steer",
showFileTree: false,
showNavigation: false,
showSearch: false,
showStatus: false,
showTerminal: false,
showReasoningSummaries: false,
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
@@ -162,6 +172,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFollowup(value: "queue" | "steer") {
setStore("general", "followup", value === "queue" ? "steer" : value)
},
showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree),
setShowFileTree(value: boolean) {
setStore("general", "showFileTree", value)
},
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
setShowNavigation(value: boolean) {
setStore("general", "showNavigation", value)
},
showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch),
setShowSearch(value: boolean) {
setStore("general", "showSearch", value)
},
showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus),
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
},
showReasoningSummaries: withFallback(
() => store.general?.showReasoningSummaries,
defaultSettings.general.showReasoningSummaries,
+2 -4
View File
@@ -1,16 +1,14 @@
import "solid-js"
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
readonly OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "solid-js" {
export declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
+11
View File
@@ -719,6 +719,7 @@ export const dict = {
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance",
"settings.general.section.advanced": "Advanced",
"settings.general.section.notifications": "System notifications",
"settings.general.section.updates": "Updates",
"settings.general.section.sounds": "Sound effects",
@@ -741,6 +742,16 @@ export const dict = {
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
"settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar",
"settings.general.row.showTerminal.title": "Terminal",
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
+179 -181
View File
@@ -13,7 +13,7 @@ import {
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -127,6 +127,7 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
@@ -2102,196 +2103,198 @@ export default function Layout(props: ParentProps) {
</Show>
}
>
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
{(project) => (
<>
<div class="shrink-0 pl-1 py-1">
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
<div class="flex flex-col min-w-0">
<InlineEditor
id={`project:${projectId()}`}
value={projectName}
onSave={(next) => {
const item = project()
if (!item) return
void renameProject(item, next)
}}
class="text-14-medium text-text-strong truncate"
displayClass="text-14-medium text-text-strong truncate"
stopPropagation
/>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
<Tooltip
placement="bottom"
gutter={2}
value={worktree()}
class="shrink-0"
contentStyle={{
"max-width": "640px",
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate select-text">
{worktree().replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
<DropdownMenu modal={!sidebarHovering()}>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
data-action="project-menu"
data-project={slug()}
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{
"opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}}
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
const item = project()
if (!item) return
showEditProjectDialog(item)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-workspaces-toggle"
data-project={slug()}
disabled={!canToggle()}
onSelect={() => {
const item = project()
if (!item) return
toggleProjectWorkspaces(item)
}}
>
<DropdownMenu.ItemLabel>
{workspacesEnabled()
? language.t("sidebar.workspaces.disable")
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={slug()}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"
data-project={slug()}
onSelect={() => {
const dir = worktree()
if (!dir) return
closeProject(dir)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<div class="flex-1 min-h-0 flex flex-col">
<Show
when={workspacesEnabled()}
fallback={
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
}}
>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="new-session"
icon="plus-small"
class="w-full"
onClick={() => {
const dir = worktree()
if (!dir) return
navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("command.session.new")}
{language.t("workspace.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
ctx={workspaceSidebarCtx}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
}
>
<>
<div class="shrink-0 py-4">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
const item = project()
if (!item) return
void createWorkspace(item)
}}
>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!panelProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
ctx={workspaceSidebarCtx}
directory={directory}
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
/>
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay
sidebarProject={sidebarProject}
activeWorkspace={() => store.activeWorkspace}
workspaceLabel={workspaceLabel}
/>
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</div>
</>
</Show>
</div>
</>
)}
</Show>
<div
@@ -2355,14 +2358,9 @@ export default function Layout(props: ParentProps) {
/>
)
const [loading] = createResource(
() => route()?.store?.[0]?.bootstrapPromise,
(p) => p,
)
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{(autoselecting(), loading()) ?? ""}
{autoselecting() ?? ""}
<Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
@@ -317,12 +317,11 @@ export const SortableWorkspace = (props: {
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false)
const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
const loading = () => query.isLoading
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
const loadMore = async () => {
@@ -427,7 +426,7 @@ export const SortableWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={showNew}
loading={loading}
loading={() => query.isLoading && count() === 0}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
@@ -453,11 +452,10 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
const loading = createMemo(() => query.isPending && count() === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > count())
const loading = () => query.isLoading && count() === 0
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
@@ -473,7 +471,7 @@ export const LocalWorkspace = (props: {
mobile={props.mobile}
ctx={props.ctx}
showNew={() => false}
loading={() => query.isLoading}
loading={loading}
sessions={sessions}
hasMore={hasMore}
loadMore={loadMore}
@@ -19,6 +19,9 @@ import { useCommand } from "@/context/command"
import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
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"
@@ -39,6 +42,9 @@ export function SessionSidePanel(props: {
size: Sizing
}) {
const layout = useLayout()
const platform = usePlatform()
const settings = useSettings()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
@@ -46,9 +52,15 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const shown = createMemo(
() =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree(),
)
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened())
const open = createMemo(() => reviewOpen() || fileOpen())
const reviewTab = createMemo(() => isDesktop())
const panelWidth = createMemo(() => {
@@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
</div>
</div>
<div
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<Show when={shown()}>
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
id="file-tree-panel"
aria-hidden={!fileOpen()}
inert={!fileOpen()}
class="relative min-w-0 h-full shrink-0 overflow-hidden"
classList={{
"pointer-events-none": !fileOpen(),
"transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
}}
style={{ width: treeWidth() }}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
<div
class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weaker-base": reviewOpen() }}
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Tabs
variant="pill"
value={fileTreeTab()}
onChange={setFileTreeTabValue}
class="h-full"
data-scope="filetree"
>
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{props.reviewCount()}{" "}
{language.t(
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={props.hasReview() || !props.diffsReady()}>
<Show
when={props.diffsReady()}
fallback={
<div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
/>
</Show>
</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
allowed={diffFiles()}
modified={diffFiles()}
kinds={kinds()}
draggable={false}
active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Show>
</Match>
<Match when={true}>{empty(props.empty())}</Match>
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<Switch>
<Match when={nofiles()}>{empty(language.t("session.files.empty"))}</Match>
<Match when={true}>
<FileTree
path=""
class="pt-3"
modified={diffFiles()}
kinds={kinds()}
onFileClick={(node) => openTab(file.tab(node.path))}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</Match>
</Switch>
</Tabs.Content>
</Tabs>
</div>
</Show>
</div>
<Show when={fileOpen()}>
<div onPointerDown={() => props.size.start()}>
<ResizeHandle
direction="horizontal"
edge="start"
size={layout.fileTree.width()}
min={200}
max={480}
onResize={(width) => {
props.size.touch()
layout.fileTree.resize(width)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
</aside>
</Show>
@@ -7,8 +7,10 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { usePermission } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@opencode-ai/ui/toast"
@@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const platform = usePlatform()
const prompt = usePrompt()
const sdk = useSDK()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
@@ -66,6 +70,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const shown = () =>
platform.platform !== "desktop" ||
import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" ||
settings.general.showFileTree()
const idle = { type: "idle" as const }
const status = () => sync.data.session_status[params.id ?? ""] ?? idle
@@ -457,12 +465,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
...(shown()
? [
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
]
: []),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
+1 -1
View File
@@ -469,7 +469,7 @@ export function persisted<T>(
state,
setState,
init,
Object.assign(() => ready() === true, {
Object.assign(() => (ready.loading ? false : ready.latest === true), {
promise: init instanceof Promise ? init : undefined,
}),
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.7",
"version": "1.4.10",
"type": "module",
"license": "MIT",
"scripts": {
@@ -45,6 +45,7 @@ import { LiteData } from "@opencode-ai/console-core/lite.js"
import { Resource } from "@opencode-ai/console-resource"
import { i18n, type Key } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createModelTpmLimiter } from "./modelTpmLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -121,6 +122,8 @@ export async function handler(
const authInfo = await authenticate(modelInfo, zenApiKey)
const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource })
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
const modelTpmLimits = await modelTpmLimiter?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@@ -133,6 +136,7 @@ export async function handler(
trialProviders,
retry,
stickyProvider,
modelTpmLimits,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
@@ -229,6 +233,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(json.usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -278,6 +283,7 @@ export async function handler(
const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@@ -433,12 +439,16 @@ export async function handler(
trialProviders: string[] | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
) {
const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
// instead of using the sticky provider for the same session
if (authInfo?.provider?.credentials) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
// Always use the same provider for the same session
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider
@@ -451,10 +461,20 @@ export async function handler(
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
const providers = modelInfo.providers
const allProviders = modelInfo.providers
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
const topPriority = Math.min(...allProviders.map((p) => p.priority))
const providers = allProviders
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip
@@ -0,0 +1,51 @@
import { and, Database, eq, inArray, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { ModelRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { UsageInfo } from "./provider/provider"
export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) {
const keys = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`)
if (keys.length === 0) return
const yyyyMMddHHmm = new Date(Date.now())
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12)
return {
check: async () => {
const data = await Database.use((tx) =>
tx
.select()
.from(ModelRateLimitTable)
.where(and(inArray(ModelRateLimitTable.key, keys), eq(ModelRateLimitTable.interval, yyyyMMddHHmm))),
)
// convert to map of model to count
return data.reduce(
(acc, curr) => {
acc[curr.key] = curr.count
return acc
},
{} as Record<string, number>,
)
},
track: async (id: string, model: string, usageInfo: UsageInfo) => {
const key = `${id}/${model}`
if (!keys.includes(key)) return
const usage =
usageInfo.inputTokens +
usageInfo.outputTokens +
(usageInfo.reasoningTokens ?? 0) +
(usageInfo.cacheReadTokens ?? 0) +
(usageInfo.cacheWrite5mTokens ?? 0) +
(usageInfo.cacheWrite1hTokens ?? 0)
if (usage <= 0) return
await Database.use((tx) =>
tx
.insert(ModelRateLimitTable)
.values({ key, interval: yyyyMMddHHmm, count: usage })
.onDuplicateKeyUpdate({ set: { count: sql`${ModelRateLimitTable.count} + ${usage}` } }),
)
},
}
}
@@ -0,0 +1,6 @@
CREATE TABLE `model_rate_limit` (
`key` varchar(255) NOT NULL,
`interval` varchar(40) NOT NULL,
`count` int NOT NULL,
CONSTRAINT PRIMARY KEY(`key`,`interval`)
);
@@ -0,0 +1,2567 @@
{
"version": "6",
"dialect": "mysql",
"id": "93c492af-c95b-4213-9fc2-38c3dd10374d",
"prevIds": ["a09a925d-6cdd-4e7c-b8b1-11c259928b4c"],
"ddl": [
{
"name": "account",
"entityType": "tables"
},
{
"name": "auth",
"entityType": "tables"
},
{
"name": "benchmark",
"entityType": "tables"
},
{
"name": "billing",
"entityType": "tables"
},
{
"name": "lite",
"entityType": "tables"
},
{
"name": "payment",
"entityType": "tables"
},
{
"name": "subscription",
"entityType": "tables"
},
{
"name": "usage",
"entityType": "tables"
},
{
"name": "ip_rate_limit",
"entityType": "tables"
},
{
"name": "ip",
"entityType": "tables"
},
{
"name": "key_rate_limit",
"entityType": "tables"
},
{
"name": "model_rate_limit",
"entityType": "tables"
},
{
"name": "key",
"entityType": "tables"
},
{
"name": "model",
"entityType": "tables"
},
{
"name": "provider",
"entityType": "tables"
},
{
"name": "user",
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "account"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "account"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "account"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "account"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "auth"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "auth"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "auth"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "auth"
},
{
"type": "enum('email','github','google')",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "auth"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "subject",
"entityType": "columns",
"table": "auth"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "account_id",
"entityType": "columns",
"table": "auth"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "model",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "agent",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "mediumtext",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "result",
"entityType": "columns",
"table": "benchmark"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "customer_id",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "payment_method_id",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(32)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "payment_method_type",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(4)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "payment_method_last4",
"entityType": "columns",
"table": "billing"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "balance",
"entityType": "columns",
"table": "billing"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "monthly_limit",
"entityType": "columns",
"table": "billing"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "monthly_usage",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_monthly_usage_updated",
"entityType": "columns",
"table": "billing"
},
{
"type": "boolean",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "reload",
"entityType": "columns",
"table": "billing"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "reload_trigger",
"entityType": "columns",
"table": "billing"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "reload_amount",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "reload_error",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_reload_error",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_reload_locked_till",
"entityType": "columns",
"table": "billing"
},
{
"type": "json",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "subscription",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(28)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "subscription_id",
"entityType": "columns",
"table": "billing"
},
{
"type": "enum('20','100','200')",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "subscription_plan",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_subscription_booked",
"entityType": "columns",
"table": "billing"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_subscription_selected",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(28)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "lite_subscription_id",
"entityType": "columns",
"table": "billing"
},
{
"type": "json",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "lite",
"entityType": "columns",
"table": "billing"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "lite"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "lite"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "lite"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "lite"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "lite"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "lite"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "rolling_usage",
"entityType": "columns",
"table": "lite"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "weekly_usage",
"entityType": "columns",
"table": "lite"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "monthly_usage",
"entityType": "columns",
"table": "lite"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_rolling_updated",
"entityType": "columns",
"table": "lite"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_weekly_updated",
"entityType": "columns",
"table": "lite"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_monthly_updated",
"entityType": "columns",
"table": "lite"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "payment"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "payment"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "payment"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "payment"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "payment"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "customer_id",
"entityType": "columns",
"table": "payment"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "invoice_id",
"entityType": "columns",
"table": "payment"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "payment_id",
"entityType": "columns",
"table": "payment"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "amount",
"entityType": "columns",
"table": "payment"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_refunded",
"entityType": "columns",
"table": "payment"
},
{
"type": "json",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "enrichment",
"entityType": "columns",
"table": "payment"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "subscription"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "subscription"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "subscription"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "rolling_usage",
"entityType": "columns",
"table": "subscription"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "fixed_usage",
"entityType": "columns",
"table": "subscription"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_rolling_updated",
"entityType": "columns",
"table": "subscription"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_fixed_updated",
"entityType": "columns",
"table": "subscription"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "usage"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "usage"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "usage"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "usage"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "usage"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "model",
"entityType": "columns",
"table": "usage"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "usage"
},
{
"type": "int",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "input_tokens",
"entityType": "columns",
"table": "usage"
},
{
"type": "int",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "output_tokens",
"entityType": "columns",
"table": "usage"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "reasoning_tokens",
"entityType": "columns",
"table": "usage"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "cache_read_tokens",
"entityType": "columns",
"table": "usage"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "cache_write_5m_tokens",
"entityType": "columns",
"table": "usage"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "cache_write_1h_tokens",
"entityType": "columns",
"table": "usage"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "cost",
"entityType": "columns",
"table": "usage"
},
{
"type": "varchar(30)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "key_id",
"entityType": "columns",
"table": "usage"
},
{
"type": "varchar(30)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "usage"
},
{
"type": "json",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "enrichment",
"entityType": "columns",
"table": "usage"
},
{
"type": "varchar(45)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "ip",
"entityType": "columns",
"table": "ip_rate_limit"
},
{
"type": "varchar(10)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "interval",
"entityType": "columns",
"table": "ip_rate_limit"
},
{
"type": "int",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "count",
"entityType": "columns",
"table": "ip_rate_limit"
},
{
"type": "varchar(45)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "ip",
"entityType": "columns",
"table": "ip"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "ip"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "ip"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "ip"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "usage",
"entityType": "columns",
"table": "ip"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "key_rate_limit"
},
{
"type": "varchar(40)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "interval",
"entityType": "columns",
"table": "key_rate_limit"
},
{
"type": "int",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "count",
"entityType": "columns",
"table": "key_rate_limit"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "model_rate_limit"
},
{
"type": "varchar(40)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "interval",
"entityType": "columns",
"table": "model_rate_limit"
},
{
"type": "int",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "count",
"entityType": "columns",
"table": "model_rate_limit"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "key"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "key"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "key"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "key"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "key"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "key"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "key"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "key"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_used",
"entityType": "columns",
"table": "key"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "model"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "model"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "model"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "model"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "model"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "model",
"entityType": "columns",
"table": "model"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "provider"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "provider"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "provider"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "provider"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "provider"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "provider"
},
{
"type": "text",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "credentials",
"entityType": "columns",
"table": "provider"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "user"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "workspace_id",
"entityType": "columns",
"table": "user"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "user"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "user"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "user"
},
{
"type": "varchar(30)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "account_id",
"entityType": "columns",
"table": "user"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "user"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "user"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_seen",
"entityType": "columns",
"table": "user"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "color",
"entityType": "columns",
"table": "user"
},
{
"type": "enum('admin','member')",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "role",
"entityType": "columns",
"table": "user"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "monthly_limit",
"entityType": "columns",
"table": "user"
},
{
"type": "bigint",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "monthly_usage",
"entityType": "columns",
"table": "user"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_monthly_usage_updated",
"entityType": "columns",
"table": "user"
},
{
"type": "varchar(30)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "workspace"
},
{
"type": "varchar(255)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "slug",
"entityType": "columns",
"table": "workspace"
},
{
"type": "varchar(255)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "workspace"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "workspace"
},
{
"type": "timestamp(3)",
"notNull": true,
"autoIncrement": false,
"default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_updated",
"entityType": "columns",
"table": "workspace"
},
{
"type": "timestamp(3)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "time_deleted",
"entityType": "columns",
"table": "workspace"
},
{
"columns": ["id"],
"name": "PRIMARY",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"name": "PRIMARY",
"table": "auth",
"entityType": "pks"
},
{
"columns": ["id"],
"name": "PRIMARY",
"table": "benchmark",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "billing",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "lite",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "payment",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "subscription",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "usage",
"entityType": "pks"
},
{
"columns": ["ip", "interval"],
"name": "PRIMARY",
"table": "ip_rate_limit",
"entityType": "pks"
},
{
"columns": ["ip"],
"name": "PRIMARY",
"table": "ip",
"entityType": "pks"
},
{
"columns": ["key", "interval"],
"name": "PRIMARY",
"table": "key_rate_limit",
"entityType": "pks"
},
{
"columns": ["key", "interval"],
"name": "PRIMARY",
"table": "model_rate_limit",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "key",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "model",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "provider",
"entityType": "pks"
},
{
"columns": ["workspace_id", "id"],
"name": "PRIMARY",
"table": "user",
"entityType": "pks"
},
{
"columns": ["id"],
"name": "PRIMARY",
"table": "workspace",
"entityType": "pks"
},
{
"columns": [
{
"value": "provider",
"isExpression": false
},
{
"value": "subject",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "provider",
"entityType": "indexes",
"table": "auth"
},
{
"columns": [
{
"value": "account_id",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "account_id",
"entityType": "indexes",
"table": "auth"
},
{
"columns": [
{
"value": "time_created",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "time_created",
"entityType": "indexes",
"table": "benchmark"
},
{
"columns": [
{
"value": "customer_id",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "global_customer_id",
"entityType": "indexes",
"table": "billing"
},
{
"columns": [
{
"value": "subscription_id",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "global_subscription_id",
"entityType": "indexes",
"table": "billing"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "workspace_user_id",
"entityType": "indexes",
"table": "lite"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "user_id",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "workspace_user_id",
"entityType": "indexes",
"table": "subscription"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "time_created",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "usage_time_created",
"entityType": "indexes",
"table": "usage"
},
{
"columns": [
{
"value": "key",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "global_key",
"entityType": "indexes",
"table": "key"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "model",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "model_workspace_model",
"entityType": "indexes",
"table": "model"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "provider",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "workspace_provider",
"entityType": "indexes",
"table": "provider"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "account_id",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "user_account_id",
"entityType": "indexes",
"table": "user"
},
{
"columns": [
{
"value": "workspace_id",
"isExpression": false
},
{
"value": "email",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "user_email",
"entityType": "indexes",
"table": "user"
},
{
"columns": [
{
"value": "account_id",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "global_account_id",
"entityType": "indexes",
"table": "user"
},
{
"columns": [
{
"value": "email",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "global_email",
"entityType": "indexes",
"table": "user"
},
{
"columns": [
{
"value": "slug",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "slug",
"entityType": "indexes",
"table": "workspace"
}
],
"renames": []
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.7",
"version": "1.4.10",
"private": true,
"type": "module",
"license": "MIT",
+11 -4
View File
@@ -34,6 +34,8 @@ export namespace ZenData {
z.object({
id: z.string(),
model: z.string(),
priority: z.number().optional(),
tpmLimit: z.number().optional(),
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
@@ -123,10 +125,16 @@ export namespace ZenData {
),
models: (() => {
const normalize = (model: z.infer<typeof ModelSchema>) => {
const composite = model.providers.find((p) => compositeProviders[p.id].length > 1)
const providers = model.providers.map((p) => ({
...p,
priority: p.priority ?? Infinity,
weight: p.weight ?? 1,
}))
const composite = providers.find((p) => compositeProviders[p.id].length > 1)
if (!composite)
return {
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
providers,
}
const weightMulti = compositeProviders[composite.id].length
@@ -137,17 +145,16 @@ export namespace ZenData {
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
return [model.trialProvider]
})(),
providers: model.providers.flatMap((p) =>
providers: providers.flatMap((p) =>
p.id === composite.id
? compositeProviders[p.id].map((sub) => ({
...p,
id: sub.id,
weight: p.weight ?? 1,
}))
: [
{
...p,
weight: (p.weight ?? 1) * weightMulti,
weight: p.weight * weightMulti,
},
],
),
@@ -30,3 +30,13 @@ export const KeyRateLimitTable = mysqlTable(
},
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)
export const ModelRateLimitTable = mysqlTable(
"model_rate_limit",
{
key: varchar("key", { length: 255 }).notNull(),
interval: varchar("interval", { length: 40 }).notNull(),
count: int("count").notNull(),
},
(table) => [primaryKey({ columns: [table.key, table.interval] })],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.7",
"version": "1.4.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.7",
"version": "1.4.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.7",
"version": "1.4.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.7",
"version": "1.4.10",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.7",
"version": "1.4.10",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.7"
version = "1.4.10"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.7/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.7",
"version": "1.4.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+8 -8
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.7",
"version": "1.4.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -79,15 +79,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -122,8 +122,8 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "0.1.99",
"@opentui/solid": "0.1.99",
"@opentui/core": "0.0.0-20260417-7aaea62b",
"@opentui/solid": "0.0.0-20260417-7aaea62b",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -143,7 +143,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+58 -19
View File
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception:
@@ -365,17 +401,16 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
- `project` - `later`
best small reads: `GET /project`, `GET /project/current`
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `later`
- `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence after the first spike:
Recommended near-term sequence:
1. `provider` auth read endpoint
2. `config` providers read endpoint
3. `project` read endpoints
4. `workspace` read endpoints
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` full read endpoint (`GET /config`)
3. `file` JSON read endpoints
4. `mcp` JSON read endpoints
## Checklist
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [ ] port `workspace` read endpoints
- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
+1 -3
View File
@@ -9,7 +9,7 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
@@ -195,7 +195,6 @@ This checklist is only about the service shape migration. Many of these services
- [x] `Config``config/config.ts`
- [x] `Discovery``skill/discovery.ts` (dependency-only layer, no standalone runtime)
- [x] `File``file/index.ts`
- [x] `FileTime``file/time.ts`
- [x] `FileWatcher``file/watcher.ts`
- [x] `Format``format/index.ts`
- [x] `Installation``installation/index.ts`
@@ -301,7 +300,6 @@ For each service, the migration is roughly:
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
+12 -14
View File
@@ -1,7 +1,6 @@
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import * as Clipboard from "@tui/util/clipboard"
import * as Selection from "@tui/util/selection"
import * as Terminal from "@tui/util/terminal"
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import {
@@ -120,12 +119,6 @@ export function tui(input: {
const unguard = win32InstallCtrlCGuard()
win32DisableProcessedInput()
const mode = await Terminal.getTerminalBackgroundColor()
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
win32DisableProcessedInput()
const onExit = async () => {
unguard?.()
resolve()
@@ -136,6 +129,7 @@ export function tui(input: {
}
const renderer = await createCliRenderer(rendererConfig(input.config))
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
await render(() => {
return (
@@ -148,7 +142,16 @@ export function tui(input: {
<ExitProvider onBeforeExit={onBeforeExit} onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<RouteProvider
initialRoute={
input.args.continue
? {
type: "session",
sessionID: "dummy",
}
: undefined
}
>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
@@ -333,7 +336,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
})
local.model.set({ providerID, modelID }, { recent: true })
}
// Handle --session without --fork immediately (fork is handled in createEffect below)
if (args.sessionID && !args.fork) {
route.navigate({
type: "session",
@@ -420,12 +422,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
aliases: ["clear"],
},
onSelect: () => {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
route.navigate({
type: "home",
initialPrompt: currentPrompt,
})
dialog.clear()
},
@@ -602,7 +600,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Toggle theme mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
@@ -0,0 +1,130 @@
import { BoxRenderable, RGBA } from "@opentui/core"
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js"
import { tint, useTheme } from "@tui/context/theme"
const PERIOD = 4600
const RINGS = 3
const WIDTH = 3.8
const TAIL = 9.5
const AMP = 0.55
const TAIL_AMP = 0.16
const BREATH_AMP = 0.05
const BREATH_SPEED = 0.0008
// Offset so bg ring emits from GO center at the moment the logo pulse peaks.
const PHASE_OFFSET = 0.29
export type BgPulseMask = {
x: number
y: number
width: number
height: number
pad?: number
strength?: number
}
export function BgPulse(props: { centerX?: number; centerY?: number; masks?: BgPulseMask[] }) {
const { theme } = useTheme()
const [now, setNow] = createSignal(performance.now())
const [size, setSize] = createSignal<{ width: number; height: number }>({ width: 0, height: 0 })
let box: BoxRenderable | undefined
const timer = setInterval(() => setNow(performance.now()), 50)
onCleanup(() => clearInterval(timer))
const sync = () => {
if (!box) return
setSize({ width: box.width, height: box.height })
}
onMount(() => {
sync()
box?.on("resize", sync)
})
onCleanup(() => {
box?.off("resize", sync)
})
const grid = createMemo(() => {
const t = now()
const w = size().width
const h = size().height
if (w === 0 || h === 0) return [] as RGBA[][]
const cxv = props.centerX ?? w / 2
const cyv = props.centerY ?? h / 2
const reach = Math.hypot(Math.max(cxv, w - cxv), Math.max(cyv, h - cyv) * 2) + TAIL
const ringStates = Array.from({ length: RINGS }, (_, i) => {
const offset = i / RINGS
const phase = (t / PERIOD + offset - PHASE_OFFSET + 1) % 1
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
return {
head: phase * reach,
eased,
}
})
const normalizedMasks = props.masks?.map((m) => {
const pad = m.pad ?? 2
return {
left: m.x - pad,
right: m.x + m.width + pad,
top: m.y - pad,
bottom: m.y + m.height + pad,
pad,
strength: m.strength ?? 0.85,
}
})
const rows = [] as RGBA[][]
for (let y = 0; y < h; y++) {
const row = [] as RGBA[]
for (let x = 0; x < w; x++) {
const dx = x + 0.5 - cxv
const dy = (y + 0.5 - cyv) * 2
const dist = Math.hypot(dx, dy)
let level = 0
for (const ring of ringStates) {
const delta = dist - ring.head
const crest = Math.abs(delta) < WIDTH ? 0.5 + 0.5 * Math.cos((delta / WIDTH) * Math.PI) : 0
const tail = delta < 0 && delta > -TAIL ? (1 + delta / TAIL) ** 2.3 : 0
level += (crest * AMP + tail * TAIL_AMP) * ring.eased
}
const edgeFalloff = Math.max(0, 1 - (dist / (reach * 0.85)) ** 2)
const breath = (0.5 + 0.5 * Math.sin(t * BREATH_SPEED)) * BREATH_AMP
let maskAtten = 1
if (normalizedMasks) {
for (const m of normalizedMasks) {
if (x < m.left || x > m.right || y < m.top || y > m.bottom) continue
const inX = Math.min(x - m.left, m.right - x)
const inY = Math.min(y - m.top, m.bottom - y)
const edge = Math.min(inX / m.pad, inY / m.pad, 1)
const eased = edge * edge * (3 - 2 * edge)
const reduce = 1 - m.strength * eased
if (reduce < maskAtten) maskAtten = reduce
}
}
const strength = Math.min(1, ((level / RINGS) * edgeFalloff + breath * edgeFalloff) * maskAtten)
row.push(tint(theme.backgroundPanel, theme.primary, strength * 0.7))
}
rows.push(row)
}
return rows
})
return (
<box ref={(item: BoxRenderable) => (box = item)} width="100%" height="100%">
<For each={grid()}>
{(row) => (
<box flexDirection="row">
<For each={row}>
{(color) => (
<text bg={color} fg={color} selectable={false}>
{" "}
</text>
)}
</For>
</box>
)}
</For>
</box>
)
}
@@ -63,6 +63,7 @@ function init() {
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
@@ -1,12 +1,16 @@
import { RGBA, TextAttributes } from "@opentui/core"
import { BoxRenderable, RGBA, TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import open from "open"
import { createSignal } from "solid-js"
import { createSignal, onCleanup, onMount } from "solid-js"
import { selectedForeground, useTheme } from "@tui/context/theme"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { Link } from "@tui/ui/link"
import { GoLogo } from "./logo"
import { BgPulse, type BgPulseMask } from "./bg-pulse"
const GO_URL = "https://opencode.ai/go"
const PAD_X = 3
const PAD_TOP_OUTER = 1
export type DialogGoUpsellProps = {
onClose?: (dontShowAgain?: boolean) => void
@@ -27,62 +31,116 @@ export function DialogGoUpsell(props: DialogGoUpsellProps) {
const dialog = useDialog()
const { theme } = useTheme()
const fg = selectedForeground(theme)
const [selected, setSelected] = createSignal(0)
const [selected, setSelected] = createSignal<"dismiss" | "subscribe">("subscribe")
const [center, setCenter] = createSignal<{ x: number; y: number } | undefined>()
const [masks, setMasks] = createSignal<BgPulseMask[]>([])
let content: BoxRenderable | undefined
let logoBox: BoxRenderable | undefined
let headingBox: BoxRenderable | undefined
let descBox: BoxRenderable | undefined
let buttonsBox: BoxRenderable | undefined
const sync = () => {
if (!content || !logoBox) return
setCenter({
x: logoBox.x - content.x + logoBox.width / 2,
y: logoBox.y - content.y + logoBox.height / 2 + PAD_TOP_OUTER,
})
const next: BgPulseMask[] = []
const baseY = PAD_TOP_OUTER
for (const b of [headingBox, descBox, buttonsBox]) {
if (!b) continue
next.push({
x: b.x - content.x,
y: b.y - content.y + baseY,
width: b.width,
height: b.height,
pad: 2,
strength: 0.78,
})
}
setMasks(next)
}
onMount(() => {
sync()
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.on("resize", sync)
})
onCleanup(() => {
for (const b of [content, logoBox, headingBox, descBox, buttonsBox]) b?.off("resize", sync)
})
useKeyboard((evt) => {
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
setSelected((s) => (s === 0 ? 1 : 0))
setSelected((s) => (s === "subscribe" ? "dismiss" : "subscribe"))
return
}
if (evt.name !== "return") return
if (selected() === 0) subscribe(props, dialog)
else dismiss(props, dialog)
if (evt.name === "return") {
if (selected() === "subscribe") subscribe(props, dialog)
else dismiss(props, dialog)
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
<box ref={(item: BoxRenderable) => (content = item)}>
<box position="absolute" top={-PAD_TOP_OUTER} left={0} right={0} bottom={0} zIndex={0}>
<BgPulse centerX={center()?.x} centerY={center()?.y} masks={masks()} />
</box>
<box gap={1} paddingBottom={1}>
<text fg={theme.textMuted}>
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
$5/month.
</text>
<box flexDirection="row" gap={1}>
<box paddingLeft={PAD_X} paddingRight={PAD_X} paddingBottom={1} gap={1}>
<box ref={(item: BoxRenderable) => (headingBox = item)} flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Free limit reached
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<box ref={(item: BoxRenderable) => (descBox = item)} gap={0}>
<box flexDirection="row">
<text fg={theme.textMuted}>Subscribe to </text>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
OpenCode Go
</text>
<text fg={theme.textMuted}> for reliable access to the</text>
</box>
<text fg={theme.textMuted}>best open-source models, starting at $5/month.</text>
</box>
<box alignItems="center" gap={1} paddingBottom={1}>
<box ref={(item: BoxRenderable) => (logoBox = item)}>
<GoLogo />
</box>
<Link href={GO_URL} fg={theme.primary} />
</box>
</box>
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(0)}
onMouseUp={() => subscribe(props, dialog)}
>
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
subscribe
</text>
</box>
<box
paddingLeft={3}
paddingRight={3}
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected(1)}
onMouseUp={() => dismiss(props, dialog)}
>
<text
fg={selected() === 1 ? fg : theme.textMuted}
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
<box ref={(item: BoxRenderable) => (buttonsBox = item)} flexDirection="row" justifyContent="space-between">
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "dismiss" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("dismiss")}
onMouseUp={() => dismiss(props, dialog)}
>
don't show again
</text>
<text
fg={selected() === "dismiss" ? fg : theme.textMuted}
attributes={selected() === "dismiss" ? TextAttributes.BOLD : undefined}
>
don't show again
</text>
</box>
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={selected() === "subscribe" ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
onMouseOver={() => setSelected("subscribe")}
onMouseUp={() => subscribe(props, dialog)}
>
<text
fg={selected() === "subscribe" ? fg : theme.text}
attributes={selected() === "subscribe" ? TextAttributes.BOLD : undefined}
>
subscribe
</text>
</box>
</box>
</box>
</box>
@@ -139,15 +139,10 @@ export function DialogSessionList() {
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
)
@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -229,6 +235,10 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
toast.show({
message: "Creating workspace failed",
variant: "error",
})
log.error("workspace create request failed", {
type,
error: errorData(err),
@@ -0,0 +1,81 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -1,8 +1,61 @@
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import { useTheme, tint } from "@tui/context/theme"
import * as Sound from "@tui/util/sound"
import { logo } from "@/cli/logo"
import { go, logo } from "@/cli/logo"
export type LogoShape = {
left: string[]
right: string[]
}
type ShimmerConfig = {
period: number
rings: number
sweepFraction: number
coreWidth: number
coreAmp: number
softWidth: number
softAmp: number
tail: number
tailAmp: number
haloWidth: number
haloOffset: number
haloAmp: number
breathBase: number
noise: number
ambientAmp: number
ambientCenter: number
ambientWidth: number
shadowMix: number
primaryMix: number
originX: number
originY: number
}
const shimmerConfig: ShimmerConfig = {
period: 4600,
rings: 2,
sweepFraction: 1,
coreWidth: 1.2,
coreAmp: 1.9,
softWidth: 10,
softAmp: 1.6,
tail: 5,
tailAmp: 0.64,
haloWidth: 4.3,
haloOffset: 0.6,
haloAmp: 0.16,
breathBase: 0.04,
noise: 0.1,
ambientAmp: 0.36,
ambientCenter: 0.5,
ambientWidth: 0.34,
shadowMix: 0.1,
primaryMix: 0.3,
originX: 4.5,
originY: 13.5,
}
// Shadow markers (rendered chars in parens):
// _ = full shadow cell (space with bg=shadow)
@@ -74,9 +127,6 @@ type Frame = {
spark: number
}
const LEFT = logo.left[0]?.length ?? 0
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
const NEAR = [
[1, 0],
[1, 1],
@@ -140,7 +190,7 @@ function noise(x: number, y: number, t: number) {
}
function lit(char: string) {
return char !== " " && char !== "_" && char !== "~"
return char !== " " && char !== "_" && char !== "~" && char !== ","
}
function key(x: number, y: number) {
@@ -188,12 +238,12 @@ function route(list: Array<{ x: number; y: number }>) {
return path
}
function mapGlyphs() {
function mapGlyphs(full: string[]) {
const cells = [] as Array<{ x: number; y: number }>
for (let y = 0; y < FULL.length; y++) {
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
for (let y = 0; y < full.length; y++) {
for (let x = 0; x < (full[y]?.length ?? 0); x++) {
if (lit(full[y]?.[x] ?? " ")) cells.push({ x, y })
}
}
@@ -237,9 +287,25 @@ function mapGlyphs() {
return { glyph, trace, center }
}
const MAP = mapGlyphs()
type LogoContext = {
LEFT: number
FULL: string[]
SPAN: number
MAP: ReturnType<typeof mapGlyphs>
shape: LogoShape
}
function shimmer(x: number, y: number, frame: Frame) {
function build(shape: LogoShape): LogoContext {
const LEFT = shape.left[0]?.length ?? 0
const FULL = shape.left.map((line, i) => line + " ".repeat(GAP) + shape.right[i])
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
return { LEFT, FULL, SPAN, MAP: mapGlyphs(FULL), shape }
}
const DEFAULT = build(logo)
const GO = build(go)
function shimmer(x: number, y: number, frame: Frame, ctx: LogoContext) {
return frame.list.reduce((best, item) => {
const age = frame.t - item.at
if (age < SHIMMER_IN || age > LIFE) return best
@@ -247,7 +313,7 @@ function shimmer(x: number, y: number, frame: Frame) {
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const p = age / LIFE
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const lag = r - dist
if (lag < 0.18 || lag > SHIMMER_OUT) return best
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
@@ -258,19 +324,19 @@ function shimmer(x: number, y: number, frame: Frame) {
}, 0)
}
function remain(x: number, y: number, item: Release, t: number) {
function remain(x: number, y: number, item: Release, t: number, ctx: LogoContext) {
const age = t - item.at
if (age < 0 || age > LIFE) return 0
const p = age / LIFE
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
if (dist > r) return 1
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
}
function wave(x: number, y: number, frame: Frame, live: boolean) {
function wave(x: number, y: number, frame: Frame, live: boolean, ctx: LogoContext) {
return frame.list.reduce((sum, item) => {
const age = frame.t - item.at
if (age < 0 || age > LIFE) return sum
@@ -278,7 +344,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
const dx = x + 0.5 - item.x
const dy = y * 2 + 1 - item.y
const dist = Math.hypot(dx, dy)
const r = SPAN * (1 - (1 - p) ** EXPAND)
const r = ctx.SPAN * (1 - (1 - p) ** EXPAND)
const fade = (1 - p) ** 1.32
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
@@ -292,7 +358,7 @@ function wave(x: number, y: number, frame: Frame, live: boolean) {
}, 0)
}
function field(x: number, y: number, frame: Frame) {
function field(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
@@ -326,11 +392,11 @@ function field(x: number, y: number, frame: Frame) {
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
Math.exp(-(dist * dist) / 0.15) *
lerp(0.08, 0.42, body)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
}
function pick(x: number, y: number, frame: Frame) {
function pick(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
@@ -339,26 +405,26 @@ function pick(x: number, y: number, frame: Frame) {
const dx = x + 0.5 - item.x - 0.5
const dy = y * 2 + 1 - item.y * 2 - 1
const dist = Math.hypot(dx, dy)
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
}
function select(x: number, y: number) {
const direct = MAP.glyph.get(key(x, y))
function select(x: number, y: number, ctx: LogoContext) {
const direct = ctx.MAP.glyph.get(key(x, y))
if (direct !== undefined) return direct
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
const near = NEAR.map(([dx, dy]) => ctx.MAP.glyph.get(key(x + dx, y + dy))).find(
(item): item is number => item !== undefined,
)
return near
}
function trace(x: number, y: number, frame: Frame) {
function trace(x: number, y: number, frame: Frame, ctx: LogoContext) {
const held = frame.hold
const rest = frame.release
const item = held ?? rest
if (!item || item.glyph === undefined) return 0
const step = MAP.trace.get(key(x, y))
const step = ctx.MAP.trace.get(key(x, y))
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
const age = frame.t - item.at
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
@@ -368,29 +434,125 @@ function trace(x: number, y: number, frame: Frame) {
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
const tail = (head - TAIL + step.l) % step.l
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t, ctx) : 1
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
return (core + glow + trail) * appear * fade
}
function bloom(x: number, y: number, frame: Frame) {
function idle(
x: number,
pixelY: number,
frame: Frame,
ctx: LogoContext,
state: IdleState,
): { glow: number; peak: number; primary: number } {
const cfg = state.cfg
const dx = x + 0.5 - cfg.originX
const dy = pixelY - cfg.originY
const dist = Math.hypot(dx, dy)
const angle = Math.atan2(dy, dx)
const wob1 = noise(x * 0.32, pixelY * 0.25, frame.t * 0.0005) - 0.5
const wob2 = noise(x * 0.12, pixelY * 0.08, frame.t * 0.00022) - 0.5
const ripple = Math.sin(angle * 3 + frame.t * 0.0012) * 0.3
const jitter = (wob1 * 0.55 + wob2 * 0.32 + ripple * 0.18) * cfg.noise
const traveled = dist + jitter
let glow = 0
let peak = 0
let halo = 0
let primary = 0
let ambient = 0
for (const active of state.active) {
const head = active.head
const eased = active.eased
const delta = traveled - head
// Use shallower exponent (1.6 vs 2) for softer edges on the Gaussians
// so adjacent pixels have smaller brightness deltas
const core = Math.exp(-(Math.abs(delta / cfg.coreWidth) ** 1.8))
const soft = Math.exp(-(Math.abs(delta / cfg.softWidth) ** 1.6))
const tailRange = cfg.tail * 2.6
const tail = delta < 0 && delta > -tailRange ? (1 + delta / tailRange) ** 2.6 : 0
const haloDelta = delta + cfg.haloOffset
const haloBand = Math.exp(-(Math.abs(haloDelta / cfg.haloWidth) ** 1.6))
glow += (soft * cfg.softAmp + tail * cfg.tailAmp) * eased
peak += core * cfg.coreAmp * eased
halo += haloBand * cfg.haloAmp * eased
// Primary-tinted fringe follows the halo (which trails behind the core) and the tail
primary += (haloBand + tail * 0.6) * eased
ambient += active.ambient
}
ambient /= state.rings
return {
glow: glow / state.rings,
peak: cfg.breathBase + ambient + (peak + halo) / state.rings,
primary: (primary / state.rings) * cfg.primaryMix,
}
}
function bloom(x: number, y: number, frame: Frame, ctx: LogoContext) {
const item = frame.glow
if (!item) return 0
const glyph = MAP.glyph.get(key(x, y))
const glyph = ctx.MAP.glyph.get(key(x, y))
if (glyph !== item.glyph) return 0
const age = frame.t - item.at
if (age < 0 || age > GLOW_OUT) return 0
const p = age / GLOW_OUT
const flash = (1 - p) ** 2
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
const dx = x + 0.5 - ctx.MAP.center.get(item.glyph)!.x
const dy = y * 2 + 1 - ctx.MAP.center.get(item.glyph)!.y
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
}
export function Logo() {
type IdleState = {
cfg: ShimmerConfig
reach: number
rings: number
active: Array<{
head: number
eased: number
ambient: number
}>
}
function buildIdleState(t: number, ctx: LogoContext): IdleState {
const cfg = shimmerConfig
const w = ctx.FULL[0]?.length ?? 1
const h = ctx.FULL.length * 2
const corners: [number, number][] = [
[0, 0],
[w, 0],
[0, h],
[w, h],
]
let maxCorner = 0
for (const [cx, cy] of corners) {
const d = Math.hypot(cx - cfg.originX, cy - cfg.originY)
if (d > maxCorner) maxCorner = d
}
const reach = maxCorner + cfg.tail * 2
const rings = Math.max(1, Math.floor(cfg.rings))
const active = [] as IdleState["active"]
for (let i = 0; i < rings; i++) {
const offset = i / rings
const cyclePhase = (t / cfg.period + offset) % 1
if (cyclePhase >= cfg.sweepFraction) continue
const phase = cyclePhase / cfg.sweepFraction
const envelope = Math.sin(phase * Math.PI)
const eased = envelope * envelope * (3 - 2 * envelope)
const d = (phase - cfg.ambientCenter) / cfg.ambientWidth
active.push({
head: phase * reach,
eased,
ambient: Math.abs(d) < 1 ? (1 - d * d) ** 2 * cfg.ambientAmp : 0,
})
}
return { cfg, reach, rings, active }
}
export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } = {}) {
const ctx = props.shape ? build(props.shape) : DEFAULT
const { theme } = useTheme()
const [rings, setRings] = createSignal<Ring[]>([])
const [hold, setHold] = createSignal<Hold>()
@@ -430,6 +592,7 @@ export function Logo() {
}
if (!live) setRelease(undefined)
if (live || hold() || release() || glow()) return
if (props.idle) return
stop()
}
@@ -438,8 +601,20 @@ export function Logo() {
timer = setInterval(tick, 16)
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
onMount(() => {
if (!props.idle) return
setNow(performance.now())
start()
})
const hit = (x: number, y: number) => {
const char = FULL[y]?.[x]
const char = ctx.FULL[y]?.[x]
return char !== undefined && char !== " "
}
@@ -448,7 +623,7 @@ export function Logo() {
if (last) burst(last.x, last.y)
setNow(t)
if (!last) setRelease(undefined)
setHold({ x, y, at: t, glyph: select(x, y) })
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
hum = false
start()
}
@@ -508,6 +683,8 @@ export function Logo() {
}
})
const idleState = createMemo(() => (props.idle ? buildIdleState(frame().t, ctx) : undefined))
const renderLine = (
line: string,
y: number,
@@ -516,24 +693,64 @@ export function Logo() {
off: number,
frame: Frame,
dusk: Frame,
state: IdleState | undefined,
): JSX.Element[] => {
const shadow = tint(theme.background, ink, 0.25)
const attrs = bold ? TextAttributes.BOLD : undefined
return Array.from(line).map((char, i) => {
const h = field(off + i, y, frame)
const n = wave(off + i, y, frame, lit(char)) + h
const s = wave(off + i, y, dusk, false) + h
const p = lit(char) ? pick(off + i, y, frame) : 0
const e = lit(char) ? trace(off + i, y, frame) : 0
const b = lit(char) ? bloom(off + i, y, frame) : 0
const q = shimmer(off + i, y, frame)
if (char === " ") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
</text>
)
}
const h = field(off + i, y, frame, ctx)
const charLit = lit(char)
// Sub-pixel sampling: cells are 2 pixels tall. Sample at top (y*2) and bottom (y*2+1) pixel rows.
const pulseTop = state ? idle(off + i, y * 2, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
const pulseBot = state ? idle(off + i, y * 2 + 1, frame, ctx, state) : { glow: 0, peak: 0, primary: 0 }
const peakMixTop = charLit ? Math.min(1, pulseTop.peak) : 0
const peakMixBot = charLit ? Math.min(1, pulseBot.peak) : 0
const primaryMixTop = charLit ? Math.min(1, pulseTop.primary) : 0
const primaryMixBot = charLit ? Math.min(1, pulseBot.primary) : 0
// Layer primary tint first, then white peak on top — so the halo/tail pulls toward primary,
// while the bright core stays pure white
const inkTopTint = primaryMixTop > 0 ? tint(ink, theme.primary, primaryMixTop) : ink
const inkBotTint = primaryMixBot > 0 ? tint(ink, theme.primary, primaryMixBot) : ink
const inkTop = peakMixTop > 0 ? tint(inkTopTint, PEAK, peakMixTop) : inkTopTint
const inkBot = peakMixBot > 0 ? tint(inkBotTint, PEAK, peakMixBot) : inkBotTint
// For the non-peak-aware brightness channels, use the average of top/bot
const pulse = {
glow: (pulseTop.glow + pulseBot.glow) / 2,
peak: (pulseTop.peak + pulseBot.peak) / 2,
primary: (pulseTop.primary + pulseBot.primary) / 2,
}
const peakMix = charLit ? Math.min(1, pulse.peak) : 0
const primaryMix = charLit ? Math.min(1, pulse.primary) : 0
const inkPrimary = primaryMix > 0 ? tint(ink, theme.primary, primaryMix) : ink
const inkTinted = peakMix > 0 ? tint(inkPrimary, PEAK, peakMix) : inkPrimary
const shadowMixCfg = state?.cfg.shadowMix ?? shimmerConfig.shadowMix
const shadowMixTop = Math.min(1, pulseTop.peak * shadowMixCfg)
const shadowMixBot = Math.min(1, pulseBot.peak * shadowMixCfg)
const shadowTop = shadowMixTop > 0 ? tint(shadow, PEAK, shadowMixTop) : shadow
const shadowBot = shadowMixBot > 0 ? tint(shadow, PEAK, shadowMixBot) : shadow
const shadowMix = Math.min(1, pulse.peak * shadowMixCfg)
const shadowTinted = shadowMix > 0 ? tint(shadow, PEAK, shadowMix) : shadow
const n = wave(off + i, y, frame, charLit, ctx) + h
const s = wave(off + i, y, dusk, false, ctx) + h
const p = charLit ? pick(off + i, y, frame, ctx) : 0
const e = charLit ? trace(off + i, y, frame, ctx) : 0
const b = charLit ? bloom(off + i, y, frame, ctx) : 0
const q = shimmer(off + i, y, frame, ctx)
if (char === "_") {
return (
<text
fg={shade(ink, theme, s * 0.08)}
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
fg={shade(inkTinted, theme, s * 0.08)}
bg={shade(shadowTinted, theme, ghost(s, 0.24) + ghost(q, 0.06))}
attributes={attrs}
selectable={false}
>
@@ -545,8 +762,8 @@ export function Logo() {
if (char === "^") {
return (
<text
fg={shade(ink, theme, n + p + e + b)}
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
fg={shade(inkTop, theme, n + p + e + b)}
bg={shade(shadowBot, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
attributes={attrs}
selectable={false}
>
@@ -557,34 +774,60 @@ export function Logo() {
if (char === "~") {
return (
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
<text fg={shade(shadowTop, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
if (char === " ") {
if (char === ",") {
return (
<text fg={ink} attributes={attrs} selectable={false}>
{char}
<text fg={shade(shadowBot, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
</text>
)
}
// Solid █: render as ▀ so the top pixel (fg) and bottom pixel (bg) can carry independent shimmer values
if (char === "█") {
return (
<text
fg={shade(inkTop, theme, n + p + e + b)}
bg={shade(inkBot, theme, n + p + e + b)}
attributes={attrs}
selectable={false}
>
</text>
)
}
// ▀ top-half-lit: fg uses top-pixel sample, bg stays transparent/panel
if (char === "▀") {
return (
<text fg={shade(inkTop, theme, n + p + e + b)} attributes={attrs} selectable={false}>
</text>
)
}
// ▄ bottom-half-lit: fg uses bottom-pixel sample
if (char === "▄") {
return (
<text fg={shade(inkBot, theme, n + p + e + b)} attributes={attrs} selectable={false}>
</text>
)
}
return (
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
<text fg={shade(inkTinted, theme, n + p + e + b)} attributes={attrs} selectable={false}>
{char}
</text>
)
})
}
onCleanup(() => {
stop()
hum = false
Sound.dispose()
})
const mouse = (evt: MouseEvent) => {
if (!box) return
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
@@ -613,17 +856,28 @@ export function Logo() {
position="absolute"
top={0}
left={0}
width={FULL[0]?.length ?? 0}
height={FULL.length}
width={ctx.FULL[0]?.length ?? 0}
height={ctx.FULL.length}
zIndex={1}
onMouse={mouse}
/>
<For each={logo.left}>
<For each={ctx.shape.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
<box flexDirection="row">
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
{renderLine(line, index(), props.ink ?? theme.textMuted, !!props.ink, 0, frame(), dusk(), idleState())}
</box>
<box flexDirection="row">
{renderLine(
ctx.shape.right[index()],
index(),
props.ink ?? theme.text,
true,
ctx.LEFT + GAP,
frame(),
dusk(),
idleState(),
)}
</box>
</box>
)}
@@ -631,3 +885,9 @@ export function Logo() {
</box>
)
}
export function GoLogo() {
const { theme } = useTheme()
const base = tint(theme.background, theme.text, 0.62)
return <Logo shape={go} ink={base} idle />
}
@@ -1,18 +1,19 @@
import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { BoxRenderable, RGBA, TextareaRenderable, MouseEvent, PasteEvent, decodePasteBytes } from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
import "opentui-spinner/solid"
import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
@@ -35,8 +36,11 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
export type PromptProps = {
@@ -75,6 +79,12 @@ function randomIndex(count: number) {
return Math.floor(Math.random() * count)
}
function fadeColor(color: RGBA, alpha: number) {
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
}
let stashed: { prompt: PromptInfo; cursor: number } | undefined
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
@@ -85,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs()
const sdk = useSDK()
const route = useRoute()
const project = useProject()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
@@ -95,6 +106,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
@@ -233,9 +245,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
onSelect: async (dialog) => {
if (!input.focused) return
void submit()
const handled = await submit()
if (!handled) return
dialog.clear()
},
},
@@ -433,26 +447,47 @@ export function Prompt(props: PromptProps) {
},
}
onMount(() => {
const saved = stashed
stashed = undefined
if (store.prompt.input) return
if (saved && saved.prompt.input) {
input.setText(saved.prompt.input)
setStore("prompt", saved.prompt)
restoreExtmarksFromParts(saved.prompt.parts)
input.cursorOffset = saved.cursor
}
})
onCleanup(() => {
if (store.prompt.input) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
props.ref?.(undefined)
})
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
input.blur()
if (input.focused) input.blur()
return
}
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
input.focus()
if (!input.focused) input.focus()
})
createEffect(() => {
if (!input || input.isDestroyed) return
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = {
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
@@ -599,20 +634,48 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
if (props.disabled) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return
if (!agent) return false
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return
return true
}
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
return
return false
}
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
const workspaceID = workspaceSession?.workspaceID
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
}
let sessionID = props.sessionID
@@ -627,7 +690,7 @@ export function Prompt(props: PromptProps) {
variant: "error",
})
return
return true
}
sessionID = res.data.id
@@ -741,6 +804,7 @@ export function Prompt(props: PromptProps) {
})
}, 50)
input.clear()
return true
}
const exit = useExit()
@@ -841,6 +905,14 @@ export function Prompt(props: PromptProps) {
return !!current
})
const agentMetaAlpha = createFadeIn(() => !!local.agent.current(), animationsEnabled)
const modelMetaAlpha = createFadeIn(() => !!local.agent.current() && store.mode === "normal", animationsEnabled)
const variantMetaAlpha = createFadeIn(
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") {
@@ -901,7 +973,7 @@ export function Prompt(props: PromptProps) {
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
@@ -1116,17 +1188,25 @@ export function Prompt(props: PromptProps) {
<Show when={local.agent.current()} fallback={<box height={1} />}>
{(agent) => (
<>
<text fg={highlight()}>{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} </text>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{currentProviderLabel()}</text>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
{local.model.variant.current()}
</span>
</text>
</Show>
</box>
@@ -1146,7 +1226,7 @@ export function Prompt(props: PromptProps) {
<box
height={1}
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
@@ -132,8 +132,10 @@ async function backupAndStripLegacy(file: string, source: string) {
}
async function opencodeFiles(input: { directories: string[]; cwd: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("opencode", input.cwd)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
const files = [
...ConfigPaths.fileInDirectory(Global.Path.config, "opencode"),
...(await Filesystem.findUp(["opencode.json", "opencode.jsonc"], input.cwd, undefined, { rootFirst: true })),
]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
@@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.array().optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
+21 -17
View File
@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" })
@@ -89,15 +89,13 @@ async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
acc.result.plugin_origins = plugins
}
async function loadState(ctx: { directory: string }) {
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = await ConfigPaths.directories(ctx.directory)
// One-time migration: extract tui keys (theme/keybinds/tui) from existing
// opencode.json files into sibling tui.json files.
await migrateTuiConfig({ directories, cwd: ctx.directory })
const directories = yield* ConfigPaths.directories(ctx.directory)
yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory }))
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory)
const acc: Acc = {
result: {},
@@ -105,18 +103,19 @@ async function loadState(ctx: { directory: string }) {
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
await mergeFile(acc, file, ctx)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
const configFile = Flag.OPENCODE_TUI_CONFIG
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
await mergeFile(acc, file, ctx)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
@@ -127,7 +126,7 @@ async function loadState(ctx: { directory: string }) {
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
await mergeFile(acc, file, ctx)
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
}
}
@@ -146,20 +145,25 @@ async function loadState(ctx: { directory: string }) {
config: acc.result,
dirs: acc.result.plugin?.length ? dirs : [],
}
}
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const directory = yield* CurrentWorkingDirectory
const npm = yield* Npm.Service
const data = yield* Effect.promise(() => loadState({ directory }))
const data = yield* loadState({ directory })
const deps = yield* Effect.forEach(
data.dirs,
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
@@ -176,7 +180,7 @@ export const layer = Layer.effect(
}).pipe(Effect.withSpan("TuiConfig.layer")),
)
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
@@ -1,7 +1,9 @@
import { Global } from "@/global"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
import { rename, rm } from "fs/promises"
import { createSignal, type Setter } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, unwrap } from "solid-js/store"
import { createSimpleContext } from "./helper"
import path from "path"
@@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
const [ready, setReady] = createSignal(false)
const [store, setStore] = createStore<Record<string, any>>()
const filePath = path.join(Global.Path.state, "kv.json")
const lock = `tui-kv:${filePath}`
// Queue same-process writes so rapid updates persist in order.
let write = Promise.resolve()
Filesystem.readJson<Record<string, any>>(filePath)
// Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence.
function writeSnapshot(snapshot: Record<string, any>) {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`
return Filesystem.writeJson(tempPath, snapshot)
.then(() => rename(tempPath, filePath))
.catch(async (error) => {
await rm(tempPath, { force: true }).catch(() => undefined)
throw error
})
}
// Read under the same lock used for writes because kv.json is shared across processes.
Flock.withLock(lock, () => Filesystem.readJson<Record<string, any>>(filePath))
.then((x) => {
setStore(x)
})
.catch(() => {})
.catch((error) => {
console.error("Failed to read KV state", { filePath, error })
})
.finally(() => {
setReady(true)
})
@@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({
},
set(key: string, value: any) {
setStore(key, value)
void Filesystem.writeJson(filePath, store)
const snapshot = structuredClone(unwrap(store))
write = write
.then(() => Flock.withLock(lock, () => writeSnapshot(snapshot)))
.catch((error) => {
console.error("Failed to write KV state", { filePath, error })
})
},
}
return result
@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
const current = this.current()
if (!current) return
let next = agents().findIndex((x) => x.name === current.name) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
name: "Project",
init: () => {
const sdk = useSDK()
const defaultPath = {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id)
})
}
@@ -1,16 +1,16 @@
import { createStore } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = {
type: "home"
initialPrompt?: PromptInfo
prompt?: PromptInfo
}
export type SessionRoute = {
type: "session"
sessionID: string
initialPrompt?: PromptInfo
prompt?: PromptInfo
}
export type PluginRoute = {
@@ -23,13 +23,14 @@ export type Route = HomeRoute | SessionRoute | PluginRoute
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: () => {
init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>(
process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
},
props.initialRoute ??
(process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
}),
)
return {
@@ -37,7 +38,7 @@ export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
return store
},
navigate(route: Route) {
setStore(route)
setStore(reconcile(route))
},
}
},
@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@/flag/flag"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const ctrl = new AbortController()
sse = ctrl
;(async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({ signal: ctrl.signal })
const events = await sdk.global.event({
signal: ctrl.signal,
sseMaxRetryAttempts: 0,
})
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
}
})().catch(() => {})
}
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
} else {
startSSE()
}
@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { batch, onMount } from "solid-js"
import { Log } from "@/util"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit()
const args = useArgs()
async function bootstrap() {
console.log("bootstrapping")
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
if (workspace !== syncedWorkspace) {
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
await exit(e)
if (fatal) {
await exit(e)
} else {
throw e
}
})
}
const fullSyncedSessions = new Set<string>()
createEffect(
on(
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
onMount(() => {
void bootstrap()
})
const result = {
data: store,
@@ -463,6 +467,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
return true
if (process.env.OPENCODE_FAST_BOOT) return true
return store.status !== "loading"
},
@@ -314,8 +314,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setStore(
produce((draft) => {
const lock = pick(kv.get("theme_mode_lock"))
const mode = pick(kv.get("theme_mode", props.mode))
draft.mode = lock ?? mode ?? props.mode
const mode = lock ?? props.mode
if (!lock && pick(kv.get("theme_mode")) !== undefined) {
kv.set("theme_mode", undefined)
}
draft.mode = mode
draft.lock = lock
const active = config.theme ?? kv.get("theme", "opencode")
draft.active = typeof active === "string" ? active : "opencode"
@@ -373,7 +376,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}
function apply(mode: "dark" | "light") {
kv.set("theme_mode", mode)
if (store.lock !== undefined) kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache()
@@ -389,6 +392,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
kv.set("theme_mode", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/shared/npm"
import { Npm } from "@/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
@@ -91,7 +91,7 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
name: "session",
params: {
sessionID: route.data.sessionID,
initialPrompt: route.data.initialPrompt,
prompt: route.data.prompt,
},
}
}
@@ -10,7 +10,6 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin"
// TODO: what is the best way to do this?
let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
@@ -31,8 +30,8 @@ export function Home() {
setRef(r)
promptRef.set(r)
if (once || !r) return
if (route.initialPrompt) {
r.set(route.initialPrompt)
if (route.prompt) {
r.set(route.prompt)
once = true
return
}
@@ -38,7 +38,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
messageID: message.id,
})
const parts = sync.data.part[message.id] ?? []
const initialPrompt = parts.reduce(
const prompt = parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
@@ -51,7 +51,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
route.navigate({
sessionID: forked.data!.id,
type: "session",
initialPrompt,
prompt,
})
dialog.clear()
},
@@ -81,25 +81,23 @@ export function DialogMessage(props: {
sessionID: props.sessionID,
messageID: props.messageID,
})
const initialPrompt = (() => {
const msg = message()
if (!msg) return undefined
const parts = sync.data.part[msg.id]
return parts.reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
})()
const msg = message()
const prompt = msg
? sync.data.part[msg.id].reduce(
(agg, part) => {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
)
: undefined
route.navigate({
sessionID: result.data!.id,
type: "session",
initialPrompt,
prompt,
})
dialog.clear()
},
@@ -181,27 +181,32 @@ export function Session() {
const sdk = useSDK()
createEffect(async () => {
await sdk.client.session
.get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
.catch((e) => {
console.error(e)
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
return navigate({ type: "home" })
const previousWorkspace = project.workspace.current()
const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
if (!result.data) {
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
navigate({ type: "home" })
return
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch (e) {}
}
await sync.session.sync(route.sessionID)
if (scroll) scroll.scrollBy(100_000)
})
// Handle initial prompt from fork
let seeded = false
let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => {
const part = evt.properties.part
@@ -219,14 +224,15 @@ export function Session() {
}
})
let seeded = false
let scroll: ScrollBoxRenderable
let prompt: PromptRef | undefined
const bind = (r: PromptRef | undefined) => {
prompt = r
promptRef.set(r)
if (seeded || !route.initialPrompt || !r) return
if (seeded || !route.prompt || !r) return
seeded = true
r.set(route.initialPrompt)
r.set(route.prompt)
}
const keybind = useKeybind()
const dialog = useDialog()
@@ -1,3 +1,4 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
@@ -8,10 +9,23 @@ import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
@@ -48,6 +62,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
+6 -3
View File
@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
env,
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
@@ -1,7 +1,41 @@
import { createSignal, type Accessor } from "solid-js"
import { createEffect, createSignal, on, onCleanup, type Accessor } from "solid-js"
import { debounce, type Scheduled } from "@solid-primitives/scheduled"
export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Scheduled<[value: T]>] {
const [get, set] = createSignal(value)
return [get, debounce((v: T) => set(() => v), ms)]
}
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
let revealed = show()
createEffect(
on([show, enabled], ([visible, animate]) => {
if (!visible) {
setAlpha(0)
return
}
if (!animate || revealed) {
revealed = true
setAlpha(1)
return
}
const start = performance.now()
revealed = true
setAlpha(0)
const timer = setInterval(() => {
const progress = Math.min((performance.now() - start) / 160, 1)
setAlpha(progress * progress * (3 - 2 * progress))
if (progress >= 1) clearInterval(timer)
}, 16)
onCleanup(() => clearInterval(timer))
}),
)
return alpha
}
@@ -17,12 +17,6 @@ function parse(color: string): RGBA | null {
return null
}
function mode(bg: RGBA | null): "dark" | "light" {
if (!bg) return "dark"
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
return luminance > 0.5 ? "light" : "dark"
}
/**
* Query terminal colors including background, foreground, and palette (0-15).
* Uses OSC escape sequences to retrieve actual terminal color values.
@@ -100,36 +94,3 @@ export async function colors(): Promise<{
}, 1000)
})
}
// Keep startup mode detection separate from `colors()`: the TUI boot path only
// needs OSC 11 and should resolve on the first background response instead of
// waiting on the full palette query used by system theme generation.
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
if (!process.stdin.isTTY) return "dark"
return new Promise((resolve) => {
let timeout: NodeJS.Timeout
const cleanup = () => {
process.stdin.setRawMode(false)
process.stdin.removeListener("data", handler)
clearTimeout(timeout)
}
const handler = (data: Buffer) => {
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
if (!match) return
cleanup()
resolve(mode(parse(match[1])))
}
process.stdin.setRawMode(true)
process.stdin.on("data", handler)
process.stdout.write("\x1b]11;?\x07")
timeout = setTimeout(() => {
cleanup()
resolve("dark")
}, 1000)
})
}
@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@/util/opencode-process"
ensureProcessMetadata("worker")
await Log.init({
print: process.argv.includes("--print-logs"),
+6 -1
View File
@@ -3,4 +3,9 @@ export const logo = {
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
}
export const marks = "_^~"
export const go = {
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
}
export const marks = "_^~,"
+1 -1
View File
@@ -15,7 +15,7 @@ const log = Log.create({ service: "config" })
export const Info = z
.object({
model: ConfigModelID.optional(),
model: ConfigModelID.zod.optional(),
variant: z
.string()
.optional()
+12 -10
View File
@@ -1,10 +1,12 @@
export * as ConfigCommand from "./command"
import { Log } from "../util"
import z from "zod"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
const log = Log.create({ service: "config" })
export const Info = z.object({
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: ConfigModelID.optional(),
subtask: z.boolean().optional(),
})
export const Info = Schema.Struct({
template: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(ConfigModelID),
subtask: Schema.optional(Schema.Boolean),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
@@ -49,7 +51,7 @@ export async function load(dir: string) {
...md.data,
template: md.content.trim(),
}
const parsed = Info.safeParse(config)
const parsed = Info.zod.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
+245 -239
View File
@@ -24,7 +24,6 @@ import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { Npm } from "@opencode-ai/shared/npm"
import { ConfigAgent } from "./agent"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
@@ -39,6 +38,7 @@ import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "config" })
@@ -97,10 +97,10 @@ export const Info = z
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), ConfigCommand.Info)
.record(z.string(), ConfigCommand.Info.zod)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
@@ -113,7 +113,7 @@ export const Info = z
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: ConfigPlugin.Spec.array().optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
@@ -135,10 +135,10 @@ export const Info = z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.zod
.describe("Small model to use for tasks like title generation in the format of provider/model")
.optional(),
default_agent: z
.string()
.optional()
@@ -178,7 +178,7 @@ export const Info = z
.record(
z.string(),
z.union([
ConfigMCP.Info,
ConfigMCP.Info.zod,
z
.object({
enabled: z.boolean(),
@@ -188,8 +188,8 @@ export const Info = z
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.optional(),
lsp: ConfigLSP.Info.optional(),
formatter: ConfigFormatter.Info.zod.optional(),
lsp: ConfigLSP.Info.zod.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
@@ -413,260 +413,266 @@ export const layer = Layer.effect(
}
})
const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
let result: Info = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPlugin.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
})
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
return "global"
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
const mergePluginOrigins = Effect.fnUntraced(function* (
source: string,
// mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step
// is attached.
list: ConfigPlugin.Spec[] | undefined,
// Scope can be inferred from the source path, but some callers already know whether the config should
// behave as global or local and can pass that explicitly.
kind?: ConfigPlugin.Scope,
) {
if (!list?.length) return
const hit = kind ?? (yield* pluginScopeForSource(source))
// Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while
// keeping the winning source/scope metadata for downstream installs, writes, and diagnostics.
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
})
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
result = mergeConfigConcatArrays(result, next)
return mergePluginOrigins(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
process.env[value.key] = value.token
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (yield* Effect.promise(() => response.json())) as { config?: Record<string, unknown> }
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
}
const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(file, yield* loadFile(file), "local")
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Fiber.Fiber<void, never>[] = []
for (const dir of directories) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
yield* ensureGitignore(dir).pipe(Effect.orDie)
const dep = yield* npmSvc
.install(dir, {
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
return Effect.void
}),
)
}
.pipe(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
: Effect.void,
),
Effect.asVoid,
Effect.forkDetach,
)
deps.push(dep)
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
}
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
if (process.env.OPENCODE_CONFIG_CONTENT) {
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
}
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global")
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
)
}
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
})
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
deps,
consoleState: {
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
}
},
Effect.provideService(AppFileSystem.Service, fs),
)
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
return yield* loadInstanceState(ctx).pipe(Effect.orDie)
}),
)
+11 -10
View File
@@ -1,15 +1,16 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
export const ConsoleState = z.object({
consoleManagedProviders: z.array(z.string()),
activeOrgName: z.string().optional(),
switchableOrgCount: z.number().int().nonnegative(),
})
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: Schema.Number,
}) {
static readonly zod = zod(this)
}
export type ConsoleState = z.infer<typeof ConsoleState>
export const emptyConsoleState: ConsoleState = {
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
}
})
+13 -9
View File
@@ -1,13 +1,17 @@
export * as ConfigFormatter from "./formatter"
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Entry = z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
})
export const Entry = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
export type Info = z.infer<typeof Info>
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
+38 -30
View File
@@ -1,37 +1,45 @@
export * as ConfigLSP from "./lsp"
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as LSPServer from "../lsp/server"
export const Disabled = z.object({
disabled: z.literal(true),
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Entry = Schema.Union([
Disabled,
Schema.Struct({
command: Schema.mutable(Schema.Array(Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
disabled: Schema.optional(Schema.Boolean),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
]).pipe(withStatics((s) => ({ zod: zod(s) })))
/**
* For custom (non-builtin) LSP server entries, `extensions` is required so the
* client knows which files the server should attach to. Builtin server IDs and
* explicitly disabled entries are exempt.
*/
export const requiresExtensionsForCustomServers = Schema.makeFilter<
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
>((data) => {
if (typeof data === "boolean") return undefined
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
const ok = Object.entries(data).every(([id, config]) => {
if ("disabled" in config && config.disabled) return true
if (serverIds.has(id)) return true
return "extensions" in config && Boolean(config.extensions)
})
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
})
export const Entry = z.union([
Disabled,
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
])
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
.check(requiresExtensionsForCustomServers)
.pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
(data) => {
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
)
export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof Info>
+56 -62
View File
@@ -1,68 +1,62 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export class Local extends Schema.Class<Local>("McpLocalConfig")({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Command and arguments to run the MCP server",
}),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Environment variables to set when running the MCP server",
}),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
}) {
static readonly zod = zod(this)
}
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
clientId: Schema.optional(Schema.String).annotate({
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
}),
clientSecret: Schema.optional(Schema.String).annotate({
description: "OAuth client secret (if required by the authorization server)",
}),
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
redirectUri: Schema.optional(Schema.String).annotate({
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
}),
}) {
static readonly zod = zod(this)
}
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Headers to send with the request",
}),
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}),
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
}) {
static readonly zod = zod(this)
}
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
export const Info = Schema.Union([Local, Remote])
.annotate({ discriminator: "type" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigMCP from "./mcp"
+12 -1
View File
@@ -1,3 +1,14 @@
import { Schema } from "effect"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
// The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a
// literal pointer to an outside schema — so the walker cannot re-derive it
// from AST metadata. Preserve the exact original Zod via ZodOverride.
export const ConfigModelID = Schema.String.annotate({
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
+27 -19
View File
@@ -6,33 +6,41 @@ import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { unique } from "remeda"
import { JsonError } from "./error"
import * as Effect from "effect/Effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
export const files = Effect.fn("ConfigPaths.projectFiles")(function* (
name: string,
directory: string,
worktree?: string,
) {
const afs = yield* AppFileSystem.Service
return (yield* afs.up({
targets: [`${name}.jsonc`, `${name}.json`],
start: directory,
stop: worktree,
})).toReversed()
})
export async function directories(directory: string, worktree?: string) {
export const directories = Effect.fn("ConfigPaths.directories")(function* (directory: string, worktree?: string) {
const afs = yield* AppFileSystem.Service
return unique([
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
? yield* afs.up({
targets: [".opencode"],
start: directory,
stop: worktree,
})
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(yield* afs.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
})),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
])
}
})
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
+33 -30
View File
@@ -1,5 +1,8 @@
export * as ConfigPermission from "./permission"
import { Schema } from "effect"
import z from "zod"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
@@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => {
return val
}
export const Action = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type Action = z.infer<typeof Action>
export const Action = Schema.Literals(["ask", "allow", "deny"])
.annotate({ identifier: "PermissionActionConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = Schema.Schema.Type<typeof Action>
export const Object = z.record(z.string(), Action).meta({
ref: "PermissionObjectConfig",
})
export type Object = z.infer<typeof Object>
export const Object = Schema.Record(Schema.String, Action)
.annotate({ identifier: "PermissionObjectConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Object = Schema.Schema.Type<typeof Object>
export const Rule = z.union([Action, Object]).meta({
ref: "PermissionRuleConfig",
})
export type Rule = z.infer<typeof Rule>
export const Rule = Schema.Union([Action, Object])
.annotate({ identifier: "PermissionRuleConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>
const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
@@ -41,25 +44,25 @@ export const Info = z
z
.object({
__originalKeys: z.string().array().optional(),
read: Rule.optional(),
edit: Rule.optional(),
glob: Rule.optional(),
grep: Rule.optional(),
list: Rule.optional(),
bash: Rule.optional(),
task: Rule.optional(),
external_directory: Rule.optional(),
todowrite: Action.optional(),
question: Action.optional(),
webfetch: Action.optional(),
websearch: Action.optional(),
codesearch: Action.optional(),
lsp: Rule.optional(),
doom_loop: Action.optional(),
skill: Rule.optional(),
read: Rule.zod.optional(),
edit: Rule.zod.optional(),
glob: Rule.zod.optional(),
grep: Rule.zod.optional(),
list: Rule.zod.optional(),
bash: Rule.zod.optional(),
task: Rule.zod.optional(),
external_directory: Rule.zod.optional(),
todowrite: Action.zod.optional(),
question: Action.zod.optional(),
webfetch: Action.zod.optional(),
websearch: Action.zod.optional(),
codesearch: Action.zod.optional(),
lsp: Rule.zod.optional(),
doom_loop: Action.zod.optional(),
skill: Rule.zod.optional(),
})
.catchall(Rule)
.or(Action),
.catchall(Rule.zod)
.or(Action.zod),
)
.transform(transform)
.meta({
+9 -5
View File
@@ -1,16 +1,20 @@
import { Glob } from "@opencode-ai/shared/util/glob"
import z from "zod"
import { Schema } from "effect"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import path from "path"
const Options = z.record(z.string(), z.unknown())
export type Options = z.infer<typeof Options>
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Options = Schema.Schema.Type<typeof Options>
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
// It answers "what should we load?" but says nothing about where that value came from.
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
export type Spec = z.infer<typeof Spec>
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Spec = Schema.Schema.Type<typeof Spec>
export type Scope = "global" | "local"
+12 -9
View File
@@ -1,13 +1,16 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export const Info = Schema.Struct({
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
description: "Additional paths to skill folders",
}),
urls: Schema.optional(Schema.Array(Schema.String)).annotate({
description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
}),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigSkills from "./skills"
+1 -1
View File
@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}
@@ -2,13 +2,13 @@ import { LocalContext } from "../util"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: WorkspaceID
workspaceID: WorkspaceID | undefined
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
},
+151 -67
View File
@@ -7,7 +7,7 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { Flag } from "@/flag/flag"
import { Log } from "@/util"
import { Filesystem } from "@/util"
@@ -23,8 +23,8 @@ import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { EventSequenceTable } from "@/sync/event.sql"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
@@ -34,7 +34,6 @@ export type Info = z.infer<typeof Info>
export const ConnectionStatus = z.object({
workspaceID: WorkspaceID.zod,
status: z.enum(["connected", "connecting", "disconnected", "error"]),
error: z.string().optional(),
})
export type ConnectionStatus = z.infer<typeof ConnectionStatus>
@@ -116,6 +115,8 @@ export const create = fn(CreateInput, async (input) => {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}
await adaptor.create(config, env)
@@ -298,22 +299,13 @@ export function list(project: Project.Info) {
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
function lookup(id: WorkspaceID) {
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
@@ -345,10 +337,10 @@ const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
const TIMEOUT = 5000
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"]) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
if (prev?.status === status) return
const next = { workspaceID: id, status }
connections.set(id, next)
if (status === "error") {
@@ -425,68 +417,145 @@ function route(url: string | URL, path: string) {
return next
}
async function syncWorkspace(space: Info, signal: AbortSignal) {
async function connectSSE(url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const res = await fetch(route(url, "/global/event"), {
method: "GET",
headers,
signal,
})
if (!res.ok) throw new Error(`Workspace sync HTTP failure: ${res.status}`)
if (!res.body) throw new Error("No response body from global sync")
return res.body
}
async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const sessionIDs = Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const state = sessionIDs.length
? Object.fromEntries(
Database.use((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
).map((row) => [row.aggregate_id, row.seq]),
)
: {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
const requestHeaders = new Headers(headers)
requestHeaders.set("content-type", "application/json")
const res = await fetch(route(url, "/sync/history"), {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(state),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`)
}
const events = await res.json()
return WorkspaceContext.provide({
workspaceID: space.id,
fn: () => {
for (const event of events) {
SyncEvent.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
)
}
},
})
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
})
}
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return null
let attempt = 0
while (!signal.aborted) {
log.info("connecting to global sync", { workspace: space.name })
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
if (target.type === "local") return
const res = await fetch(route(target.url, "/global/event"), {
method: "GET",
headers: target.headers,
signal,
}).catch((err: unknown) => {
setStatus(space.id, "error", err instanceof Error ? err.message : String(err))
let stream
try {
stream = await connectSSE(target.url, target.headers, signal)
await syncHistory(space, target.url, target.headers, signal)
} catch (err) {
stream = null
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
error: err,
err,
})
return undefined
})
if (!res || !res.ok || !res.body) {
const error = !res ? "No response from global sync" : `Global sync HTTP ${res.status}`
log.info("failed to connect to global sync", { workspace: space.name, error })
setStatus(space.id, "error", error)
await sleep(1000)
continue
}
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
if (stream) {
attempt = 0
await parseSSE(res.body, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
log.info("global sync connected", { workspace: space.name })
setStatus(space.id, "connected")
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
await parseSSE(stream, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
if (evt.payload.type === "server.heartbeat") return
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
}
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.info("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
GlobalBus.emit("event", {
directory: evt.directory,
project: evt.project,
workspace: space.id,
payload: evt.payload,
})
} catch (err) {
log.info("failed to replay global event", {
workspaceID: space.id,
error: err,
})
}
})
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
}
log.info("disconnected from global sync: " + space.id)
setStatus(space.id, "disconnected")
// TODO: Implement exponential backoff
await sleep(1000)
// Back off reconnect attempts up to 2 minutes while the workspace
// stays unavailable.
await sleep(Math.min(120_000, 1_000 * 2 ** attempt))
attempt += 1
}
}
@@ -498,7 +567,7 @@ async function startSync(space: Info) {
if (target.type === "local") {
void Filesystem.exists(target.directory).then((exists) => {
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
setStatus(space.id, exists ? "connected" : "error")
})
return
}
@@ -510,10 +579,10 @@ async function startSync(space: Info) {
const abort = new AbortController()
aborts.set(space.id, abort)
void syncWorkspace(space, abort.signal).catch((error) => {
void syncWorkspaceLoop(space, abort.signal).catch((error) => {
aborts.delete(space.id)
setStatus(space.id, "error", String(error))
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
@@ -527,4 +596,19 @@ function stopSync(id: WorkspaceID) {
connections.delete(id)
}
export function startWorkspaceSyncing(projectID: ProjectID) {
const spaces = Database.use((db) =>
db
.select({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) {
void startSync(fromRow(row))
}
}
export * as Workspace from "./workspace"
+3 -4
View File
@@ -1,5 +1,5 @@
import { Layer, ManagedRuntime } from "effect"
import { attach, memoMap } from "./run-service"
import { attach } from "./run-service"
import * as Observability from "./observability"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -9,7 +9,6 @@ import { Account } from "@/account/account"
import { Config } from "@/config"
import { Git } from "@/git"
import { Ripgrep } from "@/file/ripgrep"
import { FileTime } from "@/file/time"
import { File } from "@/file"
import { FileWatcher } from "@/file/watcher"
import { Storage } from "@/storage"
@@ -47,7 +46,8 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share"
import { SessionShare } from "@/share"
import { Npm } from "@opencode-ai/shared/npm"
import { Npm } from "@/npm"
import { memoMap } from "./memo-map"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -58,7 +58,6 @@ export const AppLayer = Layer.mergeAll(
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
@@ -1,5 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
@@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Config } from "@/config"
import * as Observability from "./observability"
import { memoMap } from "./memo-map"
export const BootstrapLayer = Layer.mergeAll(
Config.defaultLayer,
+3
View File
@@ -0,0 +1,3 @@
import { Layer } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()
+30 -20
View File
@@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { ensureProcessMetadata } from "@/util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
)
: undefined
const resource = {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
},
function resource() {
const processMetadata = ensureProcessMetadata("main")
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
const logs = Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource,
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
@@ -58,7 +68,7 @@ const traces = async () => {
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource,
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
@@ -73,7 +83,7 @@ export const layer = !base
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs)
return Layer.mergeAll(trace, logs())
}),
)
+1 -2
View File
@@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"
export const memoMap = Layer.makeMemoMapUnsafe()
import { memoMap } from "./memo-map"
type Refs = {
instance?: InstanceContext
@@ -1,7 +1,6 @@
import { Observability } from "@/effect/observability"
import { Observability } from "./observability"
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()
import { memoMap } from "./memo-map"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+2 -3
View File
@@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep"
import { Filesystem } from "@/util"
import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
const log = Log.create({ service: "ripgrep" })
@@ -157,9 +158,7 @@ type WorkerError = {
}
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { ripgrep } from "ripgrep"
import { sanitizedProcessEnv } from "@/util/opencode-process"
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
-113
View File
@@ -1,113 +0,0 @@
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Log } from "../util"
const log = Log.create({ service: "file.time" })
export type Stamp = {
readonly read: Date
readonly mtime: number | undefined
readonly size: number | undefined
}
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
interface State {
reads: Map<SessionID, Map<string, Stamp>>
locks: Map<string, Semaphore.Semaphore>
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const stamp = Effect.fnUntraced(function* (file: string) {
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
return {
read: yield* DateTime.nowAsDate,
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
size: info ? Number(info.size) : undefined,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() =>
Effect.succeed({
reads: new Map<SessionID, Map<string, Stamp>>(),
locks: new Map<string, Semaphore.Semaphore>(),
}),
),
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = AppFileSystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath)
if (lock) return lock
const next = Semaphore.makeUnsafe(1)
locks.set(filepath, next)
return next
})
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = AppFileSystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
filepath = AppFileSystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath)
const changed = next.mtime !== time.mtime || next.size !== time.size
if (!changed) return
throw new Error(
`File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`,
)
})
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}),
).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
export * as FileTime from "./time"
+1 -2
View File
@@ -70,7 +70,6 @@ export const Flag = {
OPENCODE_EXPERIMENTAL_OXFMT: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT"),
OPENCODE_EXPERIMENTAL_LSP_TY: truthy("OPENCODE_EXPERIMENTAL_LSP_TY"),
OPENCODE_EXPERIMENTAL_LSP_TOOL: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
OPENCODE_DISABLE_FILETIME_CHECK: Config.boolean("OPENCODE_DISABLE_FILETIME_CHECK").pipe(Config.withDefault(false)),
OPENCODE_EXPERIMENTAL_PLAN_MODE: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
OPENCODE_EXPERIMENTAL_MARKDOWN: !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN"),
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
@@ -82,7 +81,7 @@ export const Flag = {
OPENCODE_STRICT_CONFIG_DEPS: truthy("OPENCODE_STRICT_CONFIG_DEPS"),
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_HTTPAPI: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
OPENCODE_EXPERIMENTAL_HTTPAPI: truthy("OPENCODE_EXPERIMENTAL_HTTPAPI"),
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
// Evaluated at access time (not module load) because tests, the CLI, and
+5
View File
@@ -38,6 +38,9 @@ import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "./util/opencode-process"
const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -108,6 +111,8 @@ const cli = yargs(args)
Log.Default.info("opencode", {
version: InstallationVersion,
args: process.argv.slice(2),
process_role: processMetadata.processRole,
run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
+245 -166
View File
@@ -1,198 +1,277 @@
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Global } from "../global"
import { Log } from "../util"
export * as Npm from "."
import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/shared/global"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { makeRuntime } from "../effect/runtime"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface EntryPoint {
readonly directory: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
add: {
name: string
version?: string
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", sanitize(pkg))
}
function resolveEntryPoint(name: string, dir: string) {
let entrypoint: string | undefined
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {}
const result = {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch {
entrypoint = Option.none()
}
return {
directory: dir,
entrypoint,
}
return result
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
interface ArboristNode {
name: string
path: string
}
export async function add(pkg: string) {
const { Arborist } = await import("@npmcli/arborist")
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})
interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
return resolveEntryPoint(first.name, first.path)
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arborist = new Arborist({
path: input.dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const response = yield* Effect.tryPromise({
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []).map((pkg) => pkg.name),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = parsed.bin
if (typeof bin === "string") return Option.some(unscoped)
const keys = Object.keys(bin)
if (keys.length === 1) return Option.some(keys[0])
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(files[0])
})
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return resolveEntryPoint(first.name, first.path)
}
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
const reify = async () => {
const { Arborist } = await import("@npmcli/arborist")
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
return Service.of({
add,
install,
outdated,
which,
})
await arb.reify().catch(() => {})
}
}),
)
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
type PackageDeps = Record<string, string>
type PackageJson = {
dependencies?: PackageDeps
devDependencies?: PackageDeps
peerDependencies?: PackageDeps
optionalDependencies?: PackageDeps
}
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
packages?: Record<string, PackageJson>
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
const { runPromise } = makeRuntime(Service, defaultLayer)
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}
export async function which(pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = async () => {
const files = await readdir(binDir).catch(() => [])
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
if (typeof bin === "string") return unscoped
const keys = Object.keys(bin)
if (keys.length === 1) return keys[0]
return bin[unscoped] ? unscoped : keys[0]
}
return files[0]
export async function add(...args: Parameters<Interface["add"]>) {
const entry = await runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
const bin = await pick()
if (bin) return path.join(binDir, bin)
await rm(path.join(dir, "package-lock.json"), { force: true })
await add(pkg)
const resolved = await pick()
if (!resolved) return
return path.join(binDir, resolved)
}
export * as Npm from "."
export async function outdated(...args: Parameters<Interface["outdated"]>) {
return runPromise((svc) => svc.outdated(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
}
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
@@ -10,6 +10,11 @@ export const schema = z.object({
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
policy: z
.object({
state: z.string().optional(),
})
.optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
@@ -122,7 +127,9 @@ export async function get(
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
)
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
+42
View File
@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = {
spec: string
options: ConfigPlugin.Options | undefined
deprecated: boolean
}
// A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
// A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
// Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void
// Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
// Called for operational failures such as install, compatibility, or dynamic import errors.
error?: (
candidate: Candidate,
retry: boolean,
@@ -46,11 +56,16 @@ export namespace PluginLoader {
) => void
}
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
//
// The stages here intentionally separate install/target resolution, entrypoint detection,
// and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve(
plan: Plan,
kind: PluginKind,
@@ -59,6 +74,7 @@ export namespace PluginLoader {
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
// First make sure the plugin exists locally, installing npm plugins on demand.
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
@@ -67,6 +83,7 @@ export namespace PluginLoader {
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
// Then inspect the target for the requested server/tui entrypoint.
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
@@ -86,6 +103,8 @@ export namespace PluginLoader {
},
}
// npm plugins can declare which opencode versions they support; file plugins are treated
// as local development code and skip this compatibility gate.
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@@ -96,6 +115,7 @@ export namespace PluginLoader {
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
// Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
@@ -107,6 +127,8 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } }
}
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
// import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
@@ -116,11 +138,17 @@ export namespace PluginLoader {
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
// Missing entrypoints are handled separately so callers can still inspect package metadata,
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
@@ -131,11 +159,15 @@ export namespace PluginLoader {
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
}
@@ -149,6 +181,11 @@ export namespace PluginLoader {
report?: Report
}
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins that initially failed are retried once after the
// caller finishes preparing dependencies. This supports local plugins that depend on an install
// step happening elsewhere before their entrypoint becomes loadable.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
@@ -160,6 +197,9 @@ export namespace PluginLoader {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
// Only local file plugins are retried. npm plugins already attempted installation during
// the first pass, while file plugins may need the caller's dependency preparation to finish.
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
@@ -167,6 +207,8 @@ export namespace PluginLoader {
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
+1 -1
View File
@@ -2,9 +2,9 @@ import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver"
import { Npm } from "../npm"
import { Filesystem } from "@/util"
import { isRecord } from "@/util/record"
import { Npm } from "@/npm"
// Old npm package names for plugins that are now built-in
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]

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