Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton ea19ee7966 refactor(session): use onInterrupt finalizer for cancelled tool output
Wire the AI SDK's abortSignal into the tool fiber via runPromiseExit's
signal option so interruption is first-class, and move the "finalize on
cancel" path into an Effect.onInterrupt finalizer that re-awaits the
still-running native Promise uninterruptibly, builds the output, and
posts it through completeToolCall.

Replaces the imperative `if (options.abortSignal?.aborted)` tail check
with structural interruption handling. When the fiber is interrupted,
the finalizer captures the truncated bash output (or MCP tool result)
and the .then on runPromiseExit resolves the SDK's Promise with the
captured value instead of propagating the interrupt cause as a
rejection, so the tool is reported as successfully completed rather
than as a tool-error.

InstanceRef is provided on the tool fiber so InstanceState.context
resolves through ServiceMap rather than falling through to the
AsyncLocalStorage, which the onInterrupt finalizer runs outside of.
2026-04-09 16:40:57 -04:00
260 changed files with 66854 additions and 73424 deletions
-1
View File
@@ -26,7 +26,6 @@ kommander
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-robinmordasiewicz -robinmordasiewicz
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-toastythebot -toastythebot
+3 -6
View File
@@ -114,7 +114,7 @@ jobs:
- build-cli - build-cli
- version - version
runs-on: blacksmith-4vcpu-windows-2025 runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -213,7 +213,6 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -390,7 +389,6 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -423,6 +421,7 @@ jobs:
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
platform_flag: --linux platform_flag: --linux
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
# if: github.ref_name == 'beta'
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -548,7 +547,6 @@ jobs:
- sign-cli-windows - sign-cli-windows
- build-tauri - build-tauri
- build-electron - build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -591,13 +589,12 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: github.ref_name != 'beta'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@v4 - uses: actions/download-artifact@v4
if: needs.version.outputs.release && github.ref_name != 'beta' if: needs.version.outputs.release
with: with:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
-13
View File
@@ -17,9 +17,6 @@ permissions:
contents: read contents: read
checks: write checks: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
unit: unit:
name: unit (${{ matrix.settings.name }}) name: unit (${{ matrix.settings.name }})
@@ -41,11 +38,6 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
@@ -110,11 +102,6 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Setup Bun - name: Setup Bun
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
+41 -53
View File
@@ -27,7 +27,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -223,7 +223,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"effect": "catalog:", "effect": "catalog:",
"electron-context-menu": "4.1.2", "electron-context-menu": "4.1.2",
@@ -266,7 +266,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -295,7 +295,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -311,7 +311,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.4.3", "version": "1.4.2",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -319,7 +319,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
@@ -331,7 +331,7 @@
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.37",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
@@ -341,6 +341,7 @@
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11", "@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
@@ -356,7 +357,7 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1", "@openrouter/ai-sdk-provider": "2.4.2",
"@opentui/core": "0.1.97", "@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97", "@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
@@ -412,7 +413,7 @@
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.84.2", "@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -446,10 +447,9 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@@ -481,7 +481,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -496,7 +496,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -531,7 +531,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -580,7 +580,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -591,7 +591,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -641,7 +641,7 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.46", "@effect/platform-node": "4.0.0-beta.43",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10", "@lydell/node-pty": "1.2.0-beta.10",
@@ -662,13 +662,13 @@
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1", "@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158", "ai": "6.0.149",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.46", "effect": "4.0.0-beta.43",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -707,7 +707,7 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="], "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
"@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-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@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-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.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=="],
@@ -1025,11 +1025,11 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/language-service": ["@effect/language-service@0.84.2", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-l04qNxpiA8rY5yXWckRPJ7Mk5MNerXuNymSFf+IdflfI5i8jgL1bpBNLuP6ijg7wgjdHc/KmTnCj2kT0SCntuA=="], "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.46", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.46", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46", "ioredis": "^5.7.0" } }, "sha512-6AFRKjJO95dFl5lK/YnJi04uePjQDFi3+K1aXwcz/EfVlRwJ4+lg5O4vbievfKL/hnfcShVp3/eXnNS9tvlMZQ=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.46", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.46" } }, "sha512-Yzci82XbZ1W3tuiownsJawrJZTGeTrTZKLD0uxdBWCBzlVyqDwoSwRwO5qh33DurJj9B7iS8MDf14fpGRBPNGQ=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1161,6 +1161,8 @@
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1537,7 +1539,7 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"], "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2383,7 +2385,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "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.149", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@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-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="],
"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=="], "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=="],
@@ -2887,7 +2889,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.46", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-3f6gXvvUMtEueCRY0tU76Vq2Pej1SAwwE+s0Owd5nD53yS5n4RZhUA1rlCGFuSbQFA225pGy8vO72+lpvu7u5A=="], "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5003,11 +5005,7 @@
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], "@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
"@ai-sdk/amazon-bedrock/@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=="], "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5283,6 +5281,10 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -5507,10 +5509,6 @@
"@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="], "@solidjs/start/vite-plugin-solid": ["vite-plugin-solid@2.11.11", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-YMZCXsLw9kyuvQFEdwLP27fuTQJLmjNoHy90AOJnbRuJ6DwShUxKFo38gdFrWn9v11hnGicKCZEaeI/TFs6JKw=="],
"@standard-community/standard-json/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
@@ -5551,9 +5549,7 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"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/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.91", "", { "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-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@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-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="], "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
@@ -5769,8 +5765,6 @@
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="], "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -5953,6 +5947,8 @@
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -6439,18 +6435,12 @@
"@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="],
"@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -6823,8 +6813,6 @@
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], "@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=", "x86_64-linux": "sha256-285KZ7rZLRoc6XqCZRHc25NE+mmpGh/BVeMpv8aPQtQ=",
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=", "aarch64-linux": "sha256-qIwmY4TP4CI7R7G6A5OMYRrorVNXjkg25tTtVpIHm2o=",
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=", "aarch64-darwin": "sha256-RwvnZQhdYZ0u7h7evyfxuPLHHX9eO/jXTAxIFc8B+IE=",
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE=" "x86_64-darwin": "sha256-vVj40al+TEeMpbe5XG2GmJEpN+eQAvtr9W0T98l5PBE="
} }
} }
+3 -3
View File
@@ -26,7 +26,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.46", "@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@@ -47,8 +47,8 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.46", "effect": "4.0.0-beta.43",
"ai": "6.0.158", "ai": "6.0.149",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
+3 -7
View File
@@ -44,12 +44,8 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
} }
function done(proc: ReturnType<typeof spawn>) {
return proc.exitCode !== null || proc.signalCode !== null
}
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) { async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (done(proc)) return if (proc.exitCode !== null) return
await Promise.race([ await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())), new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)), new Promise<void>((resolve) => setTimeout(resolve, timeout)),
@@ -127,11 +123,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
return { return {
url, url,
async stop() { async stop() {
if (!done(proc)) { if (proc.exitCode === null) {
proc.kill("SIGTERM") proc.kill("SIGTERM")
await waitExit(proc) await waitExit(proc)
} }
if (!done(proc)) { if (proc.exitCode === null) {
proc.kill("SIGKILL") proc.kill("SIGKILL")
await waitExit(proc) await waitExit(proc)
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.4.3", "version": "1.4.2",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.4.3", "version": "1.4.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -316,8 +316,7 @@
/* Download Hero Section */ /* Download Hero Section */
[data-component="download-hero"] { [data-component="download-hero"] {
/* display: grid; */ display: grid;
display: none;
grid-template-columns: 260px 1fr; grid-template-columns: 260px 1fr;
gap: 4rem; gap: 4rem;
padding-bottom: 2rem; padding-bottom: 2rem;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.4.3", "version": "1.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.4.3", "version": "1.4.2",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.4.3", "version": "1.4.2",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.4.3", "version": "1.4.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.4.3", "version": "1.4.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
if (!releaseId) throw new Error("OPENCODE_RELEASE is required") if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION const version = process.env.OPENCODE_VERSION
if (!version) throw new Error("OPENCODE_VERSION is required") if (!releaseId) throw new Error("OPENCODE_VERSION is required")
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required") if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
@@ -54,10 +54,7 @@ const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset])) const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
const latestAsset = assetByName.get("latest.json") const latestAsset = assetByName.get("latest.json")
if (!latestAsset) { if (!latestAsset) throw new Error("latest.json asset not found")
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
}
const latestRes = await fetch(latestAsset.url, { const latestRes = await fetch(latestAsset.url, {
headers: { headers: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.4.3", "version": "1.4.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.4.3" version = "1.4.2"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.2/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.4.3", "version": "1.4.2",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+6 -10
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.4.3", "version": "1.4.2",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -39,16 +39,11 @@
"bun": "./src/pty/pty.bun.ts", "bun": "./src/pty/pty.bun.ts",
"node": "./src/pty/pty.node.ts", "node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts" "default": "./src/pty/pty.bun.ts"
},
"#hono": {
"bun": "./src/server/adapter.bun.ts",
"node": "./src/server/adapter.node.ts",
"default": "./src/server/adapter.bun.ts"
} }
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.4", "@babel/core": "7.28.4",
"@effect/language-service": "0.84.2", "@effect/language-service": "0.79.0",
"@octokit/webhooks-types": "7.6.1", "@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -83,7 +78,7 @@
"@actions/core": "1.11.1", "@actions/core": "1.11.1",
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.83",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.67",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
@@ -95,7 +90,7 @@
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.37",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
@@ -105,6 +100,7 @@
"@aws-sdk/credential-providers": "3.993.0", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11", "@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0", "@hono/node-ws": "1.3.0",
@@ -120,7 +116,7 @@
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1", "@openrouter/ai-sdk-provider": "2.4.2",
"@opentui/core": "0.1.97", "@opentui/core": "0.1.97",
"@opentui/solid": "0.1.97", "@opentui/solid": "0.1.97",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
+10 -87
View File
@@ -23,7 +23,7 @@ export namespace Foo {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError> readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -217,37 +217,36 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `SessionSummary``session/summary.ts` - [x] `SessionSummary``session/summary.ts`
- [x] `SessionRevert``session/revert.ts` - [x] `SessionRevert``session/revert.ts`
- [x] `Instruction``session/instruction.ts` - [x] `Instruction``session/instruction.ts`
- [x] `SystemPrompt``session/system.ts`
- [x] `Provider``provider/provider.ts` - [x] `Provider``provider/provider.ts`
- [x] `Storage``storage/storage.ts` - [x] `Storage``storage/storage.ts`
- [x] `ShareNext``share/share-next.ts`
Still open: Still open:
- [x] `SessionTodo``session/todo.ts` - [ ] `SessionTodo``session/todo.ts`
- [ ] `ShareNext``share/share-next.ts`
- [ ] `SyncEvent``sync/index.ts` - [ ] `SyncEvent``sync/index.ts`
- [ ] `Workspace``control-plane/workspace.ts` - [ ] `Workspace``control-plane/workspace.ts`
## Tool interface → Effect ## Tool interface → Effect
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is: Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
1. Migrate each tool body to return Effects 1. Migrate each tool to return Effects
2. Keep `Tool.define()` inputs Effect-native 2. Update `Tool.define()` factory to work with Effects
3. Update remaining callers to `yield*` tool initialization instead of `await`ing 3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
### Tool migration details ### Tool migration details
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally: Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition. - `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`. - Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests. - If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`: Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools. - Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`. - Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production. - Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later. This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.
@@ -309,79 +308,3 @@ Current raw fs users that will convert during tool migration:
- [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer - [ ] `util/flock.ts` — file-based distributed lock with heartbeat → Effect.repeat + addFinalizer
- [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise - [ ] `util/process.ts` — child process spawn wrapper → return Effect instead of Promise
- [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code - [ ] `util/lazy.ts` — replace uses in Effect code with Effect.cached; keep for sync-only code
## Destroying the facades
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
### Process
For each service, the migration is roughly:
1. **Find callers.** `grep -n "Namespace\.(methodA|methodB|...)"` across `src/` and `test/`. Skip the service file itself.
2. **Migrate production callers.** For each effectful caller that does `Effect.tryPromise(() => Namespace.method(...))`:
- Add the service to the caller's layer R type (`Layer.Layer<Self, never, ... | Namespace.Service>`)
- Yield it at the top of the layer: `const ns = yield* Namespace.Service`
- Replace `Effect.tryPromise(() => Namespace.method(...))` with `yield* ns.method(...)` (or `ns.method(...).pipe(Effect.orElseSucceed(...))` for the common fallback case)
- Add `Layer.provide(Namespace.defaultLayer)` to the caller's own `defaultLayer` chain
3. **Fix tests that used the caller's raw `.layer`.** Any test that composes `Caller.layer` (not `defaultLayer`) needs to also provide the newly-required service tag. The fastest fix is usually switching to `Caller.defaultLayer` since it now pulls in the new dependency.
4. **Migrate test callers of the facade.** Tests calling `Namespace.method(...)` directly get converted to full effectful style using `testEffect(Namespace.defaultLayer)` + `it.live` / `it.effect` + `yield* svc.method(...)`. Don't wrap the test body in `Effect.promise(async () => {...})` — do the whole thing in `Effect.gen` and use `AppFileSystem.Service` / `tmpdirScoped` / `Effect.addFinalizer` for what used to be raw `fs` / `Bun.write` / `try/finally`.
5. **Delete the facades.** Once `grep` shows zero callers, remove the `export async function` block AND the `makeRuntime(...)` line from the service namespace. Also remove the now-unused `import { makeRuntime }`.
### Pitfalls
- **Layer caching inside tests.** `testEffect(layer)` constructs the Storage (or whatever) service once and memoizes it. If a test then tries `inner.pipe(Effect.provide(customStorage))` to swap in a differently-configured Storage, the outer cached one wins and the inner provision is a no-op. Fix: wrap the overriding layer in `Layer.fresh(...)`, which forces a new instance to be built instead of hitting the memoMap cache. This lets a single `testEffect(...)` serve both simple and per-test-customized cases.
- **`Effect.tryPromise``yield*` drops the Promise layer.** The old code was `Effect.tryPromise(() => Storage.read(...))` — a `tryPromise` wrapper because the facade returned a Promise. The new code is `yield* storage.read(...)` directly — the service method already returns an Effect, so no wrapper is needed. Don't reach for `Effect.promise` or `Effect.tryPromise` during migration; if you're using them on a service method call, you're doing it wrong.
- **Raw `.layer` test callers break silently in the type checker.** When you add a new R requirement to a service's `.layer`, any test that composes it raw (not `defaultLayer`) becomes under-specified. `tsgo` will flag this — the error looks like `Type 'Storage.Service' is not assignable to type '... | Service | TestConsole'`. Usually the fix is to switch that composition to `defaultLayer`, or add `Layer.provide(NewDep.defaultLayer)` to the custom composition.
- **Tests that do async setup with `fs`, `Bun.write`, `tmpdir`.** Convert these to `AppFileSystem.Service` calls inside `Effect.gen`, and use `tmpdirScoped()` instead of `tmpdir()` so cleanup happens via the scope finalizer. For file operations on the actual filesystem (not via a service), a small helper like `const writeJson = Effect.fnUntraced(function* (file, value) { const fs = yield* AppFileSystem.Service; yield* fs.makeDirectory(path.dirname(file), { recursive: true }); yield* fs.writeFileString(file, JSON.stringify(value, null, 2)) })` keeps the migration tests clean.
### Migration log
- `SessionStatus` — migrated 2026-04-11. Replaced the last route and retry-policy callers with `AppRuntime.runPromise(SessionStatus.Service.use(...))` and removed the `makeRuntime(...)` facade.
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/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/routes/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
```ts
// Before — one facade call per service
;async (c) => {
await SessionRunState.assertNotBusy(id)
await Session.removeMessage({ sessionID: id, messageID })
return c.json(true)
}
// After — one Effect.gen, yield services from context
;async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(id)
yield* session.removeMessage({ sessionID: id, messageID })
}),
)
return c.json(true)
}
```
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
Route files to convert (each handler that calls facades should be wrapped):
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
- [ ] `server/routes/question.ts` — uses Question
- [ ] `server/routes/pty.ts` — uses Pty
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
+4 -1
View File
@@ -202,7 +202,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.kv.get`, `set`, `ready` - `api.kv.get`, `set`, `ready`
- `api.state` - `api.state`
- `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready` - `api.theme.current`, `selected`, `has`, `set`, `install`, `mode`, `ready`
- `api.client` - `api.client`, `api.scopedClient(workspaceID?)`, `api.workspace.current()`, `api.workspace.set(workspaceID?)`
- `api.event.on(type, handler)` - `api.event.on(type, handler)`
- `api.renderer` - `api.renderer`
- `api.slots.register(plugin)` - `api.slots.register(plugin)`
@@ -270,6 +270,7 @@ Command behavior:
- `provider` - `provider`
- `path.{state,config,worktree,directory}` - `path.{state,config,worktree,directory}`
- `vcs?.branch` - `vcs?.branch`
- `workspace.list()` / `workspace.get(workspaceID)`
- `session.count()` - `session.count()`
- `session.diff(sessionID)` - `session.diff(sessionID)`
- `session.todo(sessionID)` - `session.todo(sessionID)`
@@ -281,6 +282,8 @@ Command behavior:
- `lsp()` - `lsp()`
- `mcp()` - `mcp()`
- `api.client` always reflects the current runtime client. - `api.client` always reflects the current runtime client.
- `api.scopedClient(workspaceID?)` creates or reuses a client bound to a workspace.
- `api.workspace.set(...)` rebinds the active workspace; `api.client` follows that rebind.
- `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function. - `api.event.on(type, handler)` subscribes to the TUI event stream and returns an unsubscribe function.
- `api.renderer` exposes the raw `CliRenderer`. - `api.renderer` exposes the raw `CliRenderer`.
+17 -2
View File
@@ -1,4 +1,4 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { import {
FetchHttpClient, FetchHttpClient,
HttpClient, HttpClient,
@@ -7,6 +7,7 @@ import {
HttpClientResponse, HttpClientResponse,
} from "effect/unstable/http" } from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo" import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url" import { normalizeServerUrl } from "./url"
@@ -180,7 +181,7 @@ export namespace Account {
readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError> readonly poll: (input: Login) => Effect.Effect<PollResult, AccountError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect(
Service, Service,
@@ -453,4 +454,18 @@ export namespace Account {
) )
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer)) export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const { runPromise } = makeRuntime(Service, defaultLayer)
export async function active(): Promise<Info | undefined> {
return Option.getOrUndefined(await runPromise((service) => service.active()))
}
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
return runPromise((service) => service.orgsByAccount())
}
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
return runPromise((service) => service.use(accountID, Option.some(orgID)))
}
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect, Layer, Option, Schema, Context } from "effect" import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql" import { AccountStateTable, AccountTable } from "./account.sql"
@@ -38,7 +38,7 @@ export namespace AccountRepo {
} }
} }
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") { export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") {
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect( static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo, AccountRepo,
Effect.gen(function* () { Effect.gen(function* () {
+26 -6
View File
@@ -1,22 +1,42 @@
import { Schema } from "effect" import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError" import type * as HttpClientError from "effect/unstable/http/HttpClientError"
export const AccountID = Schema.String.pipe(Schema.brand("AccountID")) import { withStatics } from "@/util/schema"
export const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type AccountID = Schema.Schema.Type<typeof AccountID> export type AccountID = Schema.Schema.Type<typeof AccountID>
export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) export const OrgID = Schema.String.pipe(
Schema.brand("OrgID"),
withStatics((s) => ({ make: (id: string) => s.makeUnsafe(id) })),
)
export type OrgID = Schema.Schema.Type<typeof OrgID> export type OrgID = Schema.Schema.Type<typeof OrgID>
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) export const AccessToken = Schema.String.pipe(
Schema.brand("AccessToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type AccessToken = Schema.Schema.Type<typeof AccessToken> export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) export const RefreshToken = Schema.String.pipe(
Schema.brand("RefreshToken"),
withStatics((s) => ({ make: (token: string) => s.makeUnsafe(token) })),
)
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken> export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode")) export const DeviceCode = Schema.String.pipe(
Schema.brand("DeviceCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode> export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe(Schema.brand("UserCode")) export const UserCode = Schema.String.pipe(
Schema.brand("UserCode"),
withStatics((s) => ({ make: (code: string) => s.makeUnsafe(code) })),
)
export type UserCode = Schema.Schema.Type<typeof UserCode> export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({ export class Info extends Schema.Class<Info>("Account")({
+10 -13
View File
@@ -19,7 +19,7 @@ import { Global } from "@/global"
import path from "path" import path from "path"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Effect, Context, Layer } from "effect" import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -67,7 +67,7 @@ export namespace Agent {
type State = Omit<Interface, "generate"> type State = Omit<Interface, "generate">
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -341,10 +341,6 @@ export namespace Agent {
) )
const existing = yield* InstanceState.useEffect(state, (s) => s.list()) const existing = yield* InstanceState.useEffect(state, (s) => s.list())
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
const params = { const params = {
experimental_telemetry: { experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry, isEnabled: cfg.experimental?.openTelemetry,
@@ -354,14 +350,12 @@ export namespace Agent {
}, },
temperature: 0.3, temperature: 0.3,
messages: [ messages: [
...(isOpenaiOauth ...system.map(
? []
: system.map(
(item): ModelMessage => ({ (item): ModelMessage => ({
role: "system", role: "system",
content: item, content: item,
}), }),
)), ),
{ {
role: "user", role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`, content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
@@ -375,12 +369,13 @@ export namespace Agent {
}), }),
} satisfies Parameters<typeof generateObject>[0] } satisfies Parameters<typeof generateObject>[0]
if (isOpenaiOauth) { // TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const result = streamObject({ const result = streamObject({
...params, ...params,
providerOptions: ProviderTransform.providerOptions(resolved, { providerOptions: ProviderTransform.providerOptions(resolved, {
instructions: system.join("\n"),
store: false, store: false,
}), }),
onError: () => {}, onError: () => {},
@@ -398,11 +393,13 @@ export namespace Agent {
}), }),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = Layer.suspend(() =>
layer.pipe(
Layer.provide(Provider.defaultLayer), Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer), Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
),
) )
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+2 -2
View File
@@ -1,5 +1,5 @@
import path from "path" import path from "path"
import { Effect, Layer, Record, Result, Schema, Context } from "effect" import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
@@ -49,7 +49,7 @@ export namespace Auth {
readonly remove: (key: string) => Effect.Effect<void, AuthError> readonly remove: (key: string) => Effect.Effect<void, AuthError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Auth") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Auth") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
-2
View File
@@ -4,8 +4,6 @@ export const GlobalBus = new EventEmitter<{
event: [ event: [
{ {
directory?: string directory?: string
project?: string
workspace?: string
payload: any payload: any
}, },
] ]
+4 -12
View File
@@ -1,10 +1,9 @@
import z from "zod" import z from "zod"
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream } from "effect" import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { BusEvent } from "./bus-event" import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global" import { GlobalBus } from "./global"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -42,7 +41,7 @@ export namespace Bus {
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void> readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -92,13 +91,8 @@ export namespace Bus {
yield* PubSub.publish(s.wildcard, payload) yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: dir, directory: dir,
project: context.project.id,
workspace,
payload, payload,
}) })
}) })
@@ -147,7 +141,7 @@ export namespace Bus {
return () => { return () => {
log.info("unsubscribing", { type }) log.info("unsubscribing", { type })
Effect.runFork(Scope.close(scope, Exit.void).pipe(Effect.provide(EffectLogger.layer))) Effect.runFork(Scope.close(scope, Exit.void))
} }
}) })
} }
@@ -170,8 +164,6 @@ export namespace Bus {
}), }),
) )
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer) const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe, // runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
+5 -6
View File
@@ -3,7 +3,6 @@ import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account" import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
import open from "open" import open from "open"
@@ -183,7 +182,7 @@ export const LoginCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await AppRuntime.runPromise(loginEffect(args.url)) await Account.runPromise((_svc) => loginEffect(args.url))
}, },
}) })
@@ -197,7 +196,7 @@ export const LogoutCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await AppRuntime.runPromise(logoutEffect(args.email)) await Account.runPromise((_svc) => logoutEffect(args.email))
}, },
}) })
@@ -206,7 +205,7 @@ export const SwitchCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await AppRuntime.runPromise(switchEffect()) await Account.runPromise((_svc) => switchEffect())
}, },
}) })
@@ -215,7 +214,7 @@ export const OrgsCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await AppRuntime.runPromise(orgsEffect()) await Account.runPromise((_svc) => orgsEffect())
}, },
}) })
@@ -224,7 +223,7 @@ export const OpenCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await AppRuntime.runPromise(openEffect()) await Account.runPromise((_svc) => openEffect())
}, },
}) })
+2 -5
View File
@@ -1,6 +1,5 @@
import { EOL } from "os" import { EOL } from "os"
import { basename } from "path" import { basename } from "path"
import { Effect } from "effect"
import { Agent } from "../../../agent/agent" import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider" import { Provider } from "../../../provider/provider"
import { Session } from "../../../session" import { Session } from "../../../session"
@@ -158,16 +157,14 @@ async function createToolContext(agent: Agent.Info) {
agent: agent.name, agent: agent.name,
abort: new AbortController().signal, abort: new AbortController().signal,
messages: [], messages: [],
metadata: () => Effect.void, metadata: () => {},
ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) { async ask(req: Omit<Permission.Request, "id" | "sessionID" | "tool">) {
return Effect.sync(() => {
for (const pattern of req.patterns) { for (const pattern of req.patterns) {
const rule = Permission.evaluate(req.permission, pattern, ruleset) const rule = Permission.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") { if (rule.action === "deny") {
throw new Permission.DeniedError({ ruleset }) throw new Permission.DeniedError({ ruleset })
} }
} }
})
}, },
} }
} }
+5 -10
View File
@@ -21,7 +21,6 @@ import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models" import { ModelsDev } from "../../provider/models"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { SessionShare } from "@/share/session"
import { Session } from "../../session" import { Session } from "../../session"
import type { SessionID } from "../../session/schema" import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema" import { MessageID, PartID } from "../../session/schema"
@@ -29,7 +28,6 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus" import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2" import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt" import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process" import { Process } from "@/util/process"
@@ -259,9 +257,7 @@ export const GithubInstallCommand = cmd({
} }
// Get repo info // Get repo info
const info = await AppRuntime.runPromise( const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
const parsed = parseGitHubRemote(info) const parsed = parseGitHubRemote(info)
if (!parsed) { if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`) prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -500,21 +496,20 @@ export const GithubRunCommand = cmd({
: "issue" : "issue"
: undefined : undefined
const gitText = async (args: string[]) => { const gitText = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
} }
return result.text().trim() return result.text().trim()
} }
const gitRun = async (args: string[]) => { const gitRun = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree }))) const result = await Git.run(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr) throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
} }
return result return result
} }
const gitStatus = (args: string[]) => const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: Instance.worktree })))
const commitChanges = async (summary: string, actor?: string) => { const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary] const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`) if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -564,7 +559,7 @@ export const GithubRunCommand = cmd({
shareId = await (async () => { shareId = await (async () => {
if (share === false) return if (share === false) return
if (!share && repoData.data.private) return if (!share && repoData.data.private) return
await SessionShare.share(session.id) await Session.share(session.id)
return session.id.slice(-8) return session.id.slice(-8)
})() })()
console.log("opencode session", session.id) console.log("opencode session", session.id)
+2 -3
View File
@@ -10,7 +10,6 @@ import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next" import { ShareNext } from "../../share/share-next"
import { EOL } from "os" import { EOL } from "os"
import { Filesystem } from "../../util/filesystem" import { Filesystem } from "../../util/filesystem"
import { AppRuntime } from "@/effect/app-runtime"
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */ /** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData = export type ShareData =
@@ -101,7 +100,7 @@ export const ImportCommand = cmd({
if (isUrl) { if (isUrl) {
const slug = parseShareUrl(args.file) const slug = parseShareUrl(args.file)
if (!slug) { if (!slug) {
const baseUrl = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.url())) const baseUrl = await ShareNext.url()
process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`) process.stdout.write(`Invalid URL format. Expected: ${baseUrl}/share/<slug>`)
process.stdout.write(EOL) process.stdout.write(EOL)
return return
@@ -109,7 +108,7 @@ export const ImportCommand = cmd({
const parsed = new URL(args.file) const parsed = new URL(args.file)
const baseUrl = parsed.origin const baseUrl = parsed.origin
const req = await AppRuntime.runPromise(ShareNext.Service.use((svc) => svc.request())) const req = await ShareNext.request()
const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {} const headers = shouldAttachShareAuthHeaders(args.file, req.baseUrl) ? req.headers : {}
const dataPath = req.api.data(slug) const dataPath = req.api.data(slug)
+5 -16
View File
@@ -1,6 +1,5 @@
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git" import { Git } from "@/git"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Process } from "@/util/process" import { Process } from "@/util/process"
@@ -68,29 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner const remoteName = forkOwner
// Check if remote already exists // Check if remote already exists
const remotes = await AppRuntime.runPromise( const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
Git.Service.use((git) => git.run(["remote"], { cwd: Instance.worktree })),
).then((x) => x.text().trim())
if (!remotes.split("\n").includes(remoteName)) { if (!remotes.split("\n").includes(remoteName)) {
await AppRuntime.runPromise( await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
Git.Service.use((git) =>
git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree, cwd: Instance.worktree,
}), })
),
)
UI.println(`Added fork remote: ${remoteName}`) UI.println(`Added fork remote: ${remoteName}`)
} }
// Set upstream to the fork so pushes go there // Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName const headRefName = prInfo.headRefName
await AppRuntime.runPromise( await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
Git.Service.use((git) =>
git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree, cwd: Instance.worktree,
}), })
),
)
} }
// Check for opencode session link in PR body // Check for opencode session link in PR body
+29 -14
View File
@@ -14,6 +14,7 @@ import {
batch, batch,
Show, Show,
on, on,
onCleanup,
} from "solid-js" } from "solid-js"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
@@ -22,8 +23,6 @@ import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { ErrorComponent } from "@tui/component/error-component" import { ErrorComponent } from "@tui/component/error-component"
import { PluginRouteMissing } from "@tui/component/plugin-route-missing" import { PluginRouteMissing } from "@tui/component/plugin-route-missing"
import { ProjectProvider, useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
import { StartupLoading } from "@tui/component/startup-loading" import { StartupLoading } from "@tui/component/startup-loading"
import { SyncProvider, useSync } from "@tui/context/sync" import { SyncProvider, useSync } from "@tui/context/sync"
@@ -36,6 +35,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent" import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org" import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { KeybindProvider, useKeybind } from "@tui/context/keybind" import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme" import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -54,12 +54,12 @@ import { KVProvider, useKV } from "./context/kv"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
import { ArgsProvider, useArgs, type Args } from "./context/args" import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open" import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt" import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig } from "./context/tui-config" import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin" import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error" import { FormatError, FormatUnknownError } from "@/cli/error"
import { Keybind } from "@/util/keybind"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY // can't set raw mode if not a TTY
@@ -216,7 +216,6 @@ export function tui(input: {
headers={input.headers} headers={input.headers}
events={input.events} events={input.events}
> >
<ProjectProvider>
<SyncProvider> <SyncProvider>
<ThemeProvider mode={mode}> <ThemeProvider mode={mode}>
<LocalProvider> <LocalProvider>
@@ -238,7 +237,6 @@ export function tui(input: {
</LocalProvider> </LocalProvider>
</ThemeProvider> </ThemeProvider>
</SyncProvider> </SyncProvider>
</ProjectProvider>
</SDKProvider> </SDKProvider>
</TuiConfigProvider> </TuiConfigProvider>
</RouteProvider> </RouteProvider>
@@ -262,7 +260,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const kv = useKV() const kv = useKV()
const command = useCommandDialog() const command = useCommandDialog()
const keybind = useKeybind() const keybind = useKeybind()
const event = useEvent()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const themeState = useTheme() const themeState = useTheme()
@@ -286,7 +283,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
route, route,
routes, routes,
bump: () => setRouteRev((x) => x + 1), bump: () => setRouteRev((x) => x + 1),
event,
sdk, sdk,
sync, sync,
theme: themeState, theme: themeState,
@@ -311,7 +307,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
// - Ctrl+C copies and dismisses selection // - Ctrl+C copies and dismisses selection
// - Esc dismisses selection // - Esc dismisses selection
// - Most other key input dismisses selection and is passed through // - Most other key input dismisses selection and is passed through
if (Keybind.matchParsedKey("ctrl+c", evt)) { if (evt.ctrl && evt.name === "c") {
if (!Selection.copy(renderer, toast)) { if (!Selection.copy(renderer, toast)) {
renderer.clearSelection() renderer.clearSelection()
return return
@@ -465,6 +461,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.replace(() => <DialogSessionList />) dialog.replace(() => <DialogSessionList />)
}, },
}, },
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{ {
title: "New session", title: "New session",
suggested: route.data.type === "session", suggested: route.data.type === "session",
@@ -479,9 +491,12 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const current = promptRef.current const current = promptRef.current
// Don't require focus - if there's any text, preserve it // Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined const currentPrompt = current?.current?.input ? current.current : undefined
const workspaceID =
route.data.type === "session" ? sync.session.get(route.data.sessionID)?.workspaceID : undefined
route.navigate({ route.navigate({
type: "home", type: "home",
initialPrompt: currentPrompt, initialPrompt: currentPrompt,
workspaceID,
}) })
dialog.clear() dialog.clear()
}, },
@@ -791,11 +806,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}, },
]) ])
event.on(TuiEvent.CommandExecute.type, (evt) => { sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
command.trigger(evt.properties.command) command.trigger(evt.properties.command)
}) })
event.on(TuiEvent.ToastShow.type, (evt) => { sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
toast.show({ toast.show({
title: evt.properties.title, title: evt.properties.title,
message: evt.properties.message, message: evt.properties.message,
@@ -804,14 +819,14 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
event.on(TuiEvent.SessionSelect.type, (evt) => { sdk.event.on(TuiEvent.SessionSelect.type, (evt) => {
route.navigate({ route.navigate({
type: "session", type: "session",
sessionID: evt.properties.sessionID, sessionID: evt.properties.sessionID,
}) })
}) })
event.on("session.deleted", (evt) => { sdk.event.on("session.deleted", (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) { if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
toast.show({ toast.show({
@@ -821,7 +836,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
} }
}) })
event.on("session.error", (evt) => { sdk.event.on("session.error", (evt) => {
const error = evt.properties.error const error = evt.properties.error
if (error && typeof error === "object" && error.name === "MessageAbortedError") return if (error && typeof error === "object" && error.name === "MessageAbortedError") return
const message = errorMessage(error) const message = errorMessage(error)
@@ -833,7 +848,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}) })
}) })
event.on("installation.update-available", async (evt) => { sdk.event.on("installation.update-available", async (evt) => {
const version = evt.properties.version const version = evt.properties.version
const skipped = kv.get("skipped_version") const skipped = kv.get("skipped_version")
@@ -47,7 +47,7 @@ export function DialogMcp() {
const keybinds = createMemo(() => [ const keybinds = createMemo(() => [
{ {
keybind: Keybind.parseOne("space"), keybind: Keybind.parse("space")[0],
title: "toggle", title: "toggle",
onTrigger: async (option: DialogSelectOption<string>) => { onTrigger: async (option: DialogSelectOption<string>) => {
// Prevent toggling while an operation is already in progress // Prevent toggling while an operation is already in progress
@@ -2,31 +2,25 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, createResource, createSignal, onMount } from "solid-js" import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { useProject } from "@tui/context/project"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { Flag } from "@/flag/flag"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util/keybind" import { useKV } from "../context/kv"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
const route = useRoute() const route = useRoute()
const sync = useSync() const sync = useSync()
const project = useProject()
const keybind = useKeybind() const keybind = useKeybind()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
@@ -37,24 +31,8 @@ export function DialogSessionList() {
}) })
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => searchResults() ?? sync.data.session)
function createWorkspace() { const sessions = createMemo(() => searchResults() ?? sync.data.session)
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
openWorkspaceSession({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
})
}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
@@ -62,43 +40,6 @@ export function DialogSessionList() {
.filter((x) => x.parentID === undefined) .filter((x) => x.parentID === undefined)
.toSorted((a, b) => b.time.updated - a.time.updated) .toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => { .map((x) => {
const workspace = x.workspaceID ? project.workspace.get(x.workspaceID) : undefined
let workspaceStatus: WorkspaceStatus | null = null
if (x.workspaceID) {
workspaceStatus = project.workspace.status(x.workspaceID) || "error"
}
let footer = ""
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (x.workspaceID) {
let desc = "unknown"
if (workspace) {
desc = `${workspace.type}: ${workspace.name}`
}
footer = (
<>
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
}}
>
</span>
</>
)
}
} else {
footer = Locale.time(x.time.updated)
}
const date = new Date(x.time.updated) const date = new Date(x.time.updated)
let category = date.toDateString() let category = date.toDateString()
if (category === today) { if (category === today) {
@@ -112,7 +53,7 @@ export function DialogSessionList() {
bg: isDeleting ? theme.error : undefined, bg: isDeleting ? theme.error : undefined,
value: x.id, value: x.id,
category, category,
footer, footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined, gutter: isWorking ? <Spinner /> : undefined,
} }
}) })
@@ -161,15 +102,6 @@ export function DialogSessionList() {
dialog.replace(() => <DialogSessionRename session={option.value} />) dialog.replace(() => <DialogSessionRename session={option.value} />)
}, },
}, },
{
keybind: Keybind.parseOne("ctrl+w"),
title: "new workspace",
side: "right",
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
onTrigger: () => {
createWorkspace()
},
},
]} ]}
/> />
) )
@@ -1,121 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
export async function openWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
}) {
const client = scoped(input.sdk, input.sync, input.workspaceID)
while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to create workspace session",
variant: "error",
})
return
}
input.route.navigate({
type: "session",
sessionID: result.data.id,
})
input.dialog.clear()
return
}
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog()
const sync = useSync()
const project = useProject()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const create = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await project.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void create(option.value)
}}
/>
)
}
@@ -0,0 +1,320 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { createOpencodeClient, type Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { setTimeout as sleep } from "node:timers/promises"
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID?: string) {
return createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
}
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = scoped(input.sdk, input.sync, input.workspaceID)
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string) {
if (workspaceID === "__local__") {
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = scoped(sdk, sync, workspaceID)
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => {
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = scoped(sdk, sync, workspace.id)
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: "__local__",
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}
@@ -5,7 +5,6 @@ import { createSignal } from "solid-js"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32" import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll" import { getScrollAcceleration } from "../util/scroll"
import { Keybind } from "@/util/keybind"
export function ErrorComponent(props: { export function ErrorComponent(props: {
error: Error error: Error
@@ -26,7 +25,7 @@ export function ErrorComponent(props: {
} }
useKeyboard((evt) => { useKeyboard((evt) => {
if (Keybind.matchParsedKey("ctrl+c", evt)) { if (evt.ctrl && evt.name === "c") {
handleExit() handleExit()
} }
}) })
@@ -250,7 +250,7 @@ export function Autocomplete(props: {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...sortedFiles.map((item): AutocompleteOption => { ...sortedFiles.map((item): AutocompleteOption => {
const baseDir = (sync.path.directory || process.cwd()).replace(/\/+$/, "") const baseDir = (sync.data.path.directory || process.cwd()).replace(/\/+$/, "")
const fullPath = `${baseDir}/${item}` const fullPath = `${baseDir}/${item}`
const urlObj = pathToFileURL(fullPath) const urlObj = pathToFileURL(fullPath)
let filename = item let filename = item
@@ -10,7 +10,6 @@ import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema" import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
@@ -116,9 +115,8 @@ export function Prompt(props: PromptProps) {
const agentStyleId = syntax().getStyleId("extmark.agent")! const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")! const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId = 0 let promptPartTypeId = 0
const event = useEvent()
event.on(TuiEvent.PromptAppend.type, (evt) => { sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
if (!input || input.isDestroyed) return if (!input || input.isDestroyed) return
input.insertText(evt.properties.text) input.insertText(evt.properties.text)
setTimeout(() => { setTimeout(() => {
@@ -589,13 +587,6 @@ export function Prompt(props: PromptProps) {
]) ])
async function submit() { async function submit() {
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return if (props.disabled) return
if (autocomplete?.visible) return if (autocomplete?.visible) return
if (!store.prompt.input) return if (!store.prompt.input) return
@@ -1001,11 +992,7 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = input.plainText.length input.cursorOffset = input.plainText.length
} }
}} }}
onSubmit={() => { onSubmit={submit}
// IME: double-defer so the last composed character (e.g. Korean
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => { onPaste={async (event: PasteEvent) => {
if (props.disabled) { if (props.disabled) {
event.preventDefault() event.preventDefault()
@@ -0,0 +1,151 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}
@@ -1,13 +1,11 @@
import { createMemo } from "solid-js" import { createMemo } from "solid-js"
import { useProject } from "./project"
import { useSync } from "./sync" import { useSync } from "./sync"
import { Global } from "@/global" import { Global } from "@/global"
export function useDirectory() { export function useDirectory() {
const project = useProject()
const sync = useSync() const sync = useSync()
return createMemo(() => { return createMemo(() => {
const directory = project.instance.path().directory || process.cwd() const directory = sync.data.path.directory || process.cwd()
const result = directory.replace(Global.Path.home, "~") const result = directory.replace(Global.Path.home, "~")
if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch if (sync.data.vcs?.branch) return result + ":" + sync.data.vcs.branch
return result return result
@@ -1,41 +0,0 @@
import type { Event } from "@opencode-ai/sdk/v2"
import { useProject } from "./project"
import { useSDK } from "./sdk"
export function useEvent() {
const project = useProject()
const sdk = useSDK()
function subscribe(handler: (event: Event) => void) {
return sdk.event.on("event", (event) => {
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
}
})
}
function on<T extends Event["type"]>(type: T, handler: (event: Extract<Event, { type: T }>) => void) {
return subscribe((event) => {
if (event.type !== type) return
handler(event as Extract<Event, { type: T }>)
})
}
return {
subscribe,
on,
}
}
@@ -1,106 +0,0 @@
import { batch } from "solid-js"
import type { Path, Workspace } from "@opencode-ai/sdk/v2"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
export const { use: useProject, provider: ProjectProvider } = createSimpleContext({
name: "Project",
init: () => {
const sdk = useSDK()
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
},
workspace: {
current: undefined as string | undefined,
list: [] as Workspace[],
status: {} as Record<string, WorkspaceStatus>,
},
})
async function sync() {
const workspace = store.workspace.current
const [path, project] = await Promise.all([
sdk.client.path.get({ workspace }),
sdk.client.project.current({ workspace }),
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("project", "id", project.data?.id)
})
}
async function syncWorkspace() {
const listed = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!listed?.data) return
const status = await sdk.client.experimental.workspace.status().catch(() => undefined)
const next = Object.fromEntries((status?.data ?? []).map((item) => [item.workspaceID, item.status]))
batch(() => {
setStore("workspace", "list", reconcile(listed.data))
setStore("workspace", "status", reconcile(next))
if (!listed.data.some((item) => item.id === store.workspace.current)) {
setStore("workspace", "current", undefined)
}
})
}
sdk.event.on("event", (event) => {
if (event.payload.type === "workspace.status") {
setStore("workspace", "status", event.payload.properties.workspaceID, event.payload.properties.status)
}
})
return {
data: store,
project() {
return store.project.id
},
instance: {
path() {
return store.instance.path
},
directory() {
return store.instance.path.directory
},
},
workspace: {
current() {
return store.workspace.current
},
set(next?: string | null) {
const workspace = next ?? undefined
if (store.workspace.current === workspace) return
setStore("workspace", "current", workspace)
},
list() {
return store.workspace.list
},
get(workspaceID: string) {
return store.workspace.list.find((item) => item.id === workspaceID)
},
status(workspaceID: string) {
return store.workspace.status[workspaceID]
},
statuses() {
return store.workspace.status
},
sync: syncWorkspace,
},
sync,
}
},
})
@@ -5,6 +5,7 @@ import type { PromptInfo } from "../component/prompt/history"
export type HomeRoute = { export type HomeRoute = {
type: "home" type: "home"
initialPrompt?: PromptInfo initialPrompt?: PromptInfo
workspaceID?: string
} }
export type SessionRoute = { export type SessionRoute = {
@@ -1,11 +1,10 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { GlobalEvent, Event } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup, onMount } from "solid-js" import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = { export type EventSource = {
subscribe: (handler: (event: GlobalEvent) => void) => Promise<() => void> subscribe: (directory: string | undefined, handler: (event: Event) => void) => Promise<() => void>
} }
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -33,10 +32,10 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let sdk = createSDK() let sdk = createSDK()
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
event: GlobalEvent [key in Event["type"]]: Extract<Event, { type: key }>
}>() }>()
let queue: GlobalEvent[] = [] let queue: Event[] = []
let timer: Timer | undefined let timer: Timer | undefined
let last = 0 let last = 0
@@ -49,12 +48,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
// Batch all event emissions so all store updates result in a single render // Batch all event emissions so all store updates result in a single render
batch(() => { batch(() => {
for (const event of events) { for (const event of events) {
emitter.emit("event", event) emitter.emit(event.type, event)
} }
}) })
} }
const handleEvent = (event: GlobalEvent) => { const handleEvent = (event: Event) => {
queue.push(event) queue.push(event)
const elapsed = Date.now() - last const elapsed = Date.now() - last
@@ -75,7 +74,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
;(async () => { ;(async () => {
while (true) { while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({ signal: ctrl.signal }) const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
for await (const event of events.stream) { for await (const event of events.stream) {
if (ctrl.signal.aborted) break if (ctrl.signal.aborted) break
@@ -90,7 +89,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
onMount(async () => { onMount(async () => {
if (props.events) { if (props.events) {
const unsub = await props.events.subscribe(handleEvent) const unsub = await props.events.subscribe(props.directory, handleEvent)
onCleanup(unsub) onCleanup(unsub)
} else { } else {
startSSE() startSSE()
@@ -19,16 +19,16 @@ import type {
VcsInfo, VcsInfo,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useProject } from "@tui/context/project"
import { useEvent } from "@tui/context/event"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot" import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit" import { useExit } from "./exit"
import { useArgs } from "./args" import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js" import { batch, onMount } from "solid-js"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state" import { ConsoleState, emptyConsoleState, type ConsoleState as ConsoleStateType } from "@/config/console-state"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
@@ -75,6 +75,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
formatter: FormatterStatus[] formatter: FormatterStatus[]
vcs: VcsInfo | undefined vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[]
}>({ }>({
provider_next: { provider_next: {
all: [], all: [],
@@ -102,13 +104,20 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
mcp_resource: {}, mcp_resource: {},
formatter: [], formatter: [],
vcs: undefined, vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
}) })
const event = useEvent()
const project = useProject()
const sdk = useSDK() const sdk = useSDK()
event.subscribe((event) => { async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
}
sdk.event.listen((e) => {
const event = e.details
switch (event.type) { switch (event.type) {
case "server.instance.disposed": case "server.instance.disposed":
bootstrap() bootstrap()
@@ -335,8 +344,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
} }
case "lsp.updated": { case "lsp.updated": {
const workspace = project.workspace.current() sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", x.data!))
break break
} }
@@ -352,28 +360,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
async function bootstrap() { async function bootstrap() {
console.log("bootstrapping") console.log("bootstrapping")
const workspace = project.workspace.current()
const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session const sessionListPromise = sdk.client.session
.list({ start: start }) .list({ start: start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
// blocking - include session.list when continuing a session // blocking - include session.list when continuing a session
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) const providersPromise = sdk.client.config.providers({}, { throwOnError: true })
const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({}, { throwOnError: true })
const consoleStatePromise = sdk.client.experimental.console const consoleStatePromise = sdk.client.experimental.console
.get({ workspace }, { throwOnError: true }) .get({}, { throwOnError: true })
.then((x) => ConsoleState.parse(x.data)) .then((x) => ConsoleState.parse(x.data))
.catch(() => emptyConsoleState) .catch(() => emptyConsoleState)
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true }) const agentsPromise = sdk.client.app.agents({}, { throwOnError: true })
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true }) const configPromise = sdk.client.config.get({}, { throwOnError: true })
const projectPromise = project.sync()
const blockingRequests: Promise<unknown>[] = [ const blockingRequests: Promise<unknown>[] = [
providersPromise, providersPromise,
providerListPromise, providerListPromise,
agentsPromise, agentsPromise,
configPromise, configPromise,
projectPromise,
...(args.continue ? [sessionListPromise] : []), ...(args.continue ? [sessionListPromise] : []),
] ]
@@ -418,19 +423,18 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
Promise.all([ Promise.all([
...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]),
consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))), consoleStatePromise.then((consoleState) => setStore("console_state", reconcile(consoleState))),
sdk.client.command.list({ workspace }).then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))),
sdk.client.lsp.status({ workspace }).then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))),
sdk.client.mcp.status({ workspace }).then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))),
sdk.client.experimental.resource sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))),
.list({ workspace }) sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))),
.then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.session.status().then((x) => {
sdk.client.formatter.status({ workspace }).then((x) => setStore("formatter", reconcile(x.data!))),
sdk.client.session.status({ workspace }).then((x) => {
setStore("session_status", reconcile(x.data!)) setStore("session_status", reconcile(x.data!))
}), }),
sdk.client.provider.auth({ workspace }).then((x) => setStore("provider_auth", reconcile(x.data ?? {}))), sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get({ workspace }).then((x) => setStore("vcs", reconcile(x.data))), sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
project.workspace.sync(), sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
syncWorkspaces(),
]).then(() => { ]).then(() => {
setStore("status", "complete") setStore("status", "complete")
}) })
@@ -445,17 +449,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}) })
} }
const fullSyncedSessions = new Set<string>() onMount(() => {
createEffect( bootstrap()
on( })
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
const fullSyncedSessions = new Set<string>()
const result = { const result = {
data: store, data: store,
set: setStore, set: setStore,
@@ -465,9 +463,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() { get ready() {
return store.status !== "loading" return store.status !== "loading"
}, },
get path() {
return project.instance.path()
},
session: { session: {
get(sessionID: string) { get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id) const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -486,12 +481,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
if (fullSyncedSessions.has(sessionID)) return if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }), sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100, workspace }), sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID, workspace }), sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID, workspace }), sdk.client.session.diff({ sessionID }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -509,6 +503,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID) fullSyncedSessions.add(sessionID)
}, },
}, },
workspace: {
get(workspaceID: string) {
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap, bootstrap,
} }
return result return result
@@ -1,7 +1,6 @@
import type { ParsedKey } from "@opentui/core" import type { ParsedKey } from "@opentui/core"
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui" import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { useCommandDialog } from "@tui/component/dialog-command" import type { useCommandDialog } from "@tui/component/dialog-command"
import type { useEvent } from "@tui/context/event"
import type { useKeybind } from "@tui/context/keybind" import type { useKeybind } from "@tui/context/keybind"
import type { useRoute } from "@tui/context/route" import type { useRoute } from "@tui/context/route"
import type { useSDK } from "@tui/context/sdk" import type { useSDK } from "@tui/context/sdk"
@@ -37,7 +36,6 @@ type Input = {
route: ReturnType<typeof useRoute> route: ReturnType<typeof useRoute>
routes: RouteMap routes: RouteMap
bump: () => void bump: () => void
event: ReturnType<typeof useEvent>
sdk: ReturnType<typeof useSDK> sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
theme: ReturnType<typeof useTheme> theme: ReturnType<typeof useTheme>
@@ -138,7 +136,7 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
return sync.data.provider return sync.data.provider
}, },
get path() { get path() {
return sync.path return sync.data.path
}, },
get vcs() { get vcs() {
if (!sync.data.vcs) return if (!sync.data.vcs) return
@@ -146,6 +144,14 @@ function stateApi(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
branch: sync.data.vcs.branch, branch: sync.data.vcs.branch,
} }
}, },
workspace: {
list() {
return sync.data.workspaceList
},
get(workspaceID) {
return sync.workspace.get(workspaceID)
},
},
session: { session: {
count() { count() {
return sync.data.session.length return sync.data.session.length
@@ -336,7 +342,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
get client() { get client() {
return input.sdk.client return input.sdk.client
}, },
event: input.event, event: input.sdk.event,
renderer: input.renderer, renderer: input.renderer,
slots: { slots: {
register() { register() {
@@ -1,7 +1,6 @@
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal } from "solid-js" import { createEffect, createSignal } from "solid-js"
import { Logo } from "../component/logo" import { Logo } from "../component/logo"
import { useProject } from "../context/project"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { Toast } from "../ui/toast" import { Toast } from "../ui/toast"
import { useArgs } from "../context/args" import { useArgs } from "../context/args"
@@ -19,7 +18,6 @@ const placeholder = {
export function Home() { export function Home() {
const sync = useSync() const sync = useSync()
const project = useProject()
const route = useRouteData("home") const route = useRouteData("home")
const promptRef = usePromptRef() const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>() const [ref, setRef] = createSignal<PromptRef | undefined>()
@@ -65,16 +63,11 @@ export function Home() {
</box> </box>
<box height={1} minHeight={0} flexShrink={1} /> <box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}> <box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<TuiPluginRuntime.Slot <TuiPluginRuntime.Slot name="home_prompt" mode="replace" workspace_id={route.workspaceID} ref={bind}>
name="home_prompt"
mode="replace"
workspace_id={project.workspace.current()}
ref={bind}
>
<Prompt <Prompt
ref={bind} ref={bind}
workspaceID={project.workspace.current()} workspaceID={route.workspaceID}
right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={project.workspace.current()} />} right={<TuiPluginRuntime.Slot name="home_prompt_right" workspace_id={route.workspaceID} />}
placeholders={placeholder} placeholders={placeholder}
/> />
</TuiPluginRuntime.Slot> </TuiPluginRuntime.Slot>
@@ -15,9 +15,7 @@ import {
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import path from "path" import path from "path"
import { useRoute, useRouteData } from "@tui/context/route" import { useRoute, useRouteData } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
@@ -118,8 +116,6 @@ export function Session() {
const route = useRouteData("session") const route = useRouteData("session")
const { navigate } = useRoute() const { navigate } = useRoute()
const sync = useSync() const sync = useSync()
const event = useEvent()
const project = useProject()
const tuiConfig = useTuiConfig() const tuiConfig = useTuiConfig()
const kv = useKV() const kv = useKV()
const { theme } = useTheme() const { theme } = useTheme()
@@ -176,16 +172,10 @@ export function Session() {
const providers = createMemo(() => Model.index(sync.data.provider)) const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig)) const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const toast = useToast()
const sdk = useSDK()
createEffect(async () => { createEffect(async () => {
await sdk.client.session await sync.session
.get({ sessionID: route.sessionID }, { throwOnError: true }) .sync(route.sessionID)
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => { .then(() => {
if (scroll) scroll.scrollBy(100_000) if (scroll) scroll.scrollBy(100_000)
}) })
@@ -199,10 +189,13 @@ export function Session() {
}) })
}) })
const toast = useToast()
const sdk = useSDK()
// Handle initial prompt from fork // Handle initial prompt from fork
let seeded = false let seeded = false
let lastSwitch: string | undefined = undefined let lastSwitch: string | undefined = undefined
event.on("message.part.updated", (evt) => { sdk.event.on("message.part.updated", (evt) => {
const part = evt.properties.part const part = evt.properties.part
if (part.type !== "tool") return if (part.type !== "tool") return
if (part.sessionID !== route.sessionID) return if (part.sessionID !== route.sessionID) return
@@ -231,7 +224,7 @@ export function Session() {
const dialog = useDialog() const dialog = useDialog()
const renderer = useRenderer() const renderer = useRenderer()
event.on("session.status", (evt) => { sdk.event.on("session.status", (evt) => {
if (evt.properties.sessionID !== route.sessionID) return if (evt.properties.sessionID !== route.sessionID) return
if (evt.properties.status.type !== "retry") return if (evt.properties.status.type !== "retry") return
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
@@ -1798,7 +1791,7 @@ function Bash(props: ToolProps<typeof BashTool>) {
const workdir = props.input.workdir const workdir = props.input.workdir
if (!workdir || workdir === ".") return undefined if (!workdir || workdir === ".") return undefined
const base = sync.path.directory const base = sync.data.path.directory
if (!base) return undefined if (!base) return undefined
const absolute = path.resolve(base, workdir) const absolute = path.resolve(base, workdir)
+14 -12
View File
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout" import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network" import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
@@ -43,10 +43,18 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource { function createEventSource(client: RpcClient): EventSource {
return { return {
subscribe: async (handler) => { subscribe: async (directory, handler) => {
return client.on<GlobalEvent>("global.event", (e) => { const id = await client.call("subscribe", { directory })
handler(e) const unsub = client.on<{ id: string; event: Event }>("event", (e) => {
if (e.id === id) {
handler(e.event)
}
}) })
return () => {
unsub()
client.call("unsubscribe", { id })
}
}, },
} }
} }
@@ -137,18 +145,12 @@ export const TuiThreadCommand = cmd({
), ),
}) })
worker.onerror = (e) => { worker.onerror = (e) => {
Log.Default.error("thread error", { Log.Default.error(e)
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
} }
const client = Rpc.client<typeof rpc>(worker) const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => { const error = (e: unknown) => {
Log.Default.error("process error", { error: errorMessage(e) }) Log.Default.error(e)
} }
const reload = () => { const reload = () => {
client.call("reload", undefined).catch((err) => { client.call("reload", undefined).catch((err) => {
@@ -26,7 +26,6 @@ export interface DialogSelectProps<T> {
keybind?: { keybind?: {
keybind?: Keybind.Info keybind?: Keybind.Info
title: string title: string
side?: "left" | "right"
disabled?: boolean disabled?: boolean
onTrigger: (option: DialogSelectOption<T>) => void onTrigger: (option: DialogSelectOption<T>) => void
}[] }[]
@@ -43,7 +42,6 @@ export interface DialogSelectOption<T = any> {
disabled?: boolean disabled?: boolean
bg?: RGBA bg?: RGBA
gutter?: JSX.Element gutter?: JSX.Element
margin?: JSX.Element
onSelect?: (ctx: DialogContext) => void onSelect?: (ctx: DialogContext) => void
} }
@@ -195,8 +193,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
useKeyboard((evt) => { useKeyboard((evt) => {
setStore("input", "keyboard") setStore("input", "keyboard")
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1) if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10) if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10) if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0) if (evt.name === "home") moveTo(0)
@@ -236,8 +234,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
props.ref?.(ref) props.ref?.(ref)
const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? []) const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled && x.keybind) ?? [])
const left = createMemo(() => keybinds().filter((item) => item.side !== "right"))
const right = createMemo(() => keybinds().filter((item) => item.side === "right"))
return ( return (
<box gap={1} paddingBottom={1}> <box gap={1} paddingBottom={1}>
@@ -316,7 +312,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box <box
id={JSON.stringify(option.value)} id={JSON.stringify(option.value)}
flexDirection="row" flexDirection="row"
position="relative"
onMouseMove={() => { onMouseMove={() => {
setStore("input", "mouse") setStore("input", "mouse")
}} }}
@@ -340,11 +335,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingRight={3} paddingRight={3}
gap={1} gap={1}
> >
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option <Option
title={option.title} title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer} footer={flatten() ? (option.category ?? option.footer) : option.footer}
@@ -363,16 +353,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
</scrollbox> </scrollbox>
</Show> </Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}> <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box <box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
paddingRight={2} <For each={keybinds()}>
paddingLeft={4}
flexDirection="row"
justifyContent="space-between"
flexShrink={0}
paddingTop={1}
>
<box flexDirection="row" gap={2}>
<For each={left()}>
{(item) => ( {(item) => (
<text> <text>
<span style={{ fg: theme.text }}> <span style={{ fg: theme.text }}>
@@ -383,19 +365,6 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)} )}
</For> </For>
</box> </box>
<box flexDirection="row" gap={2}>
<For each={right()}>
{(item) => (
<text>
<span style={{ fg: theme.text }}>
<b>{item.title}</b>{" "}
</span>
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
</text>
)}
</For>
</box>
</box>
</Show> </Show>
</box> </box>
) )
@@ -6,7 +6,6 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast" import { useToast } from "./toast"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Selection } from "@tui/util/selection" import { Selection } from "@tui/util/selection"
import { Keybind } from "@/util/keybind"
export function Dialog( export function Dialog(
props: ParentProps<{ props: ParentProps<{
@@ -73,13 +72,12 @@ function init() {
}) })
const renderer = useRenderer() const renderer = useRenderer()
useKeyboard((evt) => { useKeyboard((evt) => {
if (store.stack.length === 0) return if (store.stack.length === 0) return
if (evt.defaultPrevented) return if (evt.defaultPrevented) return
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt) if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
if (evt.name === "escape" || isCtrlC) {
if (renderer.getSelection()) { if (renderer.getSelection()) {
renderer.clearSelection() renderer.clearSelection()
} }
+95 -1
View File
@@ -6,10 +6,13 @@ import { InstanceBootstrap } from "@/project/bootstrap"
import { Rpc } from "@/util/rpc" import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import type { GlobalEvent } from "@opencode-ai/sdk/v2" import type { Event } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8" import { writeHeapSnapshot } from "node:v8"
import { WorkspaceID } from "@/control-plane/schema"
import { Heap } from "@/cli/heap" import { Heap } from "@/cli/heap"
await Log.init({ await Log.init({
@@ -42,6 +45,87 @@ GlobalBus.on("event", (event) => {
let server: Awaited<ReturnType<typeof Server.listen>> | undefined let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStreams = new Map<string, AbortController>()
function startEventStream(directory: string) {
const id = crypto.randomUUID()
const abort = new AbortController()
const signal = abort.signal
eventStreams.set(id, abort)
async function run() {
while (!signal.aborted) {
const shouldReconnect = await Instance.provide({
directory,
init: InstanceBootstrap,
fn: () =>
new Promise<boolean>((resolve) => {
Rpc.emit("event", {
type: "server.connected",
properties: {},
} satisfies Event)
let settled = false
const settle = (value: boolean) => {
if (settled) return
settled = true
signal.removeEventListener("abort", onAbort)
unsub()
resolve(value)
}
const unsub = Bus.subscribeAll((event) => {
Rpc.emit("event", {
id,
event: event as Event,
})
if (event.type === Bus.InstanceDisposed.type) {
settle(true)
}
})
const onAbort = () => {
settle(false)
}
signal.addEventListener("abort", onAbort, { once: true })
}),
}).catch((error) => {
Log.Default.error("event stream subscribe error", {
error: error instanceof Error ? error.message : error,
})
return false
})
if (!shouldReconnect || signal.aborted) {
break
}
if (!signal.aborted) {
await sleep(250)
}
}
}
run().catch((error) => {
Log.Default.error("event stream error", {
error: error instanceof Error ? error.message : error,
})
})
return id
}
function stopEventStream(id: string) {
const abortController = eventStreams.get(id)
if (!abortController) return
abortController.abort()
eventStreams.delete(id)
}
export const rpc = { export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) { async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers } const headers = { ...input.headers }
@@ -83,9 +167,19 @@ export const rpc = {
async reload() { async reload() {
await Config.invalidate(true) await Config.invalidate(true)
}, },
async subscribe(input: { directory: string | undefined }) {
return startEventStream(input.directory || process.cwd())
},
async unsubscribe(input: { id: string }) {
stopEventStream(input.id)
},
async shutdown() { async shutdown() {
Log.Default.info("worker shutting down") Log.Default.info("worker shutting down")
for (const id of [...eventStreams.keys()]) {
stopEventStream(id)
}
await Instance.disposeAll() await Instance.disposeAll()
if (server) await server.stop(true) if (server) await server.stop(true)
}, },
+1 -2
View File
@@ -1,7 +1,6 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { UI } from "../ui" import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { Global } from "../../global" import { Global } from "../../global"
import fs from "fs/promises" import fs from "fs/promises"
@@ -58,7 +57,7 @@ export const UninstallCommand = {
UI.empty() UI.empty()
prompts.intro("Uninstall OpenCode") prompts.intro("Uninstall OpenCode")
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const method = await Installation.method()
prompts.log.info(`Installation method: ${method}`) prompts.log.info(`Installation method: ${method}`)
const targets = await collectRemovalTargets(args, method) const targets = await collectRemovalTargets(args, method)
+3 -8
View File
@@ -1,7 +1,6 @@
import type { Argv } from "yargs" import type { Argv } from "yargs"
import { UI } from "../ui" import { UI } from "../ui"
import * as prompts from "@clack/prompts" import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "../../installation" import { Installation } from "../../installation"
export const UpgradeCommand = { export const UpgradeCommand = {
@@ -25,7 +24,7 @@ export const UpgradeCommand = {
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
prompts.intro("Upgrade") prompts.intro("Upgrade")
const detectedMethod = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const detectedMethod = await Installation.method()
const method = (args.method as Installation.Method) ?? detectedMethod const method = (args.method as Installation.Method) ?? detectedMethod
if (method === "unknown") { if (method === "unknown") {
prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`) prompts.log.error(`opencode is installed to ${process.execPath} and may be managed by a package manager`)
@@ -43,9 +42,7 @@ export const UpgradeCommand = {
} }
} }
prompts.log.info("Using method: " + method) prompts.log.info("Using method: " + method)
const target = args.target const target = args.target ? args.target.replace(/^v/, "") : await Installation.latest()
? args.target.replace(/^v/, "")
: await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest()))
if (Installation.VERSION === target) { if (Installation.VERSION === target) {
prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`) prompts.log.warn(`opencode upgrade skipped: ${target} is already installed`)
@@ -56,9 +53,7 @@ export const UpgradeCommand = {
prompts.log.info(`From ${Installation.VERSION}${target}`) prompts.log.info(`From ${Installation.VERSION}${target}`)
const spinner = prompts.spinner() const spinner = prompts.spinner()
spinner.start("Upgrading...") spinner.start("Upgrading...")
const err = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, target))).catch( const err = await Installation.upgrade(method, target).catch((err) => err)
(err) => err,
)
if (err) { if (err) {
spinner.stop("Upgrade failed", 1) spinner.stop("Upgrade failed", 1)
if (err instanceof Installation.UpgradeFailedError) { if (err instanceof Installation.UpgradeFailedError) {
+3 -4
View File
@@ -1,13 +1,12 @@
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { AppRuntime } from "@/effect/app-runtime"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Installation } from "@/installation" import { Installation } from "@/installation"
export async function upgrade() { export async function upgrade() {
const config = await Config.getGlobal() const config = await Config.getGlobal()
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) const method = await Installation.method()
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {}) const latest = await Installation.latest(method).catch(() => {})
if (!latest) return if (!latest) return
if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) { if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
@@ -26,7 +25,7 @@ export async function upgrade() {
} }
if (method === "unknown") return if (method === "unknown") return
await AppRuntime.runPromise(Installation.Service.use((svc) => svc.upgrade(method, latest))) await Installation.upgrade(method, latest)
.then(() => Bus.publish(Installation.Event.Updated, { version: latest })) .then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
.catch(() => {}) .catch(() => {})
} }
+10 -6
View File
@@ -1,9 +1,8 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import type { InstanceContext } from "@/project/instance" import { makeRuntime } from "@/effect/run-service"
import { SessionID, MessageID } from "@/session/schema" import { SessionID, MessageID } from "@/session/schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { EffectLogger } from "@/effect/logger"
import z from "zod" import z from "zod"
import { Config } from "../config/config" import { Config } from "../config/config"
import { MCP } from "../mcp" import { MCP } from "../mcp"
@@ -71,7 +70,7 @@ export namespace Command {
readonly list: () => Effect.Effect<Info[]> readonly list: () => Effect.Effect<Info[]>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Command") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -80,7 +79,7 @@ export namespace Command {
const mcp = yield* MCP.Service const mcp = yield* MCP.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const init = Effect.fn("Command.state")(function* (ctx: InstanceContext) { const init = Effect.fn("Command.state")(function* (ctx) {
const cfg = yield* config.get() const cfg = yield* config.get()
const commands: Record<string, Info> = {} const commands: Record<string, Info> = {}
@@ -141,7 +140,6 @@ export namespace Command {
.map((message) => (message.content.type === "text" ? message.content.text : "")) .map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "", .join("\n") || "",
), ),
Effect.provide(EffectLogger.layer),
), ),
) )
}, },
@@ -188,4 +186,10 @@ export namespace Command {
Layer.provide(MCP.defaultLayer), Layer.provide(MCP.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function list() {
return runPromise((svc) => svc.list())
}
} }
+19 -23
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from "url"
import os from "os" import os from "os"
import { Process } from "../util/process" import { Process } from "../util/process"
import z from "zod" import z from "zod"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda" import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global" import { Global } from "../global"
import fsNode from "fs/promises" import fsNode from "fs/promises"
@@ -36,11 +37,10 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, Context } from "effect" import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm" import { Npm } from "@/npm"
import { InstanceRef } from "@/effect/instance-ref"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -1126,7 +1126,7 @@ export namespace Config {
readonly waitForDependencies: () => Effect.Effect<void> readonly waitForDependencies: () => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
function globalConfigFile() { function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
@@ -1327,31 +1327,27 @@ export namespace Config {
const consoleManagedProviders = new Set<string>() const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined let activeOrgName: string | undefined
const scope = Effect.fnUntraced(function* (source: string) { const scope = (source: string): PluginScope => {
if (source.startsWith("http://") || source.startsWith("https://")) return "global" if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local" if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local" if (Instance.containsPath(source)) return "local"
return "global" return "global"
}) }
const track = Effect.fnUntraced(function* ( const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
source: string,
list: PluginSpec[] | undefined,
kind?: PluginScope,
) {
if (!list?.length) return if (!list?.length) return
const hit = kind ?? (yield* scope(source)) const hit = kind ?? scope(source)
const plugins = deduplicatePluginOrigins([ const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []), ...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })), ...list.map((spec) => ({ spec, source, scope: hit })),
]) ])
result.plugin = plugins.map((item) => item.spec) result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins result.plugin_origins = plugins
}) }
const merge = (source: string, next: Info, kind?: PluginScope) => { const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next) result = mergeConfigConcatArrays(result, next)
return track(source, next.plugin, kind) track(source, next.plugin, kind)
} }
for (const [key, value] of Object.entries(auth)) { for (const [key, value] of Object.entries(auth)) {
@@ -1371,16 +1367,16 @@ export namespace Config {
dir: path.dirname(source), dir: path.dirname(source),
source, source,
}) })
yield* merge(source, next, "global") merge(source, next, "global")
log.debug("loaded remote config from well-known", { url }) log.debug("loaded remote config from well-known", { url })
} }
} }
const global = yield* getGlobal() const global = yield* getGlobal()
yield* merge(Global.Path.config, global, "global") merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) { if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
@@ -1388,7 +1384,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() => for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) { )) {
yield* merge(file, yield* loadFile(file), "local") merge(file, yield* loadFile(file), "local")
} }
} }
@@ -1409,7 +1405,7 @@ export namespace Config {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file) const source = path.join(dir, file)
log.debug(`loading config from ${source}`) log.debug(`loading config from ${source}`)
yield* merge(source, yield* loadFile(source)) merge(source, yield* loadFile(source))
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
result.plugin ??= [] result.plugin ??= []
@@ -1428,7 +1424,7 @@ export namespace Config {
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
const list = yield* Effect.promise(() => loadPlugin(dir)) const list = yield* Effect.promise(() => loadPlugin(dir))
yield* track(dir, list) track(dir, list)
} }
if (process.env.OPENCODE_CONFIG_CONTENT) { if (process.env.OPENCODE_CONFIG_CONTENT) {
@@ -1437,7 +1433,7 @@ export namespace Config {
dir: ctx.directory, dir: ctx.directory,
source, source,
}) })
yield* merge(source, next, "local") merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
@@ -1466,7 +1462,7 @@ export namespace Config {
for (const providerID of Object.keys(next.provider ?? {})) { for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID) consoleManagedProviders.add(providerID)
} }
yield* merge(source, next, "global") merge(source, next, "global")
} }
}).pipe( }).pipe(
Effect.catch((err) => { Effect.catch((err) => {
@@ -1481,7 +1477,7 @@ export namespace Config {
if (existsSync(managedDir)) { if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(managedDir, file) const source = path.join(managedDir, file)
yield* merge(source, yield* loadFile(source), "global") merge(source, yield* loadFile(source), "global")
} }
} }
@@ -10,7 +10,8 @@ export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe( export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({ withStatics((schema: typeof workspaceIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), make: (id: string) => schema.makeUnsafe(id),
ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("workspace", id)),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()), zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
})), })),
) )
@@ -1,22 +0,0 @@
import { LocalContext } from "../util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext {
workspaceID: string
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn())
},
get workspaceID() {
try {
return context.use().workspaceID
} catch (err) {
return undefined
}
},
}
@@ -5,9 +5,7 @@ import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project" import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { SyncEvent } from "@/sync"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { WorkspaceTable } from "./workspace.sql" import { WorkspaceTable } from "./workspace.sql"
import { getAdaptor } from "./adaptors" import { getAdaptor } from "./adaptors"
@@ -16,18 +14,6 @@ import { WorkspaceID } from "./schema"
import { parseSSE } from "./sse" import { parseSSE } from "./sse"
export namespace Workspace { export namespace Workspace {
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
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>
export const Event = { export const Event = {
Ready: BusEvent.define( Ready: BusEvent.define(
"workspace.ready", "workspace.ready",
@@ -41,9 +27,13 @@ export namespace Workspace {
message: z.string(), message: z.string(),
}), }),
), ),
Status: BusEvent.define("workspace.status", ConnectionStatus),
} }
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
})
export type Info = z.infer<typeof Info>
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
return { return {
id: row.id, id: row.id,
@@ -95,9 +85,6 @@ export namespace Workspace {
}) })
await adaptor.create(config) await adaptor.create(config)
startSync(info)
return info return info
}) })
@@ -105,24 +92,18 @@ export namespace Workspace {
const rows = Database.use((db) => const rows = Database.use((db) =>
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), 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)) return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
} }
export const get = fn(WorkspaceID.zod, async (id) => { export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return if (!row) return
const space = fromRow(row) return fromRow(row)
startSync(space)
return space
}) })
export const remove = fn(WorkspaceID.zod, async (id) => { export const remove = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (row) { if (row) {
stopSync(id)
const info = fromRow(row) const info = fromRow(row)
const adaptor = await getAdaptor(row.type) const adaptor = await getAdaptor(row.type)
adaptor.remove(info) adaptor.remove(info)
@@ -130,100 +111,58 @@ export namespace Workspace {
return info return info
} }
}) })
const connections = new Map<WorkspaceID, ConnectionStatus>()
const aborts = new Map<WorkspaceID, AbortController>()
function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) {
const prev = connections.get(id)
if (prev?.status === status && prev?.error === error) return
const next = { workspaceID: id, status, error }
connections.set(id, next)
GlobalBus.emit("event", {
directory: "global",
workspace: id,
payload: {
type: Event.Status.type,
properties: next,
},
})
}
export function status(): ConnectionStatus[] {
return [...connections.values()]
}
const log = Log.create({ service: "workspace-sync" }) const log = Log.create({ service: "workspace-sync" })
async function workspaceEventLoop(space: Info, signal: AbortSignal) { async function workspaceEventLoop(space: Info, stop: AbortSignal) {
log.info("starting sync: " + space.id) while (!stop.aborted) {
while (!signal.aborted) {
log.info("connecting to sync: " + space.id)
setStatus(space.id, "connecting")
const adaptor = await getAdaptor(space.type) const adaptor = await getAdaptor(space.type)
const target = await adaptor.target(space) const target = await Promise.resolve(adaptor.target(space))
if (target.type === "local") return if (target.type === "local") {
return
}
const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { const baseURL = String(target.url).replace(/\/?$/, "/")
setStatus(space.id, "error", String(err))
return undefined const res = await fetch(new URL(baseURL + "/event"), {
method: "GET",
signal: stop,
}) })
if (!res || !res.ok || !res.body) {
log.info("failed to connect to sync: " + res?.status)
setStatus(space.id, "error", res ? `HTTP ${res.status}` : "no response") if (!res.ok || !res.body) {
await sleep(1000) await sleep(1000)
continue continue
} }
setStatus(space.id, "connected")
await parseSSE(res.body, signal, (evt) => {
const event = evt as SyncEvent.SerializedEvent
try { await parseSSE(res.body, stop, (event) => {
if (!event.type.startsWith("server.")) { GlobalBus.emit("event", {
SyncEvent.replay(event) directory: space.id,
} payload: event,
} catch (err) {
log.warn("failed to replay sync event", {
workspaceID: space.id,
error: err,
}) })
}
}) })
setStatus(space.id, "disconnected")
log.info("disconnected to sync: " + space.id) // Wait 250ms and retry if SSE connection fails
await sleep(250) await sleep(250)
} }
} }
function startSync(space: Info) { export function startSyncing(project: Project.Info) {
if (space.type === "worktree") { const stop = new AbortController()
void Filesystem.exists(space.directory!).then((exists) => { const spaces = list(project).filter((space) => space.type !== "worktree")
setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist")
})
return
}
if (aborts.has(space.id)) return spaces.forEach((space) => {
const abort = new AbortController() void workspaceEventLoop(space, stop.signal).catch((error) => {
aborts.set(space.id, abort)
setStatus(space.id, "disconnected")
void workspaceEventLoop(space, abort.signal).catch((error) => {
setStatus(space.id, "error", String(error))
log.warn("workspace sync listener failed", { log.warn("workspace sync listener failed", {
workspaceID: space.id, workspaceID: space.id,
error, error,
}) })
}) })
} })
function stopSync(id: WorkspaceID) { return {
aborts.get(id)?.abort() async stop() {
aborts.delete(id) stop.abort()
connections.delete(id) },
}
} }
} }
-100
View File
@@ -1,100 +0,0 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Observability } from "./oltp"
import { AppFileSystem } from "@/filesystem"
import { Bus } from "@/bus"
import { Auth } from "@/auth"
import { Account } from "@/account"
import { Config } from "@/config/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/storage"
import { Snapshot } from "@/snapshot"
import { Plugin } from "@/plugin"
import { Provider } from "@/provider/provider"
import { ProviderAuth } from "@/provider/auth"
import { Agent } from "@/agent/agent"
import { Skill } from "@/skill"
import { Discovery } from "@/skill/discovery"
import { Question } from "@/question"
import { Permission } from "@/permission"
import { Todo } from "@/session/todo"
import { Session } from "@/session"
import { SessionStatus } from "@/session/status"
import { SessionRunState } from "@/session/run-state"
import { SessionProcessor } from "@/session/processor"
import { SessionCompaction } from "@/session/compaction"
import { SessionRevert } from "@/session/revert"
import { SessionSummary } from "@/session/summary"
import { SessionPrompt } from "@/session/prompt"
import { Instruction } from "@/session/instruction"
import { LLM } from "@/session/llm"
import { LSP } from "@/lsp"
import { MCP } from "@/mcp"
import { McpAuth } from "@/mcp/auth"
import { Command } from "@/command"
import { Truncate } from "@/tool/truncate"
import { ToolRegistry } from "@/tool/registry"
import { Format } from "@/format"
import { Project } from "@/project/project"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
export const AppLayer = Layer.mergeAll(
Observability.layer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
Git.defaultLayer,
Ripgrep.defaultLayer,
FileTime.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
Storage.defaultLayer,
Snapshot.defaultLayer,
Plugin.defaultLayer,
Provider.defaultLayer,
ProviderAuth.defaultLayer,
Agent.defaultLayer,
Skill.defaultLayer,
Discovery.defaultLayer,
Question.defaultLayer,
Permission.defaultLayer,
Todo.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
SessionRevert.defaultLayer,
SessionSummary.defaultLayer,
SessionPrompt.defaultLayer,
Instruction.defaultLayer,
LLM.defaultLayer,
LSP.defaultLayer,
MCP.defaultLayer,
McpAuth.defaultLayer,
Command.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
Format.defaultLayer,
Project.defaultLayer,
Vcs.defaultLayer,
Worktree.defaultLayer,
Pty.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
)
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
@@ -1,10 +0,0 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { FileWatcher } from "@/file/watcher"
import { Format } from "@/format"
import { ShareNext } from "@/share/share-next"
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
@@ -402,7 +402,6 @@ export const make = Effect.gen(function* () {
const fd = yield* setupFds(command, proc, extra) const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr) const out = setupOutput(command, proc, sout, serr)
let ref = true
return makeHandle({ return makeHandle({
pid: ProcessId(proc.pid!), pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin), stdin: yield* setupStdin(command, proc, sin),
@@ -433,18 +432,6 @@ export const make = Effect.gen(function* () {
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
}) })
}, },
unref: Effect.sync(() => {
if (ref) {
proc.unref()
ref = false
}
return Effect.sync(() => {
if (!ref) {
proc.ref()
ref = true
}
})
}),
}) })
} }
case "PipedCommand": { case "PipedCommand": {
+2 -6
View File
@@ -1,10 +1,6 @@
import { Context } from "effect" import { ServiceMap } from "effect"
import type { InstanceContext } from "@/project/instance" import type { InstanceContext } from "@/project/instance"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", { export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined, defaultValue: () => undefined,
}) })
+6 -14
View File
@@ -1,10 +1,8 @@
import { Effect, Fiber, ScopedCache, Scope, Context } from "effect" import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { EffectLogger } from "@/effect/logger"
import { Instance, type InstanceContext } from "@/project/instance" import { Instance, type InstanceContext } from "@/project/instance"
import { LocalContext } from "@/util/local-context" import { Context } from "@/util/context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
import { WorkspaceContext } from "@/control-plane/workspace-context"
const TypeId = "~opencode/InstanceState" const TypeId = "~opencode/InstanceState"
@@ -18,10 +16,10 @@ export namespace InstanceState {
try { try {
return Instance.bind(fn) return Instance.bind(fn)
} catch (err) { } catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err if (!(err instanceof Context.NotFound)) throw err
} }
const fiber = Fiber.getCurrent() const fiber = Fiber.getCurrent()
const ctx = fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
if (!ctx) return fn if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
} }
@@ -30,10 +28,6 @@ export namespace InstanceState {
return (yield* InstanceRef) ?? Instance.current return (yield* InstanceRef) ?? Instance.current
}) })
export const workspaceID = Effect.gen(function* () {
return (yield* WorkspaceRef) ?? WorkspaceContext.workspaceID
})
export const directory = Effect.map(context, (ctx) => ctx.directory) export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>( export const make = <A, E = never, R = never>(
@@ -48,9 +42,7 @@ export namespace InstanceState {
}), }),
}) })
const off = registerDisposer((directory) => const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
Effect.runPromise(ScopedCache.invalidate(cache, directory).pipe(Effect.provide(EffectLogger.layer))),
)
yield* Effect.addFinalizer(() => Effect.sync(off)) yield* Effect.addFinalizer(() => Effect.sync(off))
return { return {
-67
View File
@@ -1,67 +0,0 @@
import { Cause, Effect, Logger, References } from "effect"
import { Log } from "@/util/log"
export namespace EffectLogger {
type Fields = Record<string, unknown>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
const text = (input: unknown): string => {
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
}
+12 -19
View File
@@ -1,24 +1,26 @@
import { Duration, Layer } from "effect" import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { Otlp } from "effect/unstable/observability" import { Otlp } from "effect/unstable/observability"
import { EffectLogger } from "@/effect/logger"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { CHANNEL, VERSION } from "@/installation/meta" import { CHANNEL, VERSION } from "@/installation/meta"
export namespace Observability { export namespace Observability {
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT export const enabled = !!Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const resource = { export const layer = !Flag.OTEL_EXPORTER_OTLP_ENDPOINT
? Layer.empty
: Otlp.layerJson({
baseUrl: Flag.OTEL_EXPORTER_OTLP_ENDPOINT,
loggerMergeWithExisting: false,
resource: {
serviceName: "opencode", serviceName: "opencode",
serviceVersion: VERSION, serviceVersion: VERSION,
attributes: { attributes: {
"deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL, "deployment.environment.name": CHANNEL === "local" ? "local" : CHANNEL,
"opencode.client": Flag.OPENCODE_CLIENT, "opencode.client": Flag.OPENCODE_CLIENT,
}, },
} },
headers: Flag.OTEL_EXPORTER_OTLP_HEADERS
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => { (acc, x) => {
const [key, value] = x.split("=") const [key, value] = x.split("=")
@@ -27,15 +29,6 @@ export namespace Observability {
}, },
{} as Record<string, string>, {} as Record<string, string>,
) )
: undefined : undefined,
}).pipe(Layer.provide(FetchHttpClient.layer))
export const layer = !base
? EffectLogger.layer
: Otlp.layerJson({
baseUrl: base,
loggerExportInterval: Duration.seconds(1),
loggerMergeWithExisting: true,
resource,
headers,
}).pipe(Layer.provide(EffectLogger.layer), Layer.provide(FetchHttpClient.layer))
} }
+6 -8
View File
@@ -1,25 +1,23 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import * as Context from "effect/Context" import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { LocalContext } from "@/util/local-context" import { Context } from "@/util/context"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef } from "./instance-ref"
import { Observability } from "./oltp" import { Observability } from "./oltp"
import { WorkspaceContext } from "@/control-plane/workspace-context"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> { export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try { try {
const ctx = Instance.current const ctx = Instance.current
const workspaceID = WorkspaceContext.workspaceID return Effect.provideService(effect, InstanceRef, ctx)
return effect.pipe(Effect.provideService(InstanceRef, ctx), Effect.provideService(WorkspaceRef, workspaceID))
} catch (err) { } catch (err) {
if (!(err instanceof LocalContext.NotFound)) throw err if (!(err instanceof Context.NotFound)) throw err
} }
return effect return effect
} }
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap })) const getRuntime = () => (rt ??= ManagedRuntime.make(Layer.merge(layer, Observability.layer), { memoMap }))
+48 -31
View File
@@ -3,7 +3,7 @@ import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Git } from "@/git" import { Git } from "@/git"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import ignore from "ignore" import ignore from "ignore"
@@ -11,6 +11,7 @@ import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Protected } from "./protected" import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
@@ -337,13 +338,12 @@ export namespace File {
}) => Effect.Effect<string[]> }) => Effect.Effect<string[]>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/File") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const appFs = yield* AppFileSystem.Service const appFs = yield* AppFileSystem.Service
const git = yield* Git.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("File.state")(() => Effect.fn("File.state")(() =>
@@ -410,10 +410,6 @@ export namespace File {
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void))) cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
}) })
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
})
const init = Effect.fn("File.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* ensure() yield* ensure()
}) })
@@ -421,15 +417,12 @@ export namespace File {
const status = Effect.fn("File.status")(function* () { const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return [] if (Instance.project.vcs !== "git") return []
const diffOutput = yield* gitText([ return yield* Effect.promise(async () => {
"-c", const diffOutput = (
"core.fsmonitor=false", await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
"-c", cwd: Instance.directory,
"core.quotepath=false", })
"diff", ).text()
"--numstat",
"HEAD",
])
const changed: File.Info[] = [] const changed: File.Info[] = []
@@ -445,7 +438,9 @@ export namespace File {
} }
} }
const untrackedOutput = yield* gitText([ const untrackedOutput = (
await Git.run(
[
"-c", "-c",
"core.fsmonitor=false", "core.fsmonitor=false",
"-c", "-c",
@@ -453,24 +448,32 @@ export namespace File {
"ls-files", "ls-files",
"--others", "--others",
"--exclude-standard", "--exclude-standard",
]) ],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) { for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs try {
.readFileString(path.join(Instance.directory, file)) const content = await Filesystem.readText(path.join(Instance.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({ changed.push({
path: file, path: file,
added: content.split("\n").length, added: content.split("\n").length,
removed: 0, removed: 0,
status: "added", status: "added",
}) })
} catch {
continue
}
} }
} }
const deletedOutput = yield* gitText([ const deletedOutput = (
await Git.run(
[
"-c", "-c",
"core.fsmonitor=false", "core.fsmonitor=false",
"-c", "-c",
@@ -479,7 +482,12 @@ export namespace File {
"--name-only", "--name-only",
"--diff-filter=D", "--diff-filter=D",
"HEAD", "HEAD",
]) ],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
for (const file of deletedOutput.trim().split("\n")) { for (const file of deletedOutput.trim().split("\n")) {
@@ -500,8 +508,9 @@ export namespace File {
} }
}) })
}) })
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) { const read = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(Instance.directory, file) const full = path.join(Instance.directory, file)
@@ -549,19 +558,27 @@ export namespace File {
) )
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file]) return yield* Effect.promise(async (): Promise<File.Content> => {
let diff = (
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file]) diff = (
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: Instance.directory,
})
).text()
} }
if (diff.trim()) { if (diff.trim()) {
const original = yield* git.show(Instance.directory, "HEAD", file) const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", { const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,
}) })
return { type: "text" as const, content, patch, diff: formatPatch(patch) } return { type: "text", content, patch, diff: formatPatch(patch) }
} }
return { type: "text" as const, content } return { type: "text", content }
})
} }
return { type: "text" as const, content } return { type: "text" as const, content }
@@ -643,7 +660,7 @@ export namespace File {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
-70
View File
@@ -3,17 +3,10 @@ import path from "path"
import { Global } from "../global" import { Global } from "../global"
import fs from "fs/promises" import fs from "fs/promises"
import z from "zod" import z from "zod"
import { Effect, Layer, Context } from "effect"
import * as Stream from "effect/Stream"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import type { PlatformError } from "effect/PlatformError"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy" import { lazy } from "../util/lazy"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
@@ -281,69 +274,6 @@ export namespace Ripgrep {
input.signal?.throwIfAborted() input.signal?.throwIfAborted()
} }
export interface Interface {
readonly files: (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) => Stream.Stream<string, PlatformError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const afs = yield* AppFileSystem.Service
const files = Effect.fn("Ripgrep.files")(function* (input: {
cwd: string
glob?: string[]
hidden?: boolean
follow?: boolean
maxDepth?: number
}) {
const rgPath = yield* Effect.promise(() => filepath())
const isDir = yield* afs.isDir(input.cwd)
if (!isDir) {
return yield* Effect.die(
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT" as const,
errno: -2,
path: input.cwd,
}),
)
}
const args = [rgPath, "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
args.push(`--glob=${g}`)
}
}
return spawner
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
.pipe(Stream.filter((line: string) => line.length > 0))
})
return Service.of({
files: (input) => Stream.unwrap(files(input)),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
)
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) { export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
log.info("tree", input) log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal })) const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
+24 -5
View File
@@ -1,5 +1,6 @@
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect" import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
@@ -33,10 +34,10 @@ export namespace FileTime {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Effect.Effect<T>) => Effect.Effect<T> readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileTime") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -102,8 +103,8 @@ export namespace FileTime {
) )
}) })
const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Effect.Effect<T>) { const withLock = Effect.fn("FileTime.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
return yield* fn().pipe((yield* getLock(filepath)).withPermits(1)) return yield* Effect.promise(fn).pipe((yield* getLock(filepath)).withPermits(1))
}) })
return Service.of({ read, get, assert, withLock }) return Service.of({ read, get, assert, withLock })
@@ -111,4 +112,22 @@ export namespace FileTime {
).pipe(Layer.orDie) ).pipe(Layer.orDie)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file))
}
export function get(sessionID: SessionID, file: string) {
return runPromise((s) => s.get(sessionID, file))
}
export async function assert(sessionID: SessionID, filepath: string) {
return runPromise((s) => s.assert(sessionID, filepath))
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromise((s) => s.withLock(filepath, fn))
}
} }
+14 -6
View File
@@ -1,4 +1,4 @@
import { Cause, Effect, Layer, Scope, Context } from "effect" import { Cause, Effect, Layer, Scope, ServiceMap } from "effect"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
@@ -8,6 +8,7 @@ import z from "zod"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Git } from "@/git" import { Git } from "@/git"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
@@ -64,13 +65,12 @@ export namespace FileWatcher {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileWatcher") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileWatcher") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const config = yield* Config.Service const config = yield* Config.Service
const git = yield* Git.Service
const state = yield* InstanceState.make( const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")( Effect.fn("FileWatcher.state")(
@@ -131,9 +131,11 @@ export namespace FileWatcher {
} }
if (Instance.project.vcs === "git") { if (Instance.project.vcs === "git") {
const result = yield* git.run(["rev-parse", "--git-dir"], { const result = yield* Effect.promise(() =>
Git.run(["rev-parse", "--git-dir"], {
cwd: Instance.project.worktree, cwd: Instance.project.worktree,
}) }),
)
const vcsDir = const vcsDir =
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
@@ -159,5 +161,11 @@ export namespace FileWatcher {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export function init() {
return runPromise((svc) => svc.init())
}
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve as pathResolve } from "path"
import { realpathSync } from "fs" import { realpathSync } from "fs"
import * as NFS from "fs/promises" import * as NFS from "fs/promises"
import { lookup } from "mime-types" import { lookup } from "mime-types"
import { Effect, FileSystem, Layer, Schema, Context } from "effect" import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
@@ -36,7 +36,7 @@ export namespace AppFileSystem {
readonly globMatch: (pattern: string, filepath: string) => boolean readonly globMatch: (pattern: string, filepath: string) => boolean
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileSystem") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
+17 -9
View File
@@ -1,7 +1,8 @@
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import path from "path" import path from "path"
import { mergeDeep } from "remeda" import { mergeDeep } from "remeda"
import z from "zod" import z from "zod"
@@ -30,7 +31,7 @@ export namespace Format {
readonly file: (filepath: string) => Effect.Effect<void> readonly file: (filepath: string) => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Format") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -50,13 +51,6 @@ export namespace Format {
formatters[item.name] = item formatters[item.name] = item
} }
for (const [name, item] of Object.entries(cfg.formatter ?? {})) { for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
// Ruff and uv are both the same formatter, so disabling either should disable both.
if (["ruff", "uv"].includes(name) && (cfg.formatter?.ruff?.disabled || cfg.formatter?.uv?.disabled)) {
// TODO combine formatters so shared backends like Ruff/uv don't need linked disable handling here.
delete formatters.ruff
delete formatters.uv
continue
}
if (item.disabled) { if (item.disabled) {
delete formatters[name] delete formatters[name]
continue continue
@@ -192,4 +186,18 @@ export namespace Format {
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function init() {
return runPromise((s) => s.init())
}
export async function status() {
return runPromise((s) => s.status())
}
export async function file(filepath: string) {
return runPromise((s) => s.file(filepath))
}
} }
+13 -2
View File
@@ -1,6 +1,7 @@
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { Effect, Layer, Context, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { makeRuntime } from "@/effect/run-service"
export namespace Git { export namespace Git {
const cfg = [ const cfg = [
@@ -79,7 +80,7 @@ export namespace Git {
return "modified" return "modified"
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Git") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -257,4 +258,14 @@ export namespace Git {
) )
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function run(args: string[], opts: Options) {
return runPromise((git) => git.run(args, opts))
}
export async function defaultBranch(cwd: string) {
return runPromise((git) => git.defaultBranch(cwd))
}
} }
+17 -2
View File
@@ -1,6 +1,7 @@
import { Effect, Layer, Schema, Context, Stream } from "effect" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
@@ -90,7 +91,7 @@ export namespace Installation {
readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError> readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> = export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> =
Layer.effect( Layer.effect(
@@ -337,4 +338,18 @@ export namespace Installation {
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function method(): Promise<Method> {
return runPromise((svc) => svc.method())
}
export async function latest(installMethod?: Method): Promise<string> {
return runPromise((svc) => svc.latest(installMethod))
}
export async function upgrade(m: Method, target: string): Promise<void> {
return runPromise((svc) => svc.upgrade(m, target))
}
} }
+2 -13
View File
@@ -11,7 +11,7 @@ import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Process } from "../util/process" import { Process } from "../util/process"
import { spawn as lspspawn } from "./launch" import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -156,7 +156,7 @@ export namespace LSP {
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]> readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LSP") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LSP") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -540,8 +540,6 @@ export namespace LSP {
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input)) export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
export namespace Diagnostic { export namespace Diagnostic {
const MAX_PER_FILE = 20
export function pretty(diagnostic: LSPClient.Diagnostic) { export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = { const severityMap = {
1: "ERROR", 1: "ERROR",
@@ -556,14 +554,5 @@ export namespace LSP {
return `${severity} [${line}:${col}] ${diagnostic.message}` return `${severity} [${line}:${col}] ${diagnostic.message}`
} }
export function report(file: string, issues: LSPClient.Diagnostic[]) {
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) return ""
const limited = errors.slice(0, MAX_PER_FILE)
const more = errors.length - MAX_PER_FILE
const suffix = more > 0 ? `\n... and ${more} more` : ""
return `<diagnostics file="${file}">\n${limited.map(pretty).join("\n")}${suffix}\n</diagnostics>`
}
} }
} }
+11 -1
View File
@@ -105,7 +105,17 @@ export namespace LSPServer {
if (!tsserver) return if (!tsserver) return
const bin = await Npm.which("typescript-language-server") const bin = await Npm.which("typescript-language-server")
if (!bin) return if (!bin) return
const proc = spawn(bin, ["--stdio"], {
const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver]
if (
!(await pathExists(path.join(root, "tsconfig.json"))) &&
!(await pathExists(path.join(root, "jsconfig.json")))
) {
args.push("--ignore-node-modules")
}
const proc = spawn(bin, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
+3 -3
View File
@@ -1,7 +1,7 @@
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import { Global } from "../global" import { Global } from "../global"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -49,7 +49,7 @@ export namespace McpAuth {
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null> readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/McpAuth") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -141,7 +141,7 @@ export namespace McpAuth {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
+4 -7
View File
@@ -24,8 +24,7 @@ import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event" import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open" import open from "open"
import { Effect, Exit, Layer, Option, Context, Stream } from "effect" import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -240,7 +239,7 @@ export namespace MCP {
readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus> readonly getAuthStatus: (mcpName: string) => Effect.Effect<AuthStatus>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/MCP") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/MCP") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -470,14 +469,12 @@ export namespace MCP {
log.info("tools list changed notification received", { server: name }) log.info("tools list changed notification received", { server: name })
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
const listed = await Effect.runPromise(defs(name, client, timeout).pipe(Effect.provide(EffectLogger.layer))) const listed = await Effect.runPromise(defs(name, client, timeout))
if (!listed) return if (!listed) return
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
s.defs[name] = listed s.defs[name] = listed
await Effect.runPromise( await Effect.runPromise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore, Effect.provide(EffectLogger.layer)),
)
}) })
} }
+2 -2
View File
@@ -10,7 +10,7 @@ import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect" import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
import { evaluate as evalRule } from "./evaluate" import { evaluate as evalRule } from "./evaluate"
@@ -135,7 +135,7 @@ export namespace Permission {
return evalRule(permission, pattern, ...rulesets) return evalRule(permission, pattern, ...rulesets)
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Permission") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
+5 -1
View File
@@ -5,8 +5,12 @@ import { Identifier } from "@/id/id"
import { Newtype } from "@/util/schema" import { Newtype } from "@/util/schema"
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) { export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
static make(id: string): PermissionID {
return this.makeUnsafe(id)
}
static ascending(id?: string): PermissionID { static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id)) return this.makeUnsafe(Identifier.ascending("permission", id))
} }
static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID> static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>
@@ -5,7 +5,6 @@ import { iife } from "@/util/iife"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models" import { CopilotModels } from "./models"
import { MessageV2 } from "@/session/message-v2"
const log = Log.create({ service: "plugin.copilot" }) const log = Log.create({ service: "plugin.copilot" })
@@ -28,27 +27,11 @@ function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com" return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
} }
// Check if a message is a synthetic user msg used to attach an image from a tool call function fix(model: Model): Model {
function imgMsg(msg: any): boolean {
if (msg?.role !== "user") return false
// Handle the 3 api formats
const content = msg.content
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
if (!Array.isArray(content)) return false
return content.some(
(part: any) =>
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)
}
function fix(model: Model, url: string): Model {
return { return {
...model, ...model,
api: { api: {
...model.api, ...model.api,
url,
npm: "@ai-sdk/github-copilot", npm: "@ai-sdk/github-copilot",
}, },
} }
@@ -61,23 +44,19 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
id: "github-copilot", id: "github-copilot",
async models(provider, ctx) { async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") { if (ctx.auth?.type !== "oauth") {
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())])) return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
} }
const auth = ctx.auth
return CopilotModels.get( return CopilotModels.get(
base(auth.enterpriseUrl), base(ctx.auth.enterpriseUrl),
{ {
Authorization: `Bearer ${auth.refresh}`, Authorization: `Bearer ${ctx.auth.refresh}`,
"User-Agent": `opencode/${Installation.VERSION}`, "User-Agent": `opencode/${Installation.VERSION}`,
}, },
provider.models, provider.models,
).catch((error) => { ).catch((error) => {
log.error("failed to fetch copilot models", { error }) log.error("failed to fetch copilot models", { error })
return Object.fromEntries( return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
)
}) })
}, },
}, },
@@ -87,7 +66,10 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const info = await getAuth() const info = await getAuth()
if (!info || info.type !== "oauth") return {} if (!info || info.type !== "oauth") return {}
const baseURL = base(info.enterpriseUrl)
return { return {
baseURL,
apiKey: "", apiKey: "",
async fetch(request: RequestInfo | URL, init?: RequestInit) { async fetch(request: RequestInfo | URL, init?: RequestInit) {
const info = await getAuth() const info = await getAuth()
@@ -106,7 +88,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(msg: any) => (msg: any) =>
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"), Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -118,7 +100,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
(item: any) => (item: any) =>
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"), Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
), ),
isAgent: last?.role !== "user" || imgMsg(last), isAgent: last?.role !== "user",
} }
} }
@@ -140,7 +122,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
part.content.some((nested: any) => nested?.type === "image")), part.content.some((nested: any) => nested?.type === "image")),
), ),
), ),
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last), isAgent: !(last?.role === "user" && hasNonToolCalls),
} }
} }
} catch {} } catch {}
@@ -52,15 +52,13 @@ export namespace CopilotModels {
(remote.capabilities.supports.vision ?? false) || (remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/")) (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
return { return {
id: key, id: key,
providerID: "github-copilot", providerID: "github-copilot",
api: { api: {
id: remote.id, id: remote.id,
url: isMsgApi ? `${url}/v1` : url, url,
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot", npm: "@ai-sdk/github-copilot",
}, },
// API response wins // API response wins
status: "active", status: "active",
+10 -17
View File
@@ -11,8 +11,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth" import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { Effect, Layer, Context, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
@@ -45,7 +44,7 @@ export namespace Plugin {
readonly init: () => Effect.Effect<void> readonly init: () => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [ const INTERNAL_PLUGINS: PluginInstance[] = [
@@ -84,11 +83,7 @@ export namespace Plugin {
} }
function publishPluginError(bus: Bus.Interface, message: string) { function publishPluginError(bus: Bus.Interface, message: string) {
Effect.runFork( Effect.runFork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
bus
.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
.pipe(Effect.provide(EffectLogger.layer)),
)
} }
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) { async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
@@ -124,7 +119,7 @@ export namespace Plugin {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
} }
: undefined, : undefined,
fetch: async (...args) => (await Server.Default()).app.fetch(...args), fetch: async (...args) => Server.Default().app.fetch(...args),
}) })
const cfg = yield* config.get() const cfg = yield* config.get()
const input: PluginInput = { const input: PluginInput = {
@@ -210,15 +205,13 @@ export namespace Plugin {
return message return message
}, },
}).pipe( }).pipe(
Effect.catch(() => { Effect.catch((message) =>
// TODO: make proper events for this bus.publish(Session.Event.Error, {
// bus.publish(Session.Event.Error, { error: new NamedError.Unknown({
// error: new NamedError.Unknown({ message: `Failed to load plugin ${load.spec}: ${message}`,
// message: `Failed to load plugin ${load.spec}: ${message}`, }).toObject(),
// }).toObject(),
// })
return Effect.void
}), }),
),
) )
} }
+4 -5
View File
@@ -2,6 +2,7 @@ import { Plugin } from "../plugin"
import { Format } from "../format" import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { File } from "../file" import { File } from "../file"
import { FileWatcher } from "../file/watcher"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { Project } from "./project" import { Project } from "./project"
import { Vcs } from "./vcs" import { Vcs } from "./vcs"
@@ -9,18 +10,16 @@ import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init())) ShareNext.init()
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init())) Format.init()
await LSP.init() await LSP.init()
File.init() File.init()
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init())) FileWatcher.init()
Vcs.init() Vcs.init()
Snapshot.init() Snapshot.init()
+19 -36
View File
@@ -3,9 +3,8 @@ import { disposeInstance } from "@/effect/instance-registry"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { LocalContext } from "../util/local-context" import { Context } from "../util/context"
import { Project } from "./project" import { Project } from "./project"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { State } from "./state" import { State } from "./state"
export interface InstanceContext { export interface InstanceContext {
@@ -14,16 +13,26 @@ export interface InstanceContext {
project: Project.Info project: Project.Info
} }
const context = LocalContext.create<InstanceContext>("instance") const context = Context.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>() const cache = new Map<string, Promise<InstanceContext>>()
const disposal = { const disposal = {
all: undefined as Promise<void> | undefined, all: undefined as Promise<void> | undefined,
} }
function emitDisposed(directory: string) {} function emit(directory: string) {
GlobalBus.emit("event", {
directory,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) { function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
return iife(async () => { return iife(async () => {
const ctx = const ctx =
input.project && input.worktree input.project && input.worktree
@@ -84,19 +93,17 @@ export const Instance = {
get project() { get project() {
return context.use().project return context.use().project
}, },
/** /**
* Check if a path is within the project boundary. * Check if a path is within the project boundary.
* Returns true if path is inside Instance.directory OR Instance.worktree. * Returns true if path is inside Instance.directory OR Instance.worktree.
* Paths within the worktree but outside the working directory should not trigger external_directory permission. * Paths within the worktree but outside the working directory should not trigger external_directory permission.
*/ */
containsPath(filepath: string, ctx?: InstanceContext) { containsPath(filepath: string) {
const instance = ctx ?? Instance if (Filesystem.contains(Instance.directory, filepath)) return true
if (Filesystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path. // Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions. // Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false if (Instance.worktree === "/") return false
return Filesystem.contains(instance.worktree, filepath) return Filesystem.contains(Instance.worktree, filepath)
}, },
/** /**
* Captures the current instance ALS context and returns a wrapper that * Captures the current instance ALS context and returns a wrapper that
@@ -124,39 +131,15 @@ export const Instance = {
await Promise.all([State.dispose(directory), disposeInstance(directory)]) await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory) cache.delete(directory)
const next = track(directory, boot({ ...input, directory })) const next = track(directory, boot({ ...input, directory }))
emit(directory)
GlobalBus.emit("event", {
directory,
project: input.project?.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
return await next return await next
}, },
async dispose() { async dispose() {
const directory = Instance.directory const directory = Instance.directory
const project = Instance.project
Log.Default.info("disposing instance", { directory }) Log.Default.info("disposing instance", { directory })
await Promise.all([State.dispose(directory), disposeInstance(directory)]) await Promise.all([State.dispose(directory), disposeInstance(directory)])
cache.delete(directory) cache.delete(directory)
emit(directory)
GlobalBus.emit("event", {
directory,
project: project.id,
workspace: WorkspaceContext.workspaceID,
payload: {
type: "server.instance.disposed",
properties: {
directory,
},
},
})
}, },
async disposeAll() { async disposeAll() {
if (disposal.all) return disposal.all if (disposal.all) return disposal.all
+2 -4
View File
@@ -8,7 +8,7 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { which } from "../util/which" import { which } from "../util/which"
import { ProjectID } from "./schema" import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, Context, Stream } from "effect" import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -100,7 +100,7 @@ export namespace Project {
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void> readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
type GitResult = { code: number; text: string; stderr: string } type GitResult = { code: number; text: string; stderr: string }
@@ -137,8 +137,6 @@ export namespace Project {
const emitUpdated = (data: Info) => const emitUpdated = (data: Info) =>
Effect.sync(() => Effect.sync(() =>
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: "global",
project: data.id,
payload: { type: Event.Updated.type, properties: data }, payload: { type: Event.Updated.type, properties: data },
}), }),
) )
+2 -1
View File
@@ -9,7 +9,8 @@ export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe( export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({ withStatics((schema: typeof projectIdSchema) => ({
global: schema.make("global"), global: schema.makeUnsafe("global"),
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ProjectID>()), zod: z.string().pipe(z.custom<ProjectID>()),
})), })),
) )
+3 -3
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, Context, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
import path from "path" import path from "path"
import { Bus } from "@/bus" import { Bus } from "@/bus"
@@ -151,7 +151,7 @@ export namespace Vcs {
root: Git.Base | undefined root: Git.Base | undefined
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
Service, Service,
@@ -226,7 +226,7 @@ export namespace Vcs {
}), }),
) )
export const defaultLayer = layer.pipe( const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer), Layer.provide(Bus.layer),
+21 -2
View File
@@ -2,9 +2,10 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Auth } from "@/auth" import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
import { Array as Arr, Effect, Layer, Record, Result, Context } from "effect" import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
import z from "zod" import z from "zod"
export namespace ProviderAuth { export namespace ProviderAuth {
@@ -108,7 +109,7 @@ export namespace ProviderAuth {
pending: Map<ProviderID, AuthOAuthResult> pending: Map<ProviderID, AuthOAuthResult>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
Service, Service,
@@ -231,4 +232,22 @@ export namespace ProviderAuth {
export const defaultLayer = Layer.suspend(() => export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
) )
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function methods() {
return runPromise((svc) => svc.methods())
}
export async function authorize(input: {
providerID: ProviderID
method: number
inputs?: Record<string, string>
}): Promise<Authorization | undefined> {
return runPromise((svc) => svc.authorize(input))
}
export async function callback(input: { providerID: ProviderID; method: number; code?: string }) {
return runPromise((svc) => svc.callback(input))
}
} }
File diff suppressed because it is too large Load Diff
+3 -5
View File
@@ -19,8 +19,7 @@ import { iife } from "@/util/iife"
import { Global } from "../global" import { Global } from "../global"
import path from "path" import path from "path"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { EffectLogger } from "@/effect/logger"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -925,7 +924,7 @@ export namespace Provider {
varsLoaders: Record<string, CustomVarsLoader> varsLoaders: Record<string, CustomVarsLoader>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
function cost(c: ModelsDev.Model["cost"]): Model["cost"] { function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
const result: Model["cost"] = { const result: Model["cost"] = {
@@ -1216,8 +1215,7 @@ export namespace Provider {
const options = yield* Effect.promise(() => const options = yield* Effect.promise(() =>
plugin.auth!.loader!( plugin.auth!.loader!(
() => () => Effect.runPromise(auth.get(providerID).pipe(Effect.orDie)) as any,
Effect.runPromise(auth.get(providerID).pipe(Effect.orDie, Effect.provide(EffectLogger.layer))) as any,
database[plugin.auth!.provider], database[plugin.auth!.provider],
), ),
) )
+13 -11
View File
@@ -9,19 +9,20 @@ export type ProviderID = typeof providerIdSchema.Type
export const ProviderID = providerIdSchema.pipe( export const ProviderID = providerIdSchema.pipe(
withStatics((schema: typeof providerIdSchema) => ({ withStatics((schema: typeof providerIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ProviderID>()), zod: z.string().pipe(z.custom<ProviderID>()),
// Well-known providers // Well-known providers
opencode: schema.make("opencode"), opencode: schema.makeUnsafe("opencode"),
anthropic: schema.make("anthropic"), anthropic: schema.makeUnsafe("anthropic"),
openai: schema.make("openai"), openai: schema.makeUnsafe("openai"),
google: schema.make("google"), google: schema.makeUnsafe("google"),
googleVertex: schema.make("google-vertex"), googleVertex: schema.makeUnsafe("google-vertex"),
githubCopilot: schema.make("github-copilot"), githubCopilot: schema.makeUnsafe("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"), amazonBedrock: schema.makeUnsafe("amazon-bedrock"),
azure: schema.make("azure"), azure: schema.makeUnsafe("azure"),
openrouter: schema.make("openrouter"), openrouter: schema.makeUnsafe("openrouter"),
mistral: schema.make("mistral"), mistral: schema.makeUnsafe("mistral"),
gitlab: schema.make("gitlab"), gitlab: schema.makeUnsafe("gitlab"),
})), })),
) )
@@ -31,6 +32,7 @@ export type ModelID = typeof modelIdSchema.Type
export const ModelID = modelIdSchema.pipe( export const ModelID = modelIdSchema.pipe(
withStatics((schema: typeof modelIdSchema) => ({ withStatics((schema: typeof modelIdSchema) => ({
make: (id: string) => schema.makeUnsafe(id),
zod: z.string().pipe(z.custom<ModelID>()), zod: z.string().pipe(z.custom<ModelID>()),
})), })),
) )
+1 -4
View File
@@ -774,10 +774,7 @@ export namespace ProviderTransform {
result["chat_template_args"] = { enable_thinking: true } result["chat_template_args"] = { enable_thinking: true }
} }
if ( if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
input.model.api.npm === "@ai-sdk/openai-compatible"
) {
result["thinking"] = { result["thinking"] = {
type: "enabled", type: "enabled",
clear_thinking: false, clear_thinking: false,
+5 -6
View File
@@ -10,8 +10,7 @@ import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Effect, Layer, Context } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { EffectLogger } from "@/effect/logger"
export namespace Pty { export namespace Pty {
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
@@ -113,7 +112,7 @@ export namespace Pty {
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined> ) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
@@ -257,8 +256,8 @@ export namespace Pty {
if (session.info.status === "exited") return if (session.info.status === "exited") return
log.info("session exited", { id, exitCode }) log.info("session exited", { id, exitCode })
session.info.status = "exited" session.info.status = "exited"
Effect.runFork(bus.publish(Event.Exited, { id, exitCode }).pipe(Effect.provide(EffectLogger.layer))) Effect.runFork(bus.publish(Event.Exited, { id, exitCode }))
Effect.runFork(remove(id).pipe(Effect.provide(EffectLogger.layer))) Effect.runFork(remove(id))
}), }),
) )
yield* bus.publish(Event.Created, { info }) yield* bus.publish(Event.Created, { info })
@@ -360,7 +359,7 @@ export namespace Pty {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer)) const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)

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