Compare commits

..

15 Commits

Author SHA1 Message Date
Kit Langton 7399d57aee feat(client): generate complete protocol client 2026-06-26 22:54:23 -04:00
Dax Raad 573ab9c24b fix(tui): interrupt v2 sessions with escape 2026-06-26 22:32:21 -04:00
Dax Raad 6a16c41e8f fix(core): continue after tool defects 2026-06-26 22:02:30 -04:00
Dax Raad a491cbee64 fix(tui): hide prompt for running shell commands 2026-06-26 21:35:20 -04:00
Dax Raad 658cbe9caf fix(core): load OpenCode provider config asynchronously 2026-06-26 21:16:15 -04:00
Dax Raad 62b2bc39df test(ci): skip v2 end-to-end checks 2026-06-26 21:04:10 -04:00
Dax Raad beb2c52c3f fix(cli): publish scoped package 2026-06-26 21:01:10 -04:00
Dax Raad 655adbf46e fix: stabilize v2 runtime behavior 2026-06-26 20:56:36 -04:00
Dax Raad ac2a78391f fix(ci): publish v2 as next 2026-06-26 15:55:04 -04:00
Dax Raad 9a9bdaba95 test(ci): drop legacy opencode tests 2026-06-26 14:58:40 -04:00
Dax Raad 8b682c42b6 refactor(tui): use v2 client transport 2026-06-26 14:57:01 -04:00
Dax Raad ad4f1c1018 refactor(ci): use branch npm tag 2026-06-26 14:54:35 -04:00
Dax Raad a0afb63ed0 fix(ci): use v2 npm tag 2026-06-26 14:53:35 -04:00
Dax Raad df9ecb8f6a fix(cli): publish v2 package 2026-06-26 14:52:30 -04:00
Dax Raad e6f660fecf feat(tui): add v2 terminal interface 2026-06-26 14:38:25 -04:00
270 changed files with 14527 additions and 9357 deletions
+7 -2
View File
@@ -6,6 +6,7 @@ on:
branches: branches:
- ci - ci
- dev - dev
- v2
- beta - beta
- fix/npm-native-binary-install - fix/npm-native-binary-install
- snapshot-* - snapshot-*
@@ -31,6 +32,9 @@ permissions:
contents: write contents: write
packages: write packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs: jobs:
version: version:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -122,7 +126,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' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
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 }}
@@ -221,7 +225,7 @@ jobs:
needs: needs:
- build-cli - build-cli
- version - version
if: github.repository == 'anomalyco/opencode' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false continue-on-error: false
env: env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -447,6 +451,7 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'v2'
with: with:
name: opencode-cli-signed-windows name: opencode-cli-signed-windows
path: packages/opencode/dist path: packages/opencode/dist
+2 -5
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- dev - dev
- v2
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
@@ -74,13 +75,9 @@ jobs:
working-directory: packages/client working-directory: packages/client
run: bun run check:generated run: bun run check:generated
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
e2e: e2e:
name: e2e (${{ matrix.settings.name }}) name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
+35 -18
View File
@@ -83,7 +83,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -94,7 +93,7 @@
"name": "@opencode-ai/cli", "name": "@opencode-ai/cli",
"version": "1.17.11", "version": "1.17.11",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs", "opencode2": "./bin/opencode2.cjs",
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -205,9 +204,13 @@
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.17.11", "version": "1.17.11",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"ai": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@@ -215,6 +218,7 @@
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
@@ -312,6 +316,7 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
@@ -930,6 +935,7 @@
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
"@opentui/keymap": "catalog:", "@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"diff": "catalog:", "diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -986,7 +992,6 @@
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"solid-js": "catalog:", "solid-js": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
@@ -1174,9 +1179,9 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="], "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
@@ -4568,7 +4573,7 @@
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="], "openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -5312,8 +5317,6 @@
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -5598,6 +5601,8 @@
"@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], "@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="], "@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
@@ -5614,12 +5619,8 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/cerebras/@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/cerebras/@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/cohere/@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/cohere/@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=="],
@@ -5628,8 +5629,6 @@
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/deepinfra/@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/deepinfra/@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/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
@@ -5662,18 +5661,20 @@
"@ai-sdk/mistral/@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/mistral/@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/openai/@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/openai-compatible/@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/perplexity/@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/perplexity/@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/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@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/togetherai/@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/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@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/vercel/@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/xai/@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=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"@astrojs/cloudflare/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "@astrojs/cloudflare/vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="],
@@ -5974,6 +5975,10 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -6108,6 +6113,8 @@
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
@@ -6254,6 +6261,8 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gitlab-ai-provider/openai": ["openai@6.39.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-z3dO9fEWOXBzlXynVb/xZ/tujzUjFWQWn3C0n0mw6Vo0zJTbEkaN4b2cLWjhJ6haJQx8LlREoafHRl+Gu/Hl+A=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -6320,6 +6329,10 @@
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], "nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], "opencode/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], "opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
@@ -6510,6 +6523,10 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/perplexity/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/togetherai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=", "x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
"aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=", "aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
"aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=", "aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
"x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y=" "x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
-1
View File
@@ -38,7 +38,6 @@
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"tw-animate-css": "1.4.0",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "catalog:", "vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1", "vite-plugin-icons-spritesheet": "3.0.1",
+12 -22
View File
@@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider, useNotification } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
@@ -316,7 +316,9 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<LayoutProvider> <LayoutProvider>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</LayoutProvider> </LayoutProvider>
</PermissionProvider> </PermissionProvider>
) )
@@ -343,23 +345,13 @@ function NewAppLayout(props: ParentProps) {
function TargetServerScopedProviders(props: ServerScopedShellProps) { function TargetServerScopedProviders(props: ServerScopedShellProps) {
return ( return (
<PermissionProvider directory={props.directory}> <PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} /> <NotificationProvider directory={props.directory} sessionID={props.sessionID}>
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider> <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</NotificationProvider>
</PermissionProvider> </PermissionProvider>
) )
} }
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) { function SessionProviders(props: ParentProps) {
return ( return (
<TerminalProvider> <TerminalProvider>
@@ -568,13 +560,11 @@ export function AppInterface(props: {
component={props.router ?? Router} component={props.router ?? Router}
root={(routerProps) => ( root={(routerProps) => (
<TabsProvider> <TabsProvider>
<NotificationProvider> <ServerShell>
<ServerShell> <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}> <NewAppLayout>{routerProps.children}</NewAppLayout>
<NewAppLayout>{routerProps.children}</NewAppLayout> </Show>
</Show> </ServerShell>
</ServerShell>
</NotificationProvider>
</TabsProvider> </TabsProvider>
)} )}
> >
@@ -59,7 +59,6 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
> >
{node} {node}
+6 -11
View File
@@ -1343,9 +1343,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const agentsLoading = () => props.controls.agents.loading const agentsLoading = () => props.controls.agents.loading
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading()) const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
const providersLoading = () => props.controls.model.loading const providersLoading = () => props.controls.model.loading
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading()) const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
const [promptReady] = createResource( const [promptReady] = createResource(
() => prompt.ready.promise, () => prompt.ready.promise,
@@ -1359,7 +1359,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const modelControlState = createMemo<ComposerModelControlState>(() => ({ const modelControlState = createMemo<ComposerModelControlState>(() => ({
loading: providersLoading(), loading: providersLoading(),
shouldAnimate: providersShouldFadeIn(),
paid: props.controls.model.paid, paid: props.controls.model.paid,
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
keybind: command.keybind("model.choose"), keybind: command.keybind("model.choose"),
@@ -1520,11 +1519,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
{props.toolbar} {props.toolbar}
<ComposerModelControl state={modelControlState()} /> <ComposerModelControl state={modelControlState()} />
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}> <Show when={store.mode !== "shell" && showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block": "hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
!props.controls.model.selection.variant.current() && !store.variantOpen, !props.controls.model.selection.variant.current() && !store.variantOpen,
}} }}
@@ -1767,7 +1765,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!agentsLoading()}> <Show when={!agentsLoading()}>
<div <div
data-component="prompt-agent-control" data-component="prompt-agent-control"
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }} style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1796,7 +1794,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={store.mode !== "shell"}> <Show when={store.mode !== "shell"}>
<div <div
data-component="prompt-model-control" data-component="prompt-model-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<Show <Show
when={props.controls.model.paid} when={props.controls.model.paid}
@@ -1875,7 +1873,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={showVariantControl()}> <Show when={showVariantControl()}>
<div <div
data-component="prompt-variant-control" data-component="prompt-variant-control"
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }} style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
> >
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1925,7 +1923,6 @@ type ComposerAgentControlState = {
type ComposerModelControlState = { type ComposerModelControlState = {
loading: boolean loading: boolean
shouldAnimate: boolean
paid: boolean paid: boolean
title: string title: string
keybind: string keybind: string
@@ -1973,7 +1970,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
variant="ghost" variant="ghost"
size="normal" size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group" class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style} style={props.state.style}
onClick={props.state.onUnpaidClick} onClick={props.state.onUnpaidClick}
> >
@@ -2004,7 +2000,6 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
style: props.state.style, style: props.state.style,
class: class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group", "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model", "data-action": "prompt-model",
}} }}
onClose={props.state.onClose} onClose={props.state.onClose}
+245 -343
View File
@@ -1,9 +1,9 @@
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams, useSearchParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { ServerSDK } from "./server-sdk" import { useServerSDK } from "./server-sdk"
import type { ServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
@@ -12,11 +12,6 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -112,360 +107,267 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
gate: false, gate: false,
init: () => { init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK()
const global = useGlobal() const serverSync = useServerSync()
const server = useServer()
const tabs = useTabs()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
const language = useLanguage() const language = useLanguage()
const owner = getOwner()
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const activeServer = createMemo(() => { const empty: Notification[] = []
if (params.serverKey) return requireServerKey(params.serverKey)
if (search.draftId) { const currentDirectory = createMemo(() => {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) return props.directory?.() ?? decode64(params.dir)
if (draft) return draft.server
}
return server.key
}) })
const activeDirectory = createMemo(() => decode64(params.dir))
const activeSession = createMemo(() => params.id)
const ensure = (key: ServerConnection.Key) => { const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Notification server not found: ${key}`) const [store, setStore, _, ready] = persisted(
const ctx = global.ensureServerCtx(conn) Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
const existing = states.get(ctx.sdk.scope) createStore({
if (existing) return existing.state list: [] as Notification[],
const root = createRoot( }),
(dispose) => ({ )
dispose, const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
state: createServerNotificationState({
sdk: ctx.sdk, const meta = { pruned: false, disposed: false }
sync: ctx.sync,
active: () => server.scope(activeServer()) === ctx.sdk.scope, const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
directory: activeDirectory, setIndex(scope, "unseen", key, unseen)
sessionID: activeSession, setIndex(scope, "unseenCount", key, unseen.length)
platform, setIndex(
settings, scope,
language, "unseenHasError",
}), key,
}), unseen.some((notification) => notification.type === "error"),
owner ?? undefined,
) )
states.set(ctx.sdk.scope, root) }
return root.state
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
} }
createEffect(() => { createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) if (!ready()) return
}) if (meta.pruned) return
meta.pruned = true
createEffect(() => { const list = pruneNotifications(store.list)
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn)))) batch(() => {
states.forEach((value, scope) => { setStore("list", list)
if (scopes.has(scope)) return setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
value.dispose()
states.delete(scope)
}) })
}) })
onCleanup(() => states.forEach((value) => value.dispose())) const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
const selected = () => ensure(activeServer()) batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return { return {
ready: () => selected().ready(), ready,
ensureServerState: ensure,
session: { session: {
all: (session: string) => selected().session.all(session), all(session: string) {
unseen: (session: string) => selected().session.unseen(session), return index.session.all[session] ?? empty
unseenCount: (session: string) => selected().session.unseenCount(session), },
unseenHasError: (session: string) => selected().session.unseenHasError(session), unseen(session: string) {
markViewed: (session: string) => selected().session.markViewed(session), return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
}, },
project: { project: {
all: (directory: string) => selected().project.all(directory), all(directory: string) {
unseen: (directory: string) => selected().project.unseen(directory), return index.project.all[directory] ?? empty
unseenCount: (directory: string) => selected().project.unseenCount(directory), },
unseenHasError: (directory: string) => selected().project.unseenHasError(directory), unseen(directory: string) {
markViewed: (directory: string) => selected().project.markViewed(directory), return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
}, },
} }
}, },
}) })
type NotificationState = ReturnType<typeof createServerNotificationState>
function createServerNotificationState(input: {
sdk: ServerSDK
sync: ServerSync
active: Accessor<boolean>
directory: Accessor<string | undefined>
sessionID: Accessor<string | undefined>
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
const platform = input.platform
const settings = input.settings
const language = input.language
const empty: Notification[] = []
const currentDirectory = input.directory
const currentSession = input.sessionID
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
const append = (notification: Notification) => {
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
.sync(sessionID)
.then(() => sync.session.get(sessionID))
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
if (!input.active()) return false
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeSession) return false
if (!sessionID) return false
if (activeDirectory && directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
if (settings.sounds.agentEnabled()) {
void playSoundById(settings.sounds.agent())
}
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
if (settings.sounds.errorsEnabled()) {
void playSoundById(settings.sounds.errors())
}
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = serverSDK().event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
const directory = e.name
const time = Date.now()
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
return {
ready,
session: {
all(session: string) {
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all(directory: string) {
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
}
+9 -1
View File
@@ -1,7 +1,6 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/session-ui/styles"; @import "@opencode-ai/session-ui/styles";
@import "@opencode-ai/ui/v2/styles/tailwind.css"; @import "@opencode-ai/ui/v2/styles/tailwind.css";
@import "tw-animate-css";
@font-face { @font-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
@@ -132,4 +131,13 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
} }
+21 -24
View File
@@ -68,7 +68,6 @@ import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64 const HOME_SESSION_LIMIT = 64
const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
@@ -342,15 +341,15 @@ export function NewHome() {
} }
function unseenCount(conn: ServerConnection.Any, project: LocalProject) { function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn)) if (ServerConnection.key(conn) !== server.key) return 0
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
} }
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
const state = notification.ensureServerState(ServerConnection.key(conn)) if (ServerConnection.key(conn) !== server.key) return
directories(project) directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0) .filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory)) .forEach((directory) => notification.project.markViewed(directory))
} }
function openSession(session: Session) { function openSession(session: Session) {
@@ -1190,24 +1189,22 @@ function HomeSessionRow(props: {
</span> </span>
</Show> </Show>
</button> </button>
<Show when={SHOW_HOME_SESSION_ARCHIVE}> <div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100">
<div class="hover-reveal absolute right-1.5 top-1/2 flex -translate-y-1/2 items-center gap-1 group-hover/session:opacity-100 focus-within:opacity-100"> <TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}> <IconButtonV2
<IconButtonV2 data-action="home-session-archive"
data-action="home-session-archive" variant="ghost-muted"
variant="ghost-muted" size="large"
size="large" icon={<IconV2 name="archive" />}
icon={<IconV2 name="archive" />} aria-label={language.t("common.archive")}
aria-label={language.t("common.archive")} onClick={(event) => {
onClick={(event) => { event.preventDefault()
event.preventDefault() event.stopPropagation()
event.stopPropagation() void props.archiveSession(props.record.session)
void props.archiveSession(props.record.session) }}
}} />
/> </TooltipV2>
</TooltipV2> </div>
</div>
</Show>
</div> </div>
) )
} }
+9 -1
View File
@@ -1,18 +1,26 @@
import { createEffect, Suspense, type ParentProps } from "solid-js" import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button" import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { setV2Toast, ToastRegion } from "@/utils/toast" import { setV2Toast, ToastRegion } from "@/utils/toast"
export default function NewLayout(props: ParentProps) { export default function NewLayout(props: ParentProps) {
const platform = usePlatform() const platform = usePlatform()
const notification = useNotification()
const navigate = useNavigate() const navigate = useNavigate()
const params = useParams<{ id?: string }>()
setNavigate(navigate) setNavigate(navigate)
createEffect(() => setV2Toast(true)) createEffect(() => setV2Toast(true))
createEffect(() => {
if (!notification.ready() || !params.id) return
if (notification.session.unseenCount(params.id) === 0) return
notification.session.markViewed(params.id)
})
const update: TitlebarUpdate = { const update: TitlebarUpdate = {
version: () => { version: () => {
+82
View File
@@ -0,0 +1,82 @@
# V2 CLI and TUI development guide
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI.
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Interactive debugging
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev --standalone` for most debugging so the TUI starts with a private V2 server instead of depending on the background service.
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev --standalone
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, omit `--standalone`. Service lifecycle commands are available through `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts --standalone
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.
@@ -31,11 +31,11 @@ function run(target) {
const envPath = process.env.OPENCODE_BIN_PATH const envPath = process.env.OPENCODE_BIN_PATH
const scriptDir = path.dirname(fs.realpathSync(__filename)) const scriptDir = path.dirname(fs.realpathSync(__filename))
const cached = path.join(scriptDir, ".lildax") const cached = path.join(scriptDir, ".opencode2")
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform() const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform()
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch() const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
const base = "@opencode-ai/cli-" + platform + "-" + arch const base = "@opencode-ai/cli-" + platform + "-" + arch
const binary = platform === "windows" ? "lildax.exe" : "lildax" const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
function supportsAvx2() { function supportsAvx2() {
if (arch !== "x64") return false if (arch !== "x64") return false
@@ -121,7 +121,7 @@ function findBinary(startDir) {
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
if (!resolved) { if (!resolved) {
console.error( console.error(
"It seems that your package manager failed to install the right lildax CLI package. Try manually installing " + "It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " +
names.map((name) => `"${name}"`).join(" or ") + names.map((name) => `"${name}"`).join(" or ") +
" package", " package",
) )
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"lildax": "./bin/lildax.cjs" "opencode2": "./bin/opencode2.cjs"
}, },
"files": [ "files": [
"bin" "bin"
+1 -1
View File
@@ -10,7 +10,7 @@ import pkg from "../package.json"
import { modelsData } from "./generate" import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..") const dir = path.resolve(import.meta.dirname, "..")
const binary = "lildax" const binary = "opencode2"
process.chdir(dir) process.chdir(dir)
await rm("dist", { recursive: true, force: true }) await rm("dist", { recursive: true, force: true })
+7 -6
View File
@@ -25,14 +25,15 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
} }
console.log("binaries", binaries) console.log("binaries", binaries)
const version = Object.values(binaries)[0] const version = Object.values(binaries)[0]
const name = pkg.name
await $`mkdir -p ./dist/${pkg.name}/bin` await $`mkdir -p ./dist/${name}/bin`
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax` await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
await Bun.file(`./dist/${pkg.name}/package.json`).write( await Bun.file(`./dist/${name}/package.json`).write(
JSON.stringify( JSON.stringify(
{ {
name: pkg.name, name,
bin: { lildax: "./bin/lildax" }, bin: { opencode2: "./bin/opencode2" },
version, version,
license: pkg.license, license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
@@ -50,4 +51,4 @@ await Promise.all(
publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version), publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version),
), ),
) )
await publish(`./dist/${pkg.name}`, pkg.name, version) await publish(`./dist/${name}`, name, version)
+11
View File
@@ -5,6 +5,16 @@ declare const OPENCODE_CLI_NAME: string | undefined
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", { export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface", description: "OpenCode 2.0 preview command line interface",
params: {
directory: Argument.string("directory").pipe(
Argument.withDescription("Directory to start OpenCode in"),
Argument.optional,
),
standalone: Flag.boolean("standalone").pipe(
Flag.withDescription("Run with a private server instead of the background service"),
Flag.withDefault(false),
),
},
commands: [ commands: [
Spec.make("api", { Spec.make("api", {
description: "Make a request to the running server", description: "Make a request to the running server",
@@ -46,6 +56,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")), hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
port: Flag.integer("port").pipe(Flag.optional), port: Flag.integer("port").pipe(Flag.optional),
register: Flag.boolean("register").pipe(Flag.withDefault(false)), register: Flag.boolean("register").pipe(Flag.withDefault(false)),
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
}, },
}), }),
], ],
+15 -4
View File
@@ -1,13 +1,24 @@
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Effect } from "effect" import { Effect, Option } from "effect"
import { Daemon } from "../../services/daemon" import { Daemon } from "../../services/daemon"
import { Standalone } from "../../services/standalone"
export default Runtime.handler(Commands, () => export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () { Effect.gen(function* () {
const directory = Option.getOrUndefined(input.directory)
if (directory !== undefined) process.chdir(directory)
const daemon = yield* Daemon.Service const daemon = yield* Daemon.Service
const transport = yield* daemon.transport() const transport = yield* (input.standalone ? Standalone.transport() : daemon.transport())
const { runTui } = yield* Effect.promise(() => import("../../tui")) const { runTui } = yield* Effect.promise(() => import("../../tui"))
yield* runTui(transport) yield* runTui(
transport,
input.standalone
? undefined
: async () => {
await Effect.runPromise(daemon.stop())
return Effect.runPromise(daemon.transport())
},
)
}), }),
) )
+38 -6
View File
@@ -6,6 +6,8 @@ import * as Effect from "effect/Effect"
import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { createServer } from "node:http" import { createServer } from "node:http"
import { createRoutes } from "@opencode-ai/server/routes" import { createRoutes } from "@opencode-ai/server/routes"
import { ServerAuth } from "@opencode-ai/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { Commands } from "../commands" import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime" import { Runtime } from "../../framework/runtime"
import { Daemon } from "../../services/daemon" import { Daemon } from "../../services/daemon"
@@ -16,15 +18,41 @@ export default Runtime.handler(
return yield* Effect.scoped( return yield* Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const daemon = yield* Daemon.Service const daemon = yield* Daemon.Service
const address = yield* listen(input.hostname, input.port, yield* daemon.password()) const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
const password = input.stdio ? standalonePassword : yield* daemon.password()
if (!password) return yield* Effect.fail(new Error("Missing server password"))
const address = yield* listen(input.hostname, input.port, password)
yield* Effect.tryPromise(() =>
createOpencodeClient({
baseUrl: HttpServer.formatAddress(address),
headers: ServerAuth.headers({ password }),
}).v2.location.get(undefined, { throwOnError: true }),
)
if (input.register) yield* daemon.register(address) if (input.register) yield* daemon.register(address)
console.log(`server listening on ${HttpServer.formatAddress(address)}`) const url = HttpServer.formatAddress(address)
return yield* Effect.never console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
}), return yield* (input.stdio ? waitForStdinClose() : Effect.never)
}).pipe(Effect.annotateLogs({ role: "server" })),
) )
}), }),
) )
function waitForStdinClose() {
return Effect.callback<void>((resume) => {
const close = () => resume(Effect.void)
process.stdin.once("end", close)
process.stdin.once("close", close)
process.stdin.resume()
if (process.stdin.readableEnded || process.stdin.destroyed) close()
return Effect.sync(() => {
process.stdin.off("end", close)
process.stdin.off("close", close)
process.stdin.pause()
})
})
}
function listen(hostname: string, port: Option.Option<number>, password: string) { function listen(hostname: string, port: Option.Option<number>, password: string) {
if (Option.isSome(port)) return bind(hostname, port.value, password) if (Option.isSome(port)) return bind(hostname, port.value, password)
const next = (port: number): ReturnType<typeof bind> => const next = (port: number): ReturnType<typeof bind> =>
@@ -35,11 +63,15 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
} }
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build( return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe( HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })), Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(Credential.defaultLayer), Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer), Layer.provide(PermissionSaved.defaultLayer),
), ),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address)) ).pipe(
Effect.tap(() => Effect.addFinalizer(() => Effect.sync(() => server.closeAllConnections()))),
Effect.map((context) => Context.get(context, HttpServer.HttpServer).address),
)
} }
@@ -8,6 +8,6 @@ export default Runtime.handler(
Commands.commands.service.commands.status, Commands.commands.service.commands.status,
Effect.fn("cli.service.status")(function* () { Effect.fn("cli.service.status")(function* () {
const url = yield* (yield* Daemon.Service).status() const url = yield* (yield* Daemon.Service).status()
process.stdout.write((url ? `running ${url}` : "stopped") + EOL) process.stdout.write((url ? url : "stopped") + EOL)
}), }),
) )
+4 -3
View File
@@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"
import * as Command from "effect/unstable/cli/Command" import * as Command from "effect/unstable/cli/Command"
import { Spec } from "./spec" import { Spec } from "./spec"
import { Daemon } from "../services/daemon" import { Daemon } from "../services/daemon"
import { Scope } from "effect"
export type Input<Value> = export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands> Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -10,11 +11,11 @@ export type Input<Value> =
? Input ? Input
: never : never
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service> type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Scope.Scope>
type Loader<Node extends Spec.Any> = () => Promise<{ type Loader<Node extends Spec.Any> = () => Promise<{
default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service> default: (input: Input<Node>) => Effect.Effect<void, any, Daemon.Service | Scope.Scope>
}> }>
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service> type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Scope.Scope>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
? Loader<Node> ? Loader<Node>
+12
View File
@@ -2,10 +2,19 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime" import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
import * as NodeServices from "@effect/platform-node/NodeServices" import * as NodeServices from "@effect/platform-node/NodeServices"
import { NodeFileSystem } from "@effect/platform-node"
import * as Effect from "effect/Effect" import * as Effect from "effect/Effect"
import { Layer, Logger, References } from "effect"
import { Commands } from "./commands/commands" import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime" import { Runtime } from "./framework/runtime"
import { Daemon } from "./services/daemon" import { Daemon } from "./services/daemon"
import { Logging } from "@opencode-ai/core/observability/logging"
const LoggingLayer = Logger.layer(Logging.loggers(), { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
const Handlers = Runtime.handlers(Commands, { const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"), $: () => import("./commands/handlers/default"),
@@ -25,8 +34,11 @@ const Handlers = Runtime.handlers(Commands, {
}) })
Runtime.run(Commands, Handlers, { version: "local" }).pipe( Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Daemon.defaultLayer), Effect.provide(Daemon.defaultLayer),
Effect.provide(LoggingLayer),
Effect.provide(NodeServices.layer), Effect.provide(NodeServices.layer),
Effect.scoped, Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(0))),
NodeRuntime.runMain, NodeRuntime.runMain,
) )
+24 -12
View File
@@ -1,5 +1,5 @@
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { ServerAuth } from "@opencode-ai/server/auth" import { ServerAuth } from "@opencode-ai/server/auth"
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect" import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
@@ -28,6 +28,10 @@ const Registration = Schema.Struct({
}) })
type Registration = typeof Registration.Type type Registration = typeof Registration.Type
const Config = Schema.Struct({
password: Schema.optional(Schema.String),
})
function sameRegistration(left: Registration, right: Registration) { function sameRegistration(left: Registration, right: Registration) {
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
} }
@@ -37,22 +41,30 @@ export const layer = Layer.effect(
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const directory = Global.Path.state const directory = Global.Path.state
const file = path.join(directory, "server.json") const file = path.join(directory, InstallationChannel === "local" ? "server-local.json" : "server.json")
const passwordFile = path.join(directory, "password") const configFile = path.join(Global.Path.config, "service.json")
const legacyPasswordFile = path.join(directory, "password")
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(Config))
const password = Effect.fn("cli.daemon.password")(function* (value?: string) { const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined))) const config = yield* fs
if (value === undefined && existing) return existing .readFileString(configFile)
.pipe(Effect.flatMap(decodeConfig), Effect.catch(() => Effect.succeed(undefined)))
if (value === undefined && config?.password) return config.password
const legacy = yield* fs
.readFileString(legacyPasswordFile)
.pipe(Effect.catch(() => Effect.succeed(undefined)))
const next = value ?? legacy ?? randomBytes(32).toString("base64url")
// Keep one private credential across server restarts so discovered clients // Keep one private credential across server restarts so discovered clients
// can reconnect without exposing a password flag or environment variable. // can reconnect without exposing a password flag or environment variable.
const generated = value ?? randomBytes(32).toString("base64url") const temp = configFile + ".tmp"
const temp = passwordFile + ".tmp" yield* fs.writeFileString(temp, JSON.stringify({ password: next }, null, 2) + "\n", { mode: 0o600 })
yield* fs.makeDirectory(directory, { recursive: true }) yield* fs.rename(temp, configFile)
yield* fs.writeFileString(temp, generated, { mode: 0o600 }) if (legacy) yield* fs.remove(legacyPasswordFile).pipe(Effect.ignore)
yield* fs.rename(temp, passwordFile) return next
return generated
}) })
const registration = Effect.fnUntraced(function* () { const registration = Effect.fnUntraced(function* () {
@@ -111,7 +123,7 @@ export const layer = Layer.effect(
const existing = yield* healthy().pipe(Effect.option) const existing = yield* healthy().pipe(Effect.option)
const found = Option.getOrUndefined(existing) const found = Option.getOrUndefined(existing)
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun" const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
if (found?.version === InstallationVersion && compiled) return found.url if (found?.version === InstallationVersion) return found.url
if (found) yield* stopProcess(found).pipe(Effect.ignore) if (found) yield* stopProcess(found).pipe(Effect.ignore)
const entrypoint = compiled ? undefined : process.argv[1] const entrypoint = compiled ? undefined : process.argv[1]
+41
View File
@@ -0,0 +1,41 @@
import { ServerAuth } from "@opencode-ai/server/auth"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Schema, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { randomBytes } from "node:crypto"
import path from "node:path"
const Ready = Schema.Struct({ url: Schema.String })
const decodeReady = Schema.decodeUnknownPromise(Schema.fromJsonString(Ready))
function command(password: string) {
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
const entrypoint = compiled ? [] : process.argv[1] ? [process.argv[1]] : []
if (!compiled && entrypoint.length === 0) throw new Error("Failed to resolve CLI entrypoint")
return ChildProcess.make(process.execPath, [...entrypoint, "serve", "--stdio", "--port", "0"], {
cwd: process.cwd(),
env: { OPENCODE_SERVER_PASSWORD: password },
extendEnv: true,
// The server treats EOF on this pipe as the end of its ownership lease.
// The OS closes it even when the TUI is killed before Effect finalizers run.
stdin: "pipe",
stderr: "ignore",
killSignal: "SIGTERM",
forceKillAfter: "3 seconds",
})
}
export const transport = Effect.fn("cli.standalone.transport")(
function* () {
const password = randomBytes(32).toString("base64url")
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const proc = yield* spawner.spawn(command(password))
const output = yield* proc.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.take(1), Stream.mkString)
if (!output) return yield* Effect.fail(new Error("Standalone server exited before reporting readiness"))
const ready = yield* Effect.tryPromise(() => decodeReady(output))
return { url: ready.url, headers: ServerAuth.headers({ password }), pid: proc.pid }
},
Effect.provide(CrossSpawnSpawner.defaultLayer),
)
export * as Standalone from "./standalone"
+38 -28
View File
@@ -2,35 +2,45 @@ import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config" import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect" import { Effect } from "effect"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) { type Transport = { url: string; headers: RequestInit["headers"] }
export function runTui(transport: Transport, reload?: () => Promise<Transport>) {
const config = TuiConfig.resolve({}, { terminalSuspend: false }) const config = TuiConfig.resolve({}, { terminalSuspend: false })
return run({ let disposeSlots: (() => void) | undefined
...transport, return Effect.gen(function* () {
args: {}, const options = { baseUrl: transport.url, headers: transport.headers }
config, const client = createOpencodeClient(options)
fetch: gracefulFetch, const directory = yield* Effect.tryPromise(() =>
pluginHost: { client.v2.fs.list({ location: { directory: process.cwd() } }, { throwOnError: true }),
async start() {}, ).pipe(
async dispose() {}, Effect.map((response) => response.data.location.directory),
}, Effect.catch(() =>
Effect.tryPromise(() => client.v2.location.get(undefined, { throwOnError: true })).pipe(
Effect.map((response) => response.data.directory),
),
),
)
return yield* run({
client: createOpencodeClient({ ...options, directory }),
reload: reload
? async () => {
const next = await reload()
return createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory })
}
: undefined,
args: {},
config,
pluginHost: {
async start(input) {
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
},
async dispose() {
disposeSlots?.()
},
},
})
}).pipe(Effect.provide(Global.defaultLayer)) }).pipe(Effect.provide(Global.defaultLayer))
} }
const legacyDefaults: Record<string, unknown> = {
"/config/providers": { providers: [], default: {} },
"/provider": { all: [], default: {}, connected: [] },
"/agent": [],
"/config": {},
}
const gracefulFetch = Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await fetch(input, init)
if (response.status !== 404) return response
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
if (fallback === undefined) return response
return Response.json(fallback)
},
{ preconnect: fetch.preconnect },
)
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import path from "node:path"
import { Standalone } from "../../src/services/standalone"
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const transport = yield* Standalone.transport()
console.log(`${transport.pid} ${transport.url}`)
return yield* Effect.never
}),
),
)
+65
View File
@@ -0,0 +1,65 @@
import { expect, test } from "bun:test"
import path from "node:path"
test("standalone server exits when its owner is killed", async () => {
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: process.env,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const line = await Promise.race([readLine(owner.stdout), Bun.sleep(10_000).then(() => undefined)])
const [rawPID, url] = line?.split(" ") ?? []
const pid = Number(rawPID)
try {
expect(pid).toBeGreaterThan(0)
expect(url).toStartWith("http://127.0.0.1:")
expect(running(pid)).toBe(true)
owner.kill("SIGKILL")
await owner.exited
expect(await waitForExit(pid)).toBe(true)
} finally {
owner.kill("SIGKILL")
if (running(pid)) process.kill(pid, "SIGKILL")
}
})
async function readLine(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
const chunks: string[] = []
while (true) {
const result = await reader.read()
if (result.done) break
chunks.push(decoder.decode(result.value, { stream: true }))
const output = chunks.join("")
const newline = output.indexOf("\n")
if (newline !== -1) {
reader.releaseLock()
return output.slice(0, newline)
}
}
reader.releaseLock()
return chunks.join("") + decoder.decode()
}
async function waitForExit(pid: number, attempts = 100): Promise<boolean> {
if (!running(pid)) return true
if (attempts === 0) return false
await Bun.sleep(50)
return waitForExit(pid, attempts - 1)
}
function running(pid: number) {
if (!Number.isSafeInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ Private generation target for clients derived directly from OpenCode's authorita
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`. - `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`. - `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
The generated surface starts with the Session group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift. The generated surface includes every group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API. The Effect entrypoint uses canonical decoded values such as `Session.ID`, `Location.Ref`, and `Prompt`. These datatypes come from the lightweight `@opencode-ai/schema` package and are re-exported so callers depend only on the client surface. Protocol owns endpoint construction and middleware placement; Server supplies the concrete middleware keys used by the build-time API.
+5 -16
View File
@@ -1,29 +1,18 @@
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
import { ClientApi } from "../src/contract" import { Api } from "@opencode-ai/server/api"
import { Effect } from "effect" import { Effect } from "effect"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { endpointNames, groupNames } from "../src/contract"
const contract = compile(ClientApi, { const contract = compile(Api, { groupNames, endpointNames })
groupNames: { "server.session": "sessions", "server.event": "events" },
})
await Effect.runPromise( await Effect.runPromise(
Effect.all( Effect.all(
[ [
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
write( write(
emitPromise(contract, { emitEffectImported(contract, { module: "../contract", api: "Api" }),
outputTypes: {
"events.subscribe": {
name: "OpenCodeEventEncoded",
import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"',
},
},
}),
fileURLToPath(new URL("../src/generated", import.meta.url)),
),
write(
emitEffectImported(contract, { module: "../contract", api: "ClientApi" }),
fileURLToPath(new URL("../src/generated-effect", import.meta.url)), fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
), ),
], ],
+35 -5
View File
@@ -1,6 +1,6 @@
import { makeDefaultApi } from "@opencode-ai/protocol/api" import { makeDefaultApi } from "@opencode-ai/protocol/api"
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi" import { HttpApiMiddleware } from "effect/unstable/httpapi"
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()( class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
"@opencode-ai/client/LocationMiddleware", "@opencode-ai/client/LocationMiddleware",
@@ -11,11 +11,41 @@ class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocatio
{ error: [InvalidRequestError, SessionNotFoundError] }, { error: [InvalidRequestError, SessionNotFoundError] },
) {} ) {}
const Api = makeDefaultApi({ export const Api = makeDefaultApi({
locationMiddleware: LocationMiddleware, locationMiddleware: LocationMiddleware,
sessionLocationMiddleware: SessionLocationMiddleware, sessionLocationMiddleware: SessionLocationMiddleware,
}) })
export const SessionGroup = Api.groups["server.session"] export const groupNames = {
export const EventGroup = Api.groups["server.event"] "server.health": "health",
export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup) "server.location": "location",
"server.agent": "agents",
"server.session": "sessions",
"server.message": "messages",
"server.model": "models",
"server.provider": "providers",
"server.integration": "integrations",
"server.credential": "credentials",
"server.permission": "permissions",
"server.fs": "files",
"server.command": "commands",
"server.skill": "skills",
"server.event": "events",
"server.pty": "ptys",
"server.question": "questions",
"server.reference": "references",
"server.projectCopy": "projectCopies",
} as const
export const endpointNames = {
"session.messages": "list",
"integration.connect.key": "connectKey",
"integration.connect.oauth": "connectOauth",
"integration.attempt.status": "attemptStatus",
"integration.attempt.complete": "attemptComplete",
"integration.attempt.cancel": "attemptCancel",
"permission.request.list": "listRequests",
"permission.saved.list": "listSaved",
"permission.saved.remove": "removeSaved",
"question.request.list": "listRequests",
} as const
-1
View File
@@ -10,4 +10,3 @@ export { Session } from "@opencode-ai/schema/session"
export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionInput } from "@opencode-ai/schema/session-input"
export { SessionMessage } from "@opencode-ai/schema/session-message" export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Prompt } from "@opencode-ai/schema/prompt" export { Prompt } from "@opencode-ai/schema/prompt"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
+592 -116
View File
@@ -3,28 +3,47 @@ import { Effect, Stream, Schema } from "effect"
import { Sse } from "effect/unstable/encoding" import { Sse } from "effect/unstable/encoding"
import { HttpClientError } from "effect/unstable/http" import { HttpClientError } from "effect/unstable/http"
import { HttpApiClient } from "effect/unstable/httpapi" import { HttpApiClient } from "effect/unstable/httpapi"
import { ClientApi } from "../contract" import { Api } from "../contract"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
type RawClient = HttpApiClient.ForApi<typeof ClientApi> type RawClient = HttpApiClient.ForApi<typeof Api>
const mapClientError = <E>(error: E) => const mapClientError = <E>(error: E) =>
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
? new ClientError({ cause: error }) ? new ClientError({ cause: error })
: error : error
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0] const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
type Endpoint0_0Input = { raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
readonly limit?: Endpoint0_0Request["query"]["limit"] const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
readonly order?: Endpoint0_0Request["query"]["order"]
readonly search?: Endpoint0_0Request["query"]["search"] type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
readonly directory?: Endpoint0_0Request["query"]["directory"] type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
readonly project?: Endpoint0_0Request["query"]["project"] const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
readonly subpath?: Endpoint0_0Request["query"]["subpath"] raw["location.get"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
const adaptGroup1 = (raw: RawClient["server.location"]) => ({ get: Endpoint1_0(raw) })
type Endpoint2_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["location"] }
const Endpoint2_0 = (raw: RawClient["server.agent"]) => (input?: Endpoint2_0Input) =>
raw["agent.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup2 = (raw: RawClient["server.agent"]) => ({ list: Endpoint2_0(raw) })
type Endpoint3_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
type Endpoint3_0Input = {
readonly workspace?: Endpoint3_0Request["query"]["workspace"]
readonly limit?: Endpoint3_0Request["query"]["limit"]
readonly order?: Endpoint3_0Request["query"]["order"]
readonly search?: Endpoint3_0Request["query"]["search"]
readonly directory?: Endpoint3_0Request["query"]["directory"]
readonly project?: Endpoint3_0Request["query"]["project"]
readonly subpath?: Endpoint3_0Request["query"]["subpath"]
readonly cursor?: Endpoint3_0Request["query"]["cursor"]
} }
const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0Input) => const Endpoint3_0 = (raw: RawClient["server.session"]) => (input?: Endpoint3_0Input) =>
raw["session.list"]({ raw["session.list"]({
query: { query: {
workspace: input?.workspace, workspace: input?.workspace,
@@ -38,14 +57,14 @@ const Endpoint0_0 = (raw: RawClient["server.session"]) => (input?: Endpoint0_0In
}, },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0] type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
type Endpoint0_1Input = { type Endpoint3_1Input = {
readonly id?: Endpoint0_1Request["payload"]["id"] readonly id?: Endpoint3_1Request["payload"]["id"]
readonly agent?: Endpoint0_1Request["payload"]["agent"] readonly agent?: Endpoint3_1Request["payload"]["agent"]
readonly model?: Endpoint0_1Request["payload"]["model"] readonly model?: Endpoint3_1Request["payload"]["model"]
readonly location?: Endpoint0_1Request["payload"]["location"] readonly location?: Endpoint3_1Request["payload"]["location"]
} }
const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1Input) => const Endpoint3_1 = (raw: RawClient["server.session"]) => (input?: Endpoint3_1Input) =>
raw["session.create"]({ raw["session.create"]({
payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location }, payload: { id: input?.id, agent: input?.agent, model: input?.model, location: input?.location },
}).pipe( }).pipe(
@@ -53,49 +72,49 @@ const Endpoint0_1 = (raw: RawClient["server.session"]) => (input?: Endpoint0_1In
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const Endpoint0_2 = (raw: RawClient["server.session"]) => () => const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
raw["session.active"]({}).pipe( raw["session.active"]({}).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0] type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] } type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) => const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0] type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint0_4Input = { type Endpoint3_4Input = {
readonly sessionID: Endpoint0_4Request["params"]["sessionID"] readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
readonly agent: Endpoint0_4Request["payload"]["agent"] readonly agent: Endpoint3_4Request["payload"]["agent"]
} }
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) => const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe( raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint0_5Input = { type Endpoint3_5Input = {
readonly sessionID: Endpoint0_5Request["params"]["sessionID"] readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
readonly model: Endpoint0_5Request["payload"]["model"] readonly model: Endpoint3_5Request["payload"]["model"]
} }
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) => const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe( raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint0_6Input = { type Endpoint3_6Input = {
readonly sessionID: Endpoint0_6Request["params"]["sessionID"] readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
readonly id?: Endpoint0_6Request["payload"]["id"] readonly id?: Endpoint3_6Request["payload"]["id"]
readonly prompt: Endpoint0_6Request["payload"]["prompt"] readonly prompt: Endpoint3_6Request["payload"]["prompt"]
readonly delivery?: Endpoint0_6Request["payload"]["delivery"] readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
readonly resume?: Endpoint0_6Request["payload"]["resume"] readonly resume?: Endpoint3_6Request["payload"]["resume"]
} }
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) => const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume }, payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
@@ -104,23 +123,23 @@ const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] } type Endpoint3_7Input = { readonly sessionID: Endpoint3_7Request["params"]["sessionID"] }
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) => const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0] type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] } type Endpoint3_8Input = { readonly sessionID: Endpoint3_8Request["params"]["sessionID"] }
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) => const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint0_9Input = { type Endpoint3_9Input = {
readonly sessionID: Endpoint0_9Request["params"]["sessionID"] readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
readonly messageID: Endpoint0_9Request["payload"]["messageID"] readonly messageID: Endpoint3_9Request["payload"]["messageID"]
readonly files?: Endpoint0_9Request["payload"]["files"] readonly files?: Endpoint3_9Request["payload"]["files"]
} }
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) => const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input.sessionID }, params: { sessionID: input.sessionID },
payload: { messageID: input.messageID, files: input.files }, payload: { messageID: input.messageID, files: input.files },
@@ -129,42 +148,30 @@ const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] } type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) => const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] } type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) => const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] } type Endpoint3_12Input = { readonly sessionID: Endpoint3_12Request["params"]["sessionID"] }
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) => const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe( raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.history"]>[0] type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_13Input = { type Endpoint3_13Input = {
readonly sessionID: Endpoint0_13Request["params"]["sessionID"] readonly sessionID: Endpoint3_13Request["params"]["sessionID"]
readonly limit?: Endpoint0_13Request["query"]["limit"] readonly after?: Endpoint3_13Request["query"]["after"]
readonly after?: Endpoint0_13Request["query"]["after"]
} }
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
raw["session.history"]({
params: { sessionID: input.sessionID },
query: { limit: input.limit, after: input.after },
}).pipe(Effect.mapError(mapClientError))
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.events"]>[0]
type Endpoint0_14Input = {
readonly sessionID: Endpoint0_14Request["params"]["sessionID"]
readonly after?: Endpoint0_14Request["query"]["after"]
}
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
@@ -172,56 +179,525 @@ const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14I
), ),
) )
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] } type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
type Endpoint0_16Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint0_16Input = { type Endpoint3_15Input = {
readonly sessionID: Endpoint0_16Request["params"]["sessionID"] readonly sessionID: Endpoint3_15Request["params"]["sessionID"]
readonly messageID: Endpoint0_16Request["params"]["messageID"] readonly messageID: Endpoint3_15Request["params"]["messageID"]
} }
const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) => const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
const adaptGroup0 = (raw: RawClient["server.session"]) => ({ const adaptGroup3 = (raw: RawClient["server.session"]) => ({
list: Endpoint0_0(raw), list: Endpoint3_0(raw),
create: Endpoint0_1(raw), create: Endpoint3_1(raw),
active: Endpoint0_2(raw), active: Endpoint3_2(raw),
get: Endpoint0_3(raw), get: Endpoint3_3(raw),
switchAgent: Endpoint0_4(raw), switchAgent: Endpoint3_4(raw),
switchModel: Endpoint0_5(raw), switchModel: Endpoint3_5(raw),
prompt: Endpoint0_6(raw), prompt: Endpoint3_6(raw),
compact: Endpoint0_7(raw), compact: Endpoint3_7(raw),
wait: Endpoint0_8(raw), wait: Endpoint3_8(raw),
stage: Endpoint0_9(raw), stage: Endpoint3_9(raw),
clear: Endpoint0_10(raw), clear: Endpoint3_10(raw),
commit: Endpoint0_11(raw), commit: Endpoint3_11(raw),
context: Endpoint0_12(raw), context: Endpoint3_12(raw),
history: Endpoint0_13(raw), events: Endpoint3_13(raw),
events: Endpoint0_14(raw), interrupt: Endpoint3_14(raw),
interrupt: Endpoint0_15(raw), message: Endpoint3_15(raw),
message: Endpoint0_16(raw),
}) })
const Endpoint1_0 = (raw: RawClient["server.event"]) => () => type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
Stream.unwrap( type Endpoint4_0Input = {
raw["event.subscribe"]({}).pipe( readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
Effect.mapError(mapClientError), readonly limit?: Endpoint4_0Request["query"]["limit"]
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), readonly order?: Endpoint4_0Request["query"]["order"]
), readonly cursor?: Endpoint4_0Request["query"]["cursor"]
}
const Endpoint4_0 = (raw: RawClient["server.message"]) => (input: Endpoint4_0Input) =>
raw["session.messages"]({
params: { sessionID: input.sessionID },
query: { limit: input.limit, order: input.order, cursor: input.cursor },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup4 = (raw: RawClient["server.message"]) => ({ list: Endpoint4_0(raw) })
type Endpoint5_0Request = Parameters<RawClient["server.model"]["model.list"]>[0]
type Endpoint5_0Input = { readonly location?: Endpoint5_0Request["query"]["location"] }
const Endpoint5_0 = (raw: RawClient["server.model"]) => (input?: Endpoint5_0Input) =>
raw["model.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup5 = (raw: RawClient["server.model"]) => ({ list: Endpoint5_0(raw) })
type Endpoint6_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["location"] }
const Endpoint6_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint6_0Input) =>
raw["provider.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint6_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
type Endpoint6_1Input = {
readonly providerID: Endpoint6_1Request["params"]["providerID"]
readonly location?: Endpoint6_1Request["query"]["location"]
}
const Endpoint6_1 = (raw: RawClient["server.provider"]) => (input: Endpoint6_1Input) =>
raw["provider.get"]({ params: { providerID: input.providerID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
) )
const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) }) const adaptGroup6 = (raw: RawClient["server.provider"]) => ({ list: Endpoint6_0(raw), get: Endpoint6_1(raw) })
type Endpoint7_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
const Endpoint7_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint7_0Input) =>
raw["integration.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint7_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
type Endpoint7_1Input = {
readonly integrationID: Endpoint7_1Request["params"]["integrationID"]
readonly location?: Endpoint7_1Request["query"]["location"]
}
const Endpoint7_1 = (raw: RawClient["server.integration"]) => (input: Endpoint7_1Input) =>
raw["integration.get"]({ params: { integrationID: input.integrationID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint7_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
type Endpoint7_2Input = {
readonly integrationID: Endpoint7_2Request["params"]["integrationID"]
readonly location?: Endpoint7_2Request["query"]["location"]
readonly key: Endpoint7_2Request["payload"]["key"]
readonly label?: Endpoint7_2Request["payload"]["label"]
}
const Endpoint7_2 = (raw: RawClient["server.integration"]) => (input: Endpoint7_2Input) =>
raw["integration.connect.key"]({
params: { integrationID: input.integrationID },
query: { location: input.location },
payload: { key: input.key, label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
type Endpoint7_3Input = {
readonly integrationID: Endpoint7_3Request["params"]["integrationID"]
readonly location?: Endpoint7_3Request["query"]["location"]
readonly methodID: Endpoint7_3Request["payload"]["methodID"]
readonly inputs: Endpoint7_3Request["payload"]["inputs"]
readonly label?: Endpoint7_3Request["payload"]["label"]
}
const Endpoint7_3 = (raw: RawClient["server.integration"]) => (input: Endpoint7_3Input) =>
raw["integration.connect.oauth"]({
params: { integrationID: input.integrationID },
query: { location: input.location },
payload: { methodID: input.methodID, inputs: input.inputs, label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
type Endpoint7_4Input = {
readonly attemptID: Endpoint7_4Request["params"]["attemptID"]
readonly location?: Endpoint7_4Request["query"]["location"]
}
const Endpoint7_4 = (raw: RawClient["server.integration"]) => (input: Endpoint7_4Input) =>
raw["integration.attempt.status"]({
params: { attemptID: input.attemptID },
query: { location: input.location },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
type Endpoint7_5Input = {
readonly attemptID: Endpoint7_5Request["params"]["attemptID"]
readonly location?: Endpoint7_5Request["query"]["location"]
readonly code?: Endpoint7_5Request["payload"]["code"]
}
const Endpoint7_5 = (raw: RawClient["server.integration"]) => (input: Endpoint7_5Input) =>
raw["integration.attempt.complete"]({
params: { attemptID: input.attemptID },
query: { location: input.location },
payload: { code: input.code },
}).pipe(Effect.mapError(mapClientError))
type Endpoint7_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
type Endpoint7_6Input = {
readonly attemptID: Endpoint7_6Request["params"]["attemptID"]
readonly location?: Endpoint7_6Request["query"]["location"]
}
const Endpoint7_6 = (raw: RawClient["server.integration"]) => (input: Endpoint7_6Input) =>
raw["integration.attempt.cancel"]({
params: { attemptID: input.attemptID },
query: { location: input.location },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup7 = (raw: RawClient["server.integration"]) => ({
list: Endpoint7_0(raw),
get: Endpoint7_1(raw),
connectKey: Endpoint7_2(raw),
connectOauth: Endpoint7_3(raw),
attemptStatus: Endpoint7_4(raw),
attemptComplete: Endpoint7_5(raw),
attemptCancel: Endpoint7_6(raw),
})
type Endpoint8_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
type Endpoint8_0Input = {
readonly credentialID: Endpoint8_0Request["params"]["credentialID"]
readonly location?: Endpoint8_0Request["query"]["location"]
readonly label: Endpoint8_0Request["payload"]["label"]
}
const Endpoint8_0 = (raw: RawClient["server.credential"]) => (input: Endpoint8_0Input) =>
raw["credential.update"]({
params: { credentialID: input.credentialID },
query: { location: input.location },
payload: { label: input.label },
}).pipe(Effect.mapError(mapClientError))
type Endpoint8_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
type Endpoint8_1Input = {
readonly credentialID: Endpoint8_1Request["params"]["credentialID"]
readonly location?: Endpoint8_1Request["query"]["location"]
}
const Endpoint8_1 = (raw: RawClient["server.credential"]) => (input: Endpoint8_1Input) =>
raw["credential.remove"]({ params: { credentialID: input.credentialID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup8 = (raw: RawClient["server.credential"]) => ({ update: Endpoint8_0(raw), remove: Endpoint8_1(raw) })
type Endpoint9_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
const Endpoint9_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_0Input) =>
raw["permission.request.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
type Endpoint9_1Input = { readonly projectID?: Endpoint9_1Request["query"]["projectID"] }
const Endpoint9_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint9_1Input) =>
raw["permission.saved.list"]({ query: { projectID: input?.projectID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
type Endpoint9_2Input = { readonly id: Endpoint9_2Request["params"]["id"] }
const Endpoint9_2 = (raw: RawClient["server.permission"]) => (input: Endpoint9_2Input) =>
raw["permission.saved.remove"]({ params: { id: input.id } }).pipe(Effect.mapError(mapClientError))
type Endpoint9_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
type Endpoint9_3Input = {
readonly sessionID: Endpoint9_3Request["params"]["sessionID"]
readonly id?: Endpoint9_3Request["payload"]["id"]
readonly action: Endpoint9_3Request["payload"]["action"]
readonly resources: Endpoint9_3Request["payload"]["resources"]
readonly save?: Endpoint9_3Request["payload"]["save"]
readonly metadata?: Endpoint9_3Request["payload"]["metadata"]
readonly source?: Endpoint9_3Request["payload"]["source"]
readonly agent?: Endpoint9_3Request["payload"]["agent"]
}
const Endpoint9_3 = (raw: RawClient["server.permission"]) => (input: Endpoint9_3Input) =>
raw["session.permission.create"]({
params: { sessionID: input.sessionID },
payload: {
id: input.id,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
agent: input.agent,
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
type Endpoint9_4Input = { readonly sessionID: Endpoint9_4Request["params"]["sessionID"] }
const Endpoint9_4 = (raw: RawClient["server.permission"]) => (input: Endpoint9_4Input) =>
raw["session.permission.list"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
type Endpoint9_5Input = {
readonly sessionID: Endpoint9_5Request["params"]["sessionID"]
readonly requestID: Endpoint9_5Request["params"]["requestID"]
}
const Endpoint9_5 = (raw: RawClient["server.permission"]) => (input: Endpoint9_5Input) =>
raw["session.permission.get"]({ params: { sessionID: input.sessionID, requestID: input.requestID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint9_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
type Endpoint9_6Input = {
readonly sessionID: Endpoint9_6Request["params"]["sessionID"]
readonly requestID: Endpoint9_6Request["params"]["requestID"]
readonly reply: Endpoint9_6Request["payload"]["reply"]
readonly message?: Endpoint9_6Request["payload"]["message"]
}
const Endpoint9_6 = (raw: RawClient["server.permission"]) => (input: Endpoint9_6Input) =>
raw["session.permission.reply"]({
params: { sessionID: input.sessionID, requestID: input.requestID },
payload: { reply: input.reply, message: input.message },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup9 = (raw: RawClient["server.permission"]) => ({
listRequests: Endpoint9_0(raw),
listSaved: Endpoint9_1(raw),
removeSaved: Endpoint9_2(raw),
create: Endpoint9_3(raw),
list: Endpoint9_4(raw),
get: Endpoint9_5(raw),
reply: Endpoint9_6(raw),
})
type Endpoint10_0Request = Parameters<RawClient["server.fs"]["fs.read"]>[0]
type Endpoint10_0Input = { readonly location?: Endpoint10_0Request["query"]["location"] }
const Endpoint10_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_0Input) =>
raw["fs.read"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_1Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
type Endpoint10_1Input = {
readonly location?: Endpoint10_1Request["query"]["location"]
readonly path?: Endpoint10_1Request["query"]["path"]
}
const Endpoint10_1 = (raw: RawClient["server.fs"]) => (input?: Endpoint10_1Input) =>
raw["fs.list"]({ query: { location: input?.location, path: input?.path } }).pipe(Effect.mapError(mapClientError))
type Endpoint10_2Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
type Endpoint10_2Input = {
readonly location?: Endpoint10_2Request["query"]["location"]
readonly query: Endpoint10_2Request["query"]["query"]
readonly type?: Endpoint10_2Request["query"]["type"]
readonly limit?: Endpoint10_2Request["query"]["limit"]
}
const Endpoint10_2 = (raw: RawClient["server.fs"]) => (input: Endpoint10_2Input) =>
raw["fs.find"]({
query: { location: input.location, query: input.query, type: input.type, limit: input.limit },
}).pipe(Effect.mapError(mapClientError))
const adaptGroup10 = (raw: RawClient["server.fs"]) => ({
read: Endpoint10_0(raw),
list: Endpoint10_1(raw),
find: Endpoint10_2(raw),
})
type Endpoint11_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
const Endpoint11_0 = (raw: RawClient["server.command"]) => (input?: Endpoint11_0Input) =>
raw["command.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup11 = (raw: RawClient["server.command"]) => ({ list: Endpoint11_0(raw) })
type Endpoint12_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
const Endpoint12_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint12_0Input) =>
raw["skill.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup12 = (raw: RawClient["server.skill"]) => ({ list: Endpoint12_0(raw) })
const Endpoint13_0 = (raw: RawClient["server.event"]) => () =>
raw["event.subscribe"]({}).pipe(Effect.mapError(mapClientError))
const adaptGroup13 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint13_0(raw) })
type Endpoint14_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
const Endpoint14_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_0Input) =>
raw["pty.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint14_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
type Endpoint14_1Input = {
readonly location?: Endpoint14_1Request["query"]["location"]
readonly command?: Endpoint14_1Request["payload"]["command"]
readonly args?: Endpoint14_1Request["payload"]["args"]
readonly cwd?: Endpoint14_1Request["payload"]["cwd"]
readonly title?: Endpoint14_1Request["payload"]["title"]
readonly env?: Endpoint14_1Request["payload"]["env"]
}
const Endpoint14_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint14_1Input) =>
raw["pty.create"]({
query: { location: input?.location },
payload: { command: input?.command, args: input?.args, cwd: input?.cwd, title: input?.title, env: input?.env },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
type Endpoint14_2Input = {
readonly ptyID: Endpoint14_2Request["params"]["ptyID"]
readonly location?: Endpoint14_2Request["query"]["location"]
}
const Endpoint14_2 = (raw: RawClient["server.pty"]) => (input: Endpoint14_2Input) =>
raw["pty.get"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
type Endpoint14_3Input = {
readonly ptyID: Endpoint14_3Request["params"]["ptyID"]
readonly location?: Endpoint14_3Request["query"]["location"]
readonly title?: Endpoint14_3Request["payload"]["title"]
readonly size?: Endpoint14_3Request["payload"]["size"]
}
const Endpoint14_3 = (raw: RawClient["server.pty"]) => (input: Endpoint14_3Input) =>
raw["pty.update"]({
params: { ptyID: input.ptyID },
query: { location: input.location },
payload: { title: input.title, size: input.size },
}).pipe(Effect.mapError(mapClientError))
type Endpoint14_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
type Endpoint14_4Input = {
readonly ptyID: Endpoint14_4Request["params"]["ptyID"]
readonly location?: Endpoint14_4Request["query"]["location"]
}
const Endpoint14_4 = (raw: RawClient["server.pty"]) => (input: Endpoint14_4Input) =>
raw["pty.remove"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_5Request = Parameters<RawClient["server.pty"]["pty.connectToken"]>[0]
type Endpoint14_5Input = {
readonly ptyID: Endpoint14_5Request["params"]["ptyID"]
readonly location?: Endpoint14_5Request["query"]["location"]
}
const Endpoint14_5 = (raw: RawClient["server.pty"]) => (input: Endpoint14_5Input) =>
raw["pty.connectToken"]({ params: { ptyID: input.ptyID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
type Endpoint14_6Request = Parameters<RawClient["server.pty"]["pty.connect"]>[0]
type Endpoint14_6Input = { readonly ptyID: Endpoint14_6Request["params"]["ptyID"] }
const Endpoint14_6 = (raw: RawClient["server.pty"]) => (input: Endpoint14_6Input) =>
raw["pty.connect"]({ params: { ptyID: input.ptyID } }).pipe(Effect.mapError(mapClientError))
const adaptGroup14 = (raw: RawClient["server.pty"]) => ({
list: Endpoint14_0(raw),
create: Endpoint14_1(raw),
get: Endpoint14_2(raw),
update: Endpoint14_3(raw),
remove: Endpoint14_4(raw),
connectToken: Endpoint14_5(raw),
connect: Endpoint14_6(raw),
})
type Endpoint15_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
const Endpoint15_0 = (raw: RawClient["server.question"]) => (input?: Endpoint15_0Input) =>
raw["question.request.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
type Endpoint15_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
type Endpoint15_1Input = { readonly sessionID: Endpoint15_1Request["params"]["sessionID"] }
const Endpoint15_1 = (raw: RawClient["server.question"]) => (input: Endpoint15_1Input) =>
raw["session.question.list"]({ params: { sessionID: input.sessionID } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint15_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
type Endpoint15_2Input = {
readonly sessionID: Endpoint15_2Request["params"]["sessionID"]
readonly requestID: Endpoint15_2Request["params"]["requestID"]
readonly answers: Endpoint15_2Request["payload"]["answers"]
}
const Endpoint15_2 = (raw: RawClient["server.question"]) => (input: Endpoint15_2Input) =>
raw["session.question.reply"]({
params: { sessionID: input.sessionID, requestID: input.requestID },
payload: { answers: input.answers },
}).pipe(Effect.mapError(mapClientError))
type Endpoint15_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
type Endpoint15_3Input = {
readonly sessionID: Endpoint15_3Request["params"]["sessionID"]
readonly requestID: Endpoint15_3Request["params"]["requestID"]
}
const Endpoint15_3 = (raw: RawClient["server.question"]) => (input: Endpoint15_3Input) =>
raw["session.question.reject"]({ params: { sessionID: input.sessionID, requestID: input.requestID } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup15 = (raw: RawClient["server.question"]) => ({
listRequests: Endpoint15_0(raw),
list: Endpoint15_1(raw),
reply: Endpoint15_2(raw),
reject: Endpoint15_3(raw),
})
type Endpoint16_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
const Endpoint16_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint16_0Input) =>
raw["reference.list"]({ query: { location: input?.location } }).pipe(Effect.mapError(mapClientError))
const adaptGroup16 = (raw: RawClient["server.reference"]) => ({ list: Endpoint16_0(raw) })
type Endpoint17_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
type Endpoint17_0Input = {
readonly projectID: Endpoint17_0Request["params"]["projectID"]
readonly location?: Endpoint17_0Request["query"]["location"]
readonly strategy: Endpoint17_0Request["payload"]["strategy"]
readonly directory: Endpoint17_0Request["payload"]["directory"]
readonly name?: Endpoint17_0Request["payload"]["name"]
}
const Endpoint17_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_0Input) =>
raw["projectCopy.create"]({
params: { projectID: input.projectID },
query: { location: input.location },
payload: { strategy: input.strategy, directory: input.directory, name: input.name },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
type Endpoint17_1Input = {
readonly projectID: Endpoint17_1Request["params"]["projectID"]
readonly location?: Endpoint17_1Request["query"]["location"]
readonly directory: Endpoint17_1Request["payload"]["directory"]
readonly force: Endpoint17_1Request["payload"]["force"]
}
const Endpoint17_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_1Input) =>
raw["projectCopy.remove"]({
params: { projectID: input.projectID },
query: { location: input.location },
payload: { directory: input.directory, force: input.force },
}).pipe(Effect.mapError(mapClientError))
type Endpoint17_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
type Endpoint17_2Input = {
readonly projectID: Endpoint17_2Request["params"]["projectID"]
readonly location?: Endpoint17_2Request["query"]["location"]
}
const Endpoint17_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint17_2Input) =>
raw["projectCopy.refresh"]({ params: { projectID: input.projectID }, query: { location: input.location } }).pipe(
Effect.mapError(mapClientError),
)
const adaptGroup17 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint17_0(raw),
remove: Endpoint17_1(raw),
refresh: Endpoint17_2(raw),
})
const adaptClient = (raw: RawClient) => ({ const adaptClient = (raw: RawClient) => ({
sessions: adaptGroup0(raw["server.session"]), health: adaptGroup0(raw["server.health"]),
events: adaptGroup1(raw["server.event"]), location: adaptGroup1(raw["server.location"]),
agents: adaptGroup2(raw["server.agent"]),
sessions: adaptGroup3(raw["server.session"]),
messages: adaptGroup4(raw["server.message"]),
models: adaptGroup5(raw["server.model"]),
providers: adaptGroup6(raw["server.provider"]),
integrations: adaptGroup7(raw["server.integration"]),
credentials: adaptGroup8(raw["server.credential"]),
permissions: adaptGroup9(raw["server.permission"]),
files: adaptGroup10(raw["server.fs"]),
commands: adaptGroup11(raw["server.command"]),
skills: adaptGroup12(raw["server.skill"]),
events: adaptGroup13(raw["server.event"]),
ptys: adaptGroup14(raw["server.pty"]),
questions: adaptGroup15(raw["server.question"]),
references: adaptGroup16(raw["server.reference"]),
projectCopies: adaptGroup17(raw["server.projectCopy"]),
}) })
export const make = (options?: { readonly baseUrl?: URL | string }) => export const make = (options?: { readonly baseUrl?: URL | string }) =>
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient)) HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
+719 -17
View File
@@ -1,4 +1,9 @@
import type { import type {
HealthGetOutput,
LocationGetInput,
LocationGetOutput,
AgentsListInput,
AgentsListOutput,
SessionsListInput, SessionsListInput,
SessionsListOutput, SessionsListOutput,
SessionsCreateInput, SessionsCreateInput,
@@ -24,15 +29,93 @@ import type {
SessionsCommitOutput, SessionsCommitOutput,
SessionsContextInput, SessionsContextInput,
SessionsContextOutput, SessionsContextOutput,
SessionsHistoryInput,
SessionsHistoryOutput,
SessionsEventsInput, SessionsEventsInput,
SessionsEventsOutput, SessionsEventsOutput,
SessionsInterruptInput, SessionsInterruptInput,
SessionsInterruptOutput, SessionsInterruptOutput,
SessionsMessageInput, SessionsMessageInput,
SessionsMessageOutput, SessionsMessageOutput,
MessagesListInput,
MessagesListOutput,
ModelsListInput,
ModelsListOutput,
ProvidersListInput,
ProvidersListOutput,
ProvidersGetInput,
ProvidersGetOutput,
IntegrationsListInput,
IntegrationsListOutput,
IntegrationsGetInput,
IntegrationsGetOutput,
IntegrationsConnectKeyInput,
IntegrationsConnectKeyOutput,
IntegrationsConnectOauthInput,
IntegrationsConnectOauthOutput,
IntegrationsAttemptStatusInput,
IntegrationsAttemptStatusOutput,
IntegrationsAttemptCompleteInput,
IntegrationsAttemptCompleteOutput,
IntegrationsAttemptCancelInput,
IntegrationsAttemptCancelOutput,
CredentialsUpdateInput,
CredentialsUpdateOutput,
CredentialsRemoveInput,
CredentialsRemoveOutput,
PermissionsListRequestsInput,
PermissionsListRequestsOutput,
PermissionsListSavedInput,
PermissionsListSavedOutput,
PermissionsRemoveSavedInput,
PermissionsRemoveSavedOutput,
PermissionsCreateInput,
PermissionsCreateOutput,
PermissionsListInput,
PermissionsListOutput,
PermissionsGetInput,
PermissionsGetOutput,
PermissionsReplyInput,
PermissionsReplyOutput,
FilesReadInput,
FilesReadOutput,
FilesListInput,
FilesListOutput,
FilesFindInput,
FilesFindOutput,
CommandsListInput,
CommandsListOutput,
SkillsListInput,
SkillsListOutput,
EventsSubscribeOutput, EventsSubscribeOutput,
PtysListInput,
PtysListOutput,
PtysCreateInput,
PtysCreateOutput,
PtysGetInput,
PtysGetOutput,
PtysUpdateInput,
PtysUpdateOutput,
PtysRemoveInput,
PtysRemoveOutput,
PtysConnectTokenInput,
PtysConnectTokenOutput,
PtysConnectInput,
PtysConnectOutput,
QuestionsListRequestsInput,
QuestionsListRequestsOutput,
QuestionsListInput,
QuestionsListOutput,
QuestionsReplyInput,
QuestionsReplyOutput,
QuestionsRejectInput,
QuestionsRejectOutput,
ReferencesListInput,
ReferencesListOutput,
ProjectCopiesCreateInput,
ProjectCopiesCreateOutput,
ProjectCopiesRemoveInput,
ProjectCopiesRemoveOutput,
ProjectCopiesRefreshInput,
ProjectCopiesRefreshOutput,
} from "./types" } from "./types"
import { ClientError } from "./client-error" import { ClientError } from "./client-error"
@@ -56,6 +139,7 @@ interface RequestDescriptor {
readonly successStatus: number readonly successStatus: number
readonly declaredStatuses: ReadonlyArray<number> readonly declaredStatuses: ReadonlyArray<number>
readonly empty: boolean readonly empty: boolean
readonly binary: boolean
} }
export function make(options: ClientOptions) { export function make(options: ClientOptions) {
@@ -107,6 +191,13 @@ export function make(options: ClientOptions) {
} catch {} } catch {}
return undefined as A return undefined as A
} }
if (descriptor.binary) {
try {
return new Uint8Array(await response.arrayBuffer()) as A
} catch (cause) {
throw new ClientError("Transport", { cause })
}
}
return (await json(response)) as A return (await json(response)) as A
} }
@@ -168,6 +259,50 @@ export function make(options: ClientOptions) {
}) })
return { return {
health: {
get: (requestOptions?: RequestOptions) =>
request<HealthGetOutput>(
{
method: "GET",
path: `/api/health`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
location: {
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
request<LocationGetOutput>(
{
method: "GET",
path: `/api/location`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
agents: {
list: (input?: AgentsListInput, requestOptions?: RequestOptions) =>
request<AgentsListOutput>(
{
method: "GET",
path: `/api/agent`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
sessions: { sessions: {
list: (input?: SessionsListInput, requestOptions?: RequestOptions) => list: (input?: SessionsListInput, requestOptions?: RequestOptions) =>
request<SessionsListOutput>( request<SessionsListOutput>(
@@ -187,6 +322,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [400, 401], declaredStatuses: [400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -199,6 +335,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -210,6 +347,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [401, 400], declaredStatuses: [401, 400],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -221,6 +359,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -233,6 +372,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -245,6 +385,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -257,6 +398,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [409, 404, 400, 401], declaredStatuses: [409, 404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -268,6 +410,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 503, 400, 401], declaredStatuses: [404, 503, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -279,6 +422,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 503, 400, 401], declaredStatuses: [404, 503, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -291,6 +435,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
@@ -302,6 +447,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -313,6 +459,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -324,21 +471,10 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 500, 400, 401], declaredStatuses: [404, 500, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) =>
request<SessionsHistoryOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/history`,
query: { limit: input.limit, after: input.after },
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
),
events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> => events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable<SessionsEventsOutput> =>
sse<SessionsEventsOutput>( sse<SessionsEventsOutput>(
{ {
@@ -348,6 +484,7 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -359,6 +496,7 @@ export function make(options: ClientOptions) {
successStatus: 204, successStatus: 204,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: true, empty: true,
binary: false,
}, },
requestOptions, requestOptions,
), ),
@@ -370,14 +508,578 @@ export function make(options: ClientOptions) {
successStatus: 200, successStatus: 200,
declaredStatuses: [404, 400, 401], declaredStatuses: [404, 400, 401],
empty: false, empty: false,
binary: false,
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
}, },
messages: {
list: (input: MessagesListInput, requestOptions?: RequestOptions) =>
request<MessagesListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/message`,
query: { limit: input.limit, order: input.order, cursor: input.cursor },
successStatus: 200,
declaredStatuses: [400, 404, 500, 401],
empty: false,
binary: false,
},
requestOptions,
),
},
models: {
list: (input?: ModelsListInput, requestOptions?: RequestOptions) =>
request<ModelsListOutput>(
{
method: "GET",
path: `/api/model`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
providers: {
list: (input?: ProvidersListInput, requestOptions?: RequestOptions) =>
request<ProvidersListOutput>(
{
method: "GET",
path: `/api/provider`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
get: (input: ProvidersGetInput, requestOptions?: RequestOptions) =>
request<ProvidersGetOutput>(
{
method: "GET",
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
integrations: {
list: (input?: IntegrationsListInput, requestOptions?: RequestOptions) =>
request<IntegrationsListOutput>(
{
method: "GET",
path: `/api/integration`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
get: (input: IntegrationsGetInput, requestOptions?: RequestOptions) =>
request<IntegrationsGetOutput>(
{
method: "GET",
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
connectKey: (input: IntegrationsConnectKeyInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectKeyOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/key`,
query: { location: input.location },
body: { key: input.key, label: input.label },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
connectOauth: (input: IntegrationsConnectOauthInput, requestOptions?: RequestOptions) =>
request<IntegrationsConnectOauthOutput>(
{
method: "POST",
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth`,
query: { location: input.location },
body: { methodID: input.methodID, inputs: input.inputs, label: input.label },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
attemptStatus: (input: IntegrationsAttemptStatusInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptStatusOutput>(
{
method: "GET",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
attemptComplete: (input: IntegrationsAttemptCompleteInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCompleteOutput>(
{
method: "POST",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}/complete`,
query: { location: input.location },
body: { code: input.code },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
attemptCancel: (input: IntegrationsAttemptCancelInput, requestOptions?: RequestOptions) =>
request<IntegrationsAttemptCancelOutput>(
{
method: "DELETE",
path: `/api/integration/attempt/${encodeURIComponent(input.attemptID)}`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
},
credentials: {
update: (input: CredentialsUpdateInput, requestOptions?: RequestOptions) =>
request<CredentialsUpdateOutput>(
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input.location },
body: { label: input.label },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
remove: (input: CredentialsRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialsRemoveOutput>(
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
},
permissions: {
listRequests: (input?: PermissionsListRequestsInput, requestOptions?: RequestOptions) =>
request<PermissionsListRequestsOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
listSaved: (input?: PermissionsListSavedInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListSavedOutput }>(
{
method: "GET",
path: `/api/permission/saved`,
query: { projectID: input?.projectID },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
removeSaved: (input: PermissionsRemoveSavedInput, requestOptions?: RequestOptions) =>
request<PermissionsRemoveSavedOutput>(
{
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
binary: false,
},
requestOptions,
),
create: (input: PermissionsCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
body: {
id: input.id,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
agent: input.agent,
},
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
list: (input: PermissionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
get: (input: PermissionsGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionsGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: PermissionsReplyInput, requestOptions?: RequestOptions) =>
request<PermissionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input.reply, message: input.message },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
},
files: {
read: (input?: FilesReadInput, requestOptions?: RequestOptions) =>
request<FilesReadOutput>(
{
method: "GET",
path: `/api/fs/read/*`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: true,
},
requestOptions,
),
list: (input?: FilesListInput, requestOptions?: RequestOptions) =>
request<FilesListOutput>(
{
method: "GET",
path: `/api/fs/list`,
query: { location: input?.location, path: input?.path },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
find: (input: FilesFindInput, requestOptions?: RequestOptions) =>
request<FilesFindOutput>(
{
method: "GET",
path: `/api/fs/find`,
query: { location: input.location, query: input.query, type: input.type, limit: input.limit },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
commands: {
list: (input?: CommandsListInput, requestOptions?: RequestOptions) =>
request<CommandsListOutput>(
{
method: "GET",
path: `/api/command`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
skills: {
list: (input?: SkillsListInput, requestOptions?: RequestOptions) =>
request<SkillsListOutput>(
{
method: "GET",
path: `/api/skill`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
events: { events: {
subscribe: (requestOptions?: RequestOptions): AsyncIterable<EventsSubscribeOutput> => subscribe: (requestOptions?: RequestOptions) =>
sse<EventsSubscribeOutput>( request<EventsSubscribeOutput>(
{ method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, {
method: "GET",
path: `/api/event`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
ptys: {
list: (input?: PtysListInput, requestOptions?: RequestOptions) =>
request<PtysListOutput>(
{
method: "GET",
path: `/api/pty`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
create: (input?: PtysCreateInput, requestOptions?: RequestOptions) =>
request<PtysCreateOutput>(
{
method: "POST",
path: `/api/pty`,
query: { location: input?.location },
body: { command: input?.command, args: input?.args, cwd: input?.cwd, title: input?.title, env: input?.env },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
get: (input: PtysGetInput, requestOptions?: RequestOptions) =>
request<PtysGetOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
update: (input: PtysUpdateInput, requestOptions?: RequestOptions) =>
request<PtysUpdateOutput>(
{
method: "PUT",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input.location },
body: { title: input.title, size: input.size },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
remove: (input: PtysRemoveInput, requestOptions?: RequestOptions) =>
request<PtysRemoveOutput>(
{
method: "DELETE",
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
binary: false,
},
requestOptions,
),
connectToken: (input: PtysConnectTokenInput, requestOptions?: RequestOptions) =>
request<PtysConnectTokenOutput>(
{
method: "POST",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect-token`,
query: { location: input.location },
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
connect: (input: PtysConnectInput, requestOptions?: RequestOptions) =>
request<PtysConnectOutput>(
{
method: "GET",
path: `/api/pty/${encodeURIComponent(input.ptyID)}/connect`,
successStatus: 200,
declaredStatuses: [403, 404, 401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
questions: {
listRequests: (input?: QuestionsListRequestsInput, requestOptions?: RequestOptions) =>
request<QuestionsListRequestsOutput>(
{
method: "GET",
path: `/api/question/request`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
list: (input: QuestionsListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: QuestionsListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
binary: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: QuestionsReplyInput, requestOptions?: RequestOptions) =>
request<QuestionsReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
body: { answers: input.answers },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
reject: (input: QuestionsRejectInput, requestOptions?: RequestOptions) =>
request<QuestionsRejectOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
binary: false,
},
requestOptions,
),
},
references: {
list: (input?: ReferencesListInput, requestOptions?: RequestOptions) =>
request<ReferencesListOutput>(
{
method: "GET",
path: `/api/reference`,
query: { location: input?.location },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
binary: false,
},
requestOptions,
),
},
projectCopies: {
create: (input: ProjectCopiesCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesCreateOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input.location },
body: { strategy: input.strategy, directory: input.directory, name: input.name },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
binary: false,
},
requestOptions,
),
remove: (input: ProjectCopiesRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRemoveOutput>(
{
method: "DELETE",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input.location },
body: { directory: input.directory, force: input.force },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions,
),
refresh: (input: ProjectCopiesRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopiesRefreshOutput>(
{
method: "POST",
path: `/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input.location },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
binary: false,
},
requestOptions, requestOptions,
), ),
}, },
File diff suppressed because it is too large Load Diff
-1
View File
@@ -1,2 +1 @@
export * from "./generated/index" export * from "./generated/index"
export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types"
@@ -19,8 +19,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace" import { Workspace } from "@opencode-ai/schema/workspace"
import { Api } from "@opencode-ai/server/api" import { Api } from "@opencode-ai/server/api"
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
import { HttpApi } from "effect/unstable/httpapi" import { Api as ClientApi, endpointNames, groupNames } from "../src/contract"
import { EventGroup, SessionGroup } from "../src/contract"
test("Core and Server reuse the authoritative Schema and Protocol values", () => { test("Core and Server reuse the authoritative Schema and Protocol values", () => {
expect(AgentV2.ID).toBe(Agent.ID) expect(AgentV2.ID).toBe(Agent.ID)
@@ -31,18 +30,16 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message) expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
expect(CorePrompt).toBe(Prompt) expect(CorePrompt).toBe(Prompt)
expect(Api.groups["server.session"].identifier).toBe("server.session") expect(Api.groups["server.session"].identifier).toBe("server.session")
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier) expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier)
expect(Session.ID.create()).toStartWith("ses_") expect(Session.ID.create()).toStartWith("ses_")
expect(Project.ID.global).toBe("global") expect(Project.ID.global).toBe("global")
expect(Provider.ID.anthropic).toBe("anthropic") expect(Provider.ID.anthropic).toBe("anthropic")
expect(Workspace.ID.create()).toStartWith("wrk_") expect(Workspace.ID.create()).toStartWith("wrk_")
}) })
test("client and Server Session contracts generate identically", () => { test("client and Server contracts generate identically", () => {
const options = { groupNames: { "server.session": "sessions" } } const server = compile(Api, { groupNames, endpointNames })
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options) const client = compile(ClientApi, { groupNames, endpointNames })
const client = compile(HttpApi.make("client").add(SessionGroup), options)
expect(emitPromise(client)).toEqual(emitPromise(server)) expect(emitPromise(client)).toEqual(emitPromise(server))
}) })
+1 -100
View File
@@ -15,53 +15,7 @@ test("sessions.get returns the decoded Effect projection", async () => {
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
}) })
test("events.subscribe exposes and decodes the native Effect event stream", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
),
),
)
const events = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"])
const durable = events[1]
if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event")
expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000)
expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 })
})
test("events.subscribe terminates on Effect protocol decode failures", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response(`data: {"type":"server.connected"}\n\n`, {
headers: { "content-type": "text/event-stream" },
}),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("ClientError")
})
test("session methods retain decoded Effect inputs and outputs", async () => { test("session methods retain decoded Effect inputs and outputs", async () => {
const historyQueries: Array<Record<string, string>> = []
let historyPage = 0
const httpClient = HttpClient.make((request) => { const httpClient = HttpClient.make((request) => {
const url = request.url const url = request.url
if (url.includes("/event")) { if (url.includes("/event")) {
@@ -74,18 +28,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
), ),
) )
} }
if (url.includes("/history")) {
historyPage++
historyQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
),
),
)
}
if (url.includes("/prompt")) { if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
} }
@@ -130,18 +72,6 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") })
yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") })
const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") })
const history = yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: 0,
limit: 1,
})
const historyNext = history.hasMore
? yield* client.sessions.history({
sessionID: Session.ID.make("ses_test"),
after: history.data.at(-1)?.durable?.seq,
limit: 2,
})
: undefined
const events = yield* client.sessions const events = yield* client.sessions
.events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .events({ sessionID: Session.ID.make("ses_test"), after: 0 })
.pipe(Stream.runCollect) .pipe(Stream.runCollect)
@@ -150,7 +80,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
messageID: SessionMessage.ID.make("msg_model"), messageID: SessionMessage.ID.make("msg_model"),
}) })
return { page, active, created, admitted, context, history, historyNext, events, message } return { page, active, created, admitted, context, events, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
@@ -162,39 +92,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.history).toEqual(expect.objectContaining({ hasMore: true }))
expect(result.historyNext).toEqual({ data: [], hasMore: false })
expect(historyQueries[0]).toEqual({ limit: "1", after: "0" })
expect(historyQueries[1]).toEqual({ limit: "2", after: "1" })
expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
}) })
test("sessions.history retains the typed SessionNotFoundError", async () => {
const httpClient = HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
),
),
)
const error = await Effect.gen(function* () {
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
return yield* client.sessions
.history({
sessionID: Session.ID.make("ses_missing"),
})
.pipe(Effect.flip)
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(error._tag).toBe("SessionNotFoundError")
})
const session = { const session = {
data: { data: {
id: "ses_test", id: "ses_test",
+47 -66
View File
@@ -1,5 +1,49 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" import { isUnauthorizedError, OpenCode } from "../src"
test("exposes every authoritative API group", () => {
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
expect(Object.keys(client)).toEqual([
"health",
"location",
"agents",
"sessions",
"messages",
"models",
"providers",
"integrations",
"credentials",
"permissions",
"files",
"commands",
"skills",
"events",
"ptys",
"questions",
"references",
"projectCopies",
])
expect(Object.keys(client.messages)).toEqual(["list"])
expect(Object.keys(client.integrations)).toEqual([
"list",
"get",
"connectKey",
"connectOauth",
"attemptStatus",
"attemptComplete",
"attemptCancel",
])
expect(Object.keys(client.permissions)).toEqual([
"listRequests",
"listSaved",
"removeSaved",
"create",
"list",
"get",
"reply",
])
})
test("sessions.get returns the wire projection", async () => { test("sessions.get returns the wire projection", async () => {
const client = OpenCode.make({ const client = OpenCode.make({
@@ -17,38 +61,8 @@ test("sessions.get returns the wire projection", async () => {
expect(result.time.created).toBe(1_717_171_717_000) expect(result.time.created).toBe(1_717_171_717_000)
}) })
test("events.subscribe exposes the Promise event stream wire projection", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
new Response(
`: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` +
`data: ${JSON.stringify(modelSwitchedEvent)}\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
})
const events = []
for await (const event of client.events.subscribe()) events.push(event)
expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent])
expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000)
})
test("events.subscribe terminates on malformed Promise SSE data", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }),
})
await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
name: "ClientError",
reason: "MalformedResponse",
})
})
test("session methods use the public HTTP contract", async () => { test("session methods use the public HTTP contract", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [] const requests: Array<{ url: string; init?: RequestInit }> = []
let historyPage = 0
const client = OpenCode.make({ const client = OpenCode.make({
baseUrl: "http://localhost:3000", baseUrl: "http://localhost:3000",
fetch: async (input, init) => { fetch: async (input, init) => {
@@ -59,12 +73,6 @@ test("session methods use the public HTTP contract", async () => {
headers: { "content-type": "text/event-stream" }, headers: { "content-type": "text/event-stream" },
}) })
} }
if (url.includes("/history")) {
historyPage++
return Response.json(
historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false },
)
}
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
@@ -75,7 +83,7 @@ test("session methods use the public HTTP contract", async () => {
}, },
}) })
const page = await client.sessions.list({ limit: 10, order: "desc" }) const page = await client.sessions.list({ limit: "10", order: "desc" })
const active = await client.sessions.active() const active = await client.sessions.active()
const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
@@ -91,13 +99,8 @@ test("session methods use the public HTTP contract", async () => {
await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.compact({ sessionID: "ses_test" })
await client.sessions.wait({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" })
const context = await client.sessions.context({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" })
const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 })
const historyAfter = history.data.at(-1)?.durable?.seq
const historyNext = history.hasMore
? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
: undefined
const events = [] const events = []
for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event) for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
await client.sessions.interrupt({ sessionID: "ses_test" }) await client.sessions.interrupt({ sessionID: "ses_test" })
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
@@ -106,8 +109,6 @@ test("session methods use the public HTTP contract", async () => {
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
expect(context).toEqual([]) expect(context).toEqual([])
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
expect(historyNext).toEqual({ data: [], hasMore: false })
expect(events).toEqual([modelSwitchedEvent]) expect(events).toEqual([modelSwitchedEvent])
expect(message).toEqual(modelSwitchedMessage) expect(message).toEqual(modelSwitchedMessage)
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
@@ -120,8 +121,6 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/compact"],
["POST", "http://localhost:3000/api/session/ses_test/wait"], ["POST", "http://localhost:3000/api/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"], ["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"],
["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"],
["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
@@ -149,24 +148,6 @@ test("middleware errors remain declared client errors", async () => {
} }
}) })
test("sessions.history decodes SessionNotFoundError", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async () =>
Response.json(
{ _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" },
{ status: 404 },
),
})
try {
await client.sessions.history({ sessionID: "ses_missing" })
throw new Error("Expected request to fail")
} catch (error) {
expect(isSessionNotFoundError(error)).toBe(true)
}
})
const session = { const session = {
data: { data: {
id: "ses_test", id: "ses_test",
@@ -215,16 +215,6 @@ export async function handler(
body: reqBody, body: reqBody,
}) })
if (providerInfo.id.startsWith("console.")) {
const resEndpointId = res.headers.get("x-opencode-endpoint-id")
const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id")
if (resEndpointId && resEndpointModelId)
logger.metric({
provider: resEndpointId,
"provider.model": resEndpointModelId,
})
}
if (res.status !== 200) { if (res.status !== 200) {
logger.metric({ logger.metric({
"llm.error.code": res.status, "llm.error.code": res.status,
+5
View File
@@ -12,13 +12,18 @@
"@cloudflare/workers-types": "catalog:", "@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/node": "catalog:", "@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:", "typescript": "catalog:",
"@typescript/native-preview": "catalog:" "@typescript/native-preview": "catalog:"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"zod": "catalog:" "zod": "catalog:"
} }
} }
+1 -2
View File
@@ -16,8 +16,6 @@
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"exports": { "exports": {
"./effect/layer-node": "./src/effect/layer-node/index.ts",
"./effect/node": "./src/effect/node.ts",
"./session/runner": "./src/session/runner/index.ts", "./session/runner": "./src/session/runner/index.ts",
"./system-context": "./src/system-context/index.ts", "./system-context": "./src/system-context/index.ts",
"./*": "./src/*.ts" "./*": "./src/*.ts"
@@ -104,6 +102,7 @@
"ai-gateway-provider": "3.1.2", "ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8", "bun-pty": "0.4.8",
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
"diff": "catalog:",
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
-3
View File
@@ -1,6 +1,5 @@
export * as AgentV2 from "./agent" export * as AgentV2 from "./agent"
import { makeLocationNode } from "./effect/node"
import { Array, Context, Effect, Layer, Types } from "effect" import { Array, Context, Effect, Layer, Types } from "effect"
import { Agent } from "@opencode-ai/schema/agent" import { Agent } from "@opencode-ai/schema/agent"
import { State } from "./state" import { State } from "./state"
@@ -107,5 +106,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
-3
View File
@@ -1,6 +1,5 @@
export * as AISDK from "./aisdk" export * as AISDK from "./aisdk"
import { makeLocationNode } from "./effect/node"
import type { LanguageModelV3 } from "@ai-sdk/provider" import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -232,6 +231,4 @@ export const locationLayer = Layer.effect(
}), }),
) )
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
export const defaultLayer = locationLayer export const defaultLayer = locationLayer
-3
View File
@@ -2,7 +2,6 @@ export * as BackgroundJob from "./background-job"
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
import { Identifier } from "./id/id" import { Identifier } from "./id/id"
import { makeGlobalNode } from "./effect/node"
export type Status = "running" | "completed" | "error" | "cancelled" export type Status = "running" | "completed" | "error" | "cancelled"
@@ -363,5 +362,3 @@ export const make = Effect.gen(function* () {
export const layer = Layer.effect(Service, make) export const layer = Layer.effect(Service, make)
export const defaultLayer = layer export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
-3
View File
@@ -1,6 +1,5 @@
export * as Catalog from "./catalog" export * as Catalog from "./catalog"
import { makeLocationNode } from "./effect/node"
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
import { Catalog } from "@opencode-ai/schema/catalog" import { Catalog } from "@opencode-ai/schema/catalog"
import { ModelV2 } from "./model" import { ModelV2 } from "./model"
@@ -297,5 +296,3 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Policy.locationLayer), Layer.provideMerge(Policy.locationLayer),
) )
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
-3
View File
@@ -1,6 +1,5 @@
export * as CommandV2 from "./command" export * as CommandV2 from "./command"
import { makeLocationNode } from "./effect/node"
import { Context, Effect, Layer, Types } from "effect" import { Context, Effect, Layer, Types } from "effect"
import { Command } from "@opencode-ai/schema/command" import { Command } from "@opencode-ai/schema/command"
import { State } from "./state" import { State } from "./state"
@@ -60,5 +59,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [] })
-7
View File
@@ -1,6 +1,5 @@
export * as Config from "./config" export * as Config from "./config"
import { makeLocationNode } from "./effect/node"
import path from "path" import path from "path"
import { type ParseError, parse } from "jsonc-parser" import { type ParseError, parse } from "jsonc-parser"
import { Context, Effect, Layer, Option, Schema } from "effect" import { Context, Effect, Layer, Option, Schema } from "effect"
@@ -219,9 +218,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Location.node, Policy.node],
})
-3
View File
@@ -5,7 +5,6 @@ import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential" import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration" import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database" import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/node"
import { CredentialTable } from "./credential/sql" import { CredentialTable } from "./credential/sql"
export const ID = Credential.ID export const ID = Credential.ID
@@ -136,5 +135,3 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
+2 -2
View File
@@ -24,7 +24,7 @@ import {
import * as NodeChildProcess from "node:child_process" import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream" import { PassThrough } from "node:stream"
import launch from "cross-spawn" import launch from "cross-spawn"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { filesystem, path } from "./effect/layer-node-platform" import { filesystem, path } from "./effect/layer-node-platform"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
@@ -503,6 +503,6 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
) )
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] }) export const node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
export * as CrossSpawnSpawner from "./cross-spawn-spawner" export * as CrossSpawnSpawner from "./cross-spawn-spawner"
+2 -2
View File
@@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path" import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration" import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version" import { InstallationChannel } from "../installation/version"
import { makeGlobalNode } from "../effect/node" import { LayerNode } from "../effect/layer-node"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults() const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase> type DatabaseShape = Effect.Success<typeof makeDatabase>
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
}), }),
).pipe(Layer.provide(Global.defaultLayer)) ).pipe(Layer.provide(Global.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
@@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { FileSystem, Path } from "effect" import { FileSystem, Path } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { HttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http"
import { makeGlobalNode } from "./node" import { LayerNode } from "./layer-node"
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] }) export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] }) export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] }) export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
export const requestExecutor = makeGlobalNode({ export const requestExecutor = LayerNode.make({
service: RequestExecutor.Service, service: RequestExecutor.Service,
layer: RequestExecutor.layer, layer: RequestExecutor.layer,
deps: [httpClient], deps: [httpClient],
}) })
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] }) export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
export * as LayerNodePlatform from "./layer-node-platform" export * as LayerNodePlatform from "./layer-node-platform"
+248
View File
@@ -0,0 +1,248 @@
import { Brand, Context, Layer } from "effect"
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
type AnyNode = Node<unknown, unknown, any>
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
Missing<Layer.Services<Implementation>, Dependencies>,
] extends [never]
? unknown
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
declare const $OutputType: unique symbol
declare const $ErrorType: unique symbol
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
const makeTier = Brand.nominal<Tier>()
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
readonly kind: "layer" | "group"
readonly name: string
readonly service?: Context.Service.Any
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly tier?: T
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
type NodeIdentity =
| { readonly service: Context.Service.Any; readonly name?: never }
| { readonly name: string; readonly service?: never }
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
type NodeInput<
Implementation extends Layer.Any,
Items extends NodeList,
T extends Tier | undefined = undefined,
> = NodeIdentity & {
readonly layer: Implementation
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
readonly tier?: T
}
export function make<
const Implementation extends Layer.Any,
const Items extends NodeList,
const T extends Tier | undefined = undefined,
>(
input: NodeInput<Implementation, Items, T>,
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
return {
kind: "layer",
name: input.service !== undefined ? input.service.key : input.name,
service: input.service,
implementation: input.layer,
dependencies: input.deps,
tier: input.tier,
}
}
export function group<const Items extends NodeList>(
dependencies: Items,
): Node<Output<Items[number]>, Error<Items[number]>> {
return { kind: "group", name: "group", dependencies }
}
type AllowedTierNames<Names extends readonly string[], Name extends Names[number]> = Names extends readonly [
infer Head extends string,
...infer Tail extends readonly string[],
]
? Head extends Name
? Head | Tail[number]
: AllowedTierNames<Tail, Name>
: never
type NodeInTiers<Names extends string> = Node<unknown, unknown, Tier<Names>>
export interface Tiers<Names extends readonly [string, ...string[]]> {
readonly names: Names
readonly values: { readonly [K in Names[number]]: Tier<K> }
readonly make: <Name extends Names[number]>(
name: Name,
) => <
const Implementation extends Layer.Any,
const Items extends NodeList<NodeInTiers<AllowedTierNames<Names, Name>>>,
>(
input: DistributiveOmit<NodeInput<Implementation, Items, Tier<Name>>, "tier">,
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tier<Name>>
}
export function tiers<const Names extends readonly [string, ...string[]]>(names: Names): Tiers<Names> {
const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers<Names>["values"]
return {
names,
values,
make: ((name: Names[number]) => (input: DistributiveOmit<NodeInput<Layer.Any, NodeList, Tier>, "tier">) =>
make({ ...input, tier: values[name] })) as Tiers<Names>["make"],
}
}
const defaultTiers = tiers(["untiered"])
const untiered = defaultTiers.values.untiered
export type Replacement = {
readonly source: Layer.Any
readonly replacement: Layer.Any
}
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, R, E2>(
source: Layer.Layer<A, E, R>,
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement {
return { source, replacement }
}
export function buildLayer<
A,
E,
const Names extends readonly [string, ...string[]] = readonly ["untiered"],
const Built extends Layer.Any = Layer.Layer<never, never, never>,
>(
node: Node<A, E, any>,
options?: {
readonly tiers?: Tiers<Names>
readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built
readonly replacements?: readonly Replacement[]
},
): Layer.Layer<A | Layer.Success<Built>, E | Layer.Error<Built>, never> {
const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers<Names>)
const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
const plans = plan(node, tiers, replacementMap)
const layers: RuntimeLayer[] = tiers.names.map((name) => {
const tier = tiers.values[name as Names[number]]
const layers = plans.get(tier) ?? []
return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer
})
if (layers.length === 0) return Layer.empty as never
return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never
}
export function combine(layers: readonly Layer.Any[]): RuntimeLayer {
return layers.reduce<RuntimeLayer>(
(result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)),
Layer.empty as RuntimeLayer,
)
}
function plan(
root: AnyNode,
tiers: Tiers<readonly [string, ...string[]]>,
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
) {
const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index]))
const plans = new Map<Tier, Layer.Any[]>()
const activeImplementations = new Map<Tier, Map<string, AnyNode>>()
const serviceTiers = new Map<string, Tier>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const boundaryVisited = new Map<AnyNode, Set<Tier>>()
const boundaryServices = new Map<Tier, Map<string, AnyNode>>()
const validateBoundary = (node: AnyNode, origin: Tier) => {
const checked = boundaryVisited.get(node) ?? new Set<Tier>()
boundaryVisited.set(node, checked)
if (checked.has(origin)) return false
checked.add(origin)
const services = boundaryServices.get(origin) ?? new Map<string, AnyNode>()
boundaryServices.set(origin, services)
const key = node.name
const existing = services.get(key)
if (existing && existing !== node) {
throw new Error(`Tier ${origin} has conflicting implementations for ${key}`)
}
services.set(key, node)
return true
}
const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => {
if (node.kind === "group") {
node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins))
return
}
const tier = node.tier ?? untiered
if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
const key = node.name
const serviceTier = serviceTiers.get(key)
if (serviceTier && serviceTier !== tier) {
throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`)
}
serviceTiers.set(key, tier)
const nextOrigins = [...origins]
if (currentTier) {
const current = indexes.get(currentTier)!
const required = indexes.get(tier)!
if (required < current) {
throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`)
}
if (required > current) nextOrigins.push(currentTier)
}
const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin))
// A node may need to be emitted more than once because the final output is a
// flat list of layers applied with Layer.provideMerge. If another node for
// the same service was emitted afterward, this node is no longer the active
// implementation for subsequent consumers. Re-emitting restores the intended
// implementation ordering while Effect memoization avoids reacquiring the layer.
const implementations = activeImplementations.get(tier) ?? new Map<string, AnyNode>()
activeImplementations.set(tier, implementations)
if (implementations.get(key) === node && unseenOrigins.length === 0) return
if (visiting.has(node)) {
const start = stack.indexOf(node)
throw new Error(
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(node)
stack.push(node)
try {
node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins))
const layers = plans.get(tier) ?? []
plans.set(tier, layers)
layers.push(replacements.get(node.implementation!) ?? node.implementation!)
implementations.set(key, node)
} finally {
stack.pop()
visiting.delete(node)
}
}
visit(root)
return plans
}
function requireTier(node: AnyNode, indexes: ReadonlyMap<Tier, number>) {
if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`)
}
export * as LayerNode from "./layer-node"
@@ -1,2 +0,0 @@
export * from "./layer-node"
export * as LayerNodeTree from "./layer-node-tree"
@@ -1,118 +0,0 @@
import { Layer } from "effect"
import { LayerNode } from "./layer-node"
type AnyNode = LayerNode.Node<unknown, unknown, any>
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
export function hoist<A, E, T extends LayerNode.Tag>(
root: LayerNode.Node<A, E, any>,
tag: T,
): {
readonly node: LayerNode.Node<A, E>
readonly hoisted: LayerNode.Node<unknown, E>
} {
const visited = new Map<AnyNode, AnyNode>()
const hoisted = new Map<string, AnyNode>()
const visiting = new Set<AnyNode>()
const stack: AnyNode[] = []
const visit = (node: AnyNode): AnyNode => {
if (node.kind === "group") {
return { ...node, dependencies: node.dependencies.map(visit) }
}
const existingNode = visited.get(node)
if (existingNode) return existingNode
if (node.tag === tag) {
const existing = hoisted.get(node.name)
if (existing && existing !== node) {
throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`)
}
hoisted.set(node.name, node)
const empty = LayerNode.group([])
visited.set(node, empty)
return empty
}
if (node.kind === "unbound") {
return node
}
if (visiting.has(node)) {
const start = stack.indexOf(node)
throw new Error(
`Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
)
}
visiting.add(node)
stack.push(node)
try {
const dependencies = node.dependencies.map(visit)
const clone = { ...node, dependencies }
visited.set(node, clone)
return clone
} finally {
stack.pop()
visiting.delete(node)
}
}
return {
node: visit(root) as LayerNode.Node<A, E>,
hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node<unknown, E>,
}
}
export function compile<A, E>(
root: LayerNode.Node<A, E, any>,
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
): Layer.Layer<A, E> {
const cache = new Map<AnyNode, RuntimeLayer>()
const compileNode = (node: AnyNode): RuntimeLayer => {
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
const cached = cache.get(node)
if (cached) return cached
const dependencies = node.dependencies.flatMap(flatten).map(compileNode)
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
const layer =
dependencies.length === 0
? implementation
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
cache.set(node, layer)
return layer
}
const layers = flatten(root).map((node) => compileNode(node))
const layer = layers.reduce<RuntimeLayer>((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty)
return layer as Layer.Layer<A, E>
}
export function bind<A, E, T extends LayerNode.Tag | undefined>(
root: LayerNode.Node<A, E, T>,
source: AnyNode,
replacement: AnyNode,
): LayerNode.Node<A, E, T> {
if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`)
if (source.name !== replacement.name) {
throw new Error(`Cannot bind ${source.name} to ${replacement.name}`)
}
if (source.tag !== replacement.tag) {
throw new Error(`Cannot bind ${source.name} across tags`)
}
const visited = new Map<AnyNode, AnyNode>()
const visit = (node: AnyNode): AnyNode => {
if (node === source) return replacement
const existing = visited.get(node)
if (existing) return existing
if (node.kind === "unbound") return node
const clone = { ...node, dependencies: node.dependencies.map(visit) }
visited.set(node, clone)
return clone
}
return visit(root) as LayerNode.Node<A, E, T>
}
function flatten(node: AnyNode): readonly AnyNode[] {
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
}
export * as LayerNodeTree from "./layer-node-tree"
@@ -1,127 +0,0 @@
import { Brand, Context, Layer } from "effect"
type AnyNode = Node<unknown, unknown, any>
type NodeList<Item extends AnyNode = AnyNode> = readonly [] | readonly [Item, ...Item[]]
export type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown, any> ? A : never
export type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E, any> ? E : never
type NodeTag<Item> = [Item] extends [never] ? undefined : Item extends Node<unknown, unknown, infer T> ? T : never
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
Missing<Layer.Services<Implementation>, Dependencies>,
] extends [never]
? unknown
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
declare const $OutputType: unique symbol
declare const $ErrorType: unique symbol
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
const makeTag = Brand.nominal<Tag>()
export interface Node<A, E = never, T extends Tag | undefined = undefined> {
readonly kind: "layer" | "unbound" | "group"
readonly name: string
readonly service?: Context.Service.Any
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly tag?: T
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
type NodeIdentity =
| { readonly service: Context.Service.Any; readonly name?: never }
| { readonly name: string; readonly service?: never }
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
type MakeInput<
Implementation extends Layer.Any,
Items extends NodeList,
T extends Tag | undefined = undefined,
> = NodeIdentity & {
readonly layer: Implementation
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
readonly tag?: T
}
export function make<
const Implementation extends Layer.Any,
const Items extends NodeList,
const T extends Tag | undefined = undefined,
>(
input: MakeInput<Implementation, Items, T>,
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
return {
kind: "layer",
name: input.service !== undefined ? input.service.key : input.name,
service: input.service,
implementation: input.layer,
dependencies: input.deps,
tag: input.tag,
}
}
export function unbound<R, Shape, const T extends Tag>(service: Context.Key<R, Shape>, tag: T): Node<R, never, T> {
return {
kind: "unbound",
name: service.key,
service,
dependencies: [],
tag,
}
}
export function group<const Items extends readonly AnyNode[]>(
dependencies: Items,
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<Items[number]>> {
return { kind: "group", name: "group", dependencies }
}
export type TagConfig = Readonly<Record<string, readonly string[]>>
type TagNames<Config extends TagConfig> = keyof Config & string
type NodeInTags<Names extends string> = Node<unknown, unknown, Tag<Names> | undefined>
type CheckTags<Items extends NodeList, Names extends string> = [Exclude<Items[number], NodeInTags<Names>>] extends [
never,
]
? unknown
: { readonly "Invalid tag dependencies": Exclude<Items[number], NodeInTags<Names>> }
export interface Tags<Config extends TagConfig> {
readonly values: { readonly [Name in TagNames<Config>]: Tag<Name> }
readonly make: <Name extends TagNames<Config>>(
name: Name,
) => <const Implementation extends Layer.Any, const Items extends NodeList>(
input: DistributiveOmit<MakeInput<Implementation, Items, Tag<Name>>, "tag"> &
CheckTags<Items, Name | Extract<Config[Name][number], string>>,
) => Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, Tag<Name>>
}
export function tags<const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }>(
config: Config,
): Tags<Config> {
const names = Object.keys(config) as TagNames<Config>[]
const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags<Config>["values"]
return {
values,
make: ((name: TagNames<Config>) => (input: DistributiveOmit<MakeInput<Layer.Any, NodeList, Tag>, "tag">) =>
make({ ...input, tag: values[name] })) as Tags<Config>["make"],
}
}
export type Replacement = {
readonly source: Layer.Any
readonly replacement: Layer.Any
}
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
? unknown
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
export function replace<A, E, R, E2>(
source: Layer.Layer<A, E, R>,
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
): Replacement {
return { source, replacement }
}
export * as LayerNode from "./layer-node"
-18
View File
@@ -1,18 +0,0 @@
import { Layer } from "effect"
import { buildLocationServiceMap } from "../location-services"
import { LocationServiceMap } from "../location-service-map"
import { LayerNode, LayerNodeTree } from "./layer-node"
import { makeGlobalNode } from "./node"
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) {
const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement]))
const locationMap = buildLocationServiceMap(replacementMap)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
const app = LayerNodeTree.bind(root, LocationServiceMap.node, locationMapNode)
return LayerNodeTree.compile(app, replacementMap)
}
export * as NodeBuild from "./node-build"
-14
View File
@@ -1,14 +0,0 @@
import { LayerNode } from "./layer-node"
export const tags = LayerNode.tags({
location: ["global"],
global: [],
})
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["global"]>
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tags.values)["location"]>
export const makeGlobalNode = tags.make("global")
export const makeLocationNode = tags.make("location")
export * as Node from "./node"
+11
View File
@@ -0,0 +1,11 @@
import { LayerNode } from "./layer-node"
export const tiers = LayerNode.tiers(["location", "global"])
export type GlobalNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["global"]>
export type LocationNode<A, E = never> = LayerNode.Node<A, E, (typeof tiers.values)["location"]>
export const makeGlobalNode = tiers.make("global")
export const makeLocationNode = tiers.make("location")
export * as ScopedNode from "./scoped-node"
+17 -83
View File
@@ -1,13 +1,13 @@
export * as EventV2 from "./event" export * as EventV2 from "./event"
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
import { and, asc, eq, gt, inArray } from "drizzle-orm" import { and, asc, eq, gt } from "drizzle-orm"
import { Database } from "./database/database" import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql" import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location" import { Location } from "./location"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { isDeepStrictEqual } from "node:util" import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest" import { Durable } from "@opencode-ai/schema/durable-event-manifest"
@@ -47,71 +47,6 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
}, },
) {} ) {}
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
return {
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data),
}
}
export const readAggregate = Effect.fn("EventV2.readAggregate")(function* <A>(
db: Database.Interface["db"],
input: {
readonly aggregateID: string
readonly after?: number
readonly limit: number
readonly manifest: {
readonly definitions: ReadonlyMap<string, Definition>
readonly schema: Schema.Decoder<A, never>
}
},
) {
const after = input.after ?? -1
const rows = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, input.aggregateID),
gt(EventTable.seq, after),
inArray(EventTable.type, Array.from(input.manifest.definitions.keys())),
),
)
.orderBy(asc(EventTable.seq))
.limit(input.limit + 1)
.all()
.pipe(Effect.orDie)
const page = rows.slice(0, input.limit)
const decode = Schema.decodeUnknownSync(input.manifest.schema)
const events = page.map((event) =>
decode({
id: event.id,
type: input.manifest.definitions.get(event.type)?.type ?? event.type,
durable: {
aggregateID: event.aggregate_id,
seq: event.seq,
version: input.manifest.definitions.get(event.type)?.durable?.version,
},
data: event.data,
}),
)
return {
events,
hasMore: rows.length > input.limit,
}
})
export class SubscriberOverflowError extends Schema.TaggedErrorClass<SubscriberOverflowError>()(
"EventV2.SubscriberOverflow",
{ capacity: Schema.Int },
) {}
export const define = Event.define export const define = Event.define
export const versionedType = Event.versionedType export const versionedType = Event.versionedType
@@ -149,20 +84,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
export const allBounded = (events: Interface, capacity: number) =>
Effect.gen(function* () {
const queue = yield* Queue.dropping<Payload, SubscriberOverflowError>(capacity)
const unsubscribe = yield* events.listen((event) =>
Queue.offer(queue, event).pipe(
Effect.flatMap((accepted) =>
accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid),
),
),
)
yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid))
return Stream.fromQueue(queue)
})
export interface LayerOptions { export interface LayerOptions {
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void> readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
} }
@@ -538,6 +459,19 @@ export const layerWith = (options?: LayerOptions) =>
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all) const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(pubsub.all)
const decodeSerializedEvent = (event: SerializedEvent) => {
const definition = Durable.get(event.type)
if (!definition?.durable) {
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
}
return {
id: event.id,
type: definition.type,
durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version },
data: Schema.decodeUnknownSync(definition.data)(event.data),
}
}
const readAfter = (aggregateID: string, after: number) => const readAfter = (aggregateID: string, after: number) =>
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
Effect.andThen( Effect.andThen(
@@ -635,6 +569,6 @@ export const layerWith = (options?: LayerOptions) =>
) )
export const layer = layerWith() export const layer = layerWith()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
-3
View File
@@ -1,6 +1,5 @@
export * as FileMutation from "./file-mutation" export * as FileMutation from "./file-mutation"
import { makeLocationNode } from "./effect/node"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path" import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
@@ -193,8 +192,6 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
/** /**
* Deferred until the corresponding V2 integrations exist. * Deferred until the corresponding V2 integrations exist.
*/ */
-7
View File
@@ -1,6 +1,5 @@
export * as FileSystem from "./filesystem" export * as FileSystem from "./filesystem"
import { makeLocationNode } from "./effect/node"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
@@ -114,9 +113,3 @@ const baseLayer = Layer.effect(
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer: baseLayer,
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
})
+3 -6
View File
@@ -1,6 +1,5 @@
export * as FileSystemSearch from "./search" export * as FileSystemSearch from "./search"
import { makeLocationNode } from "../effect/node"
import path from "path" import path from "path"
import { Context, Effect, Layer, Scope } from "effect" import { Context, Effect, Layer, Scope } from "effect"
import { Fff } from "#fff" import { Fff } from "#fff"
@@ -230,8 +229,6 @@ export const fffLayer = Layer.effect(
}), }),
) )
const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) export const locationLayer = Layer.unwrap(
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
export const locationLayer = layer )
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] })
-7
View File
@@ -3,7 +3,6 @@ export * as Watcher from "./watcher"
// @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"
import { makeLocationNode } from "../effect/node"
import { Cause, Context, Effect, Layer } from "effect" import { Cause, Context, Effect, Layer } from "effect"
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
import path from "path" import path from "path"
@@ -134,9 +133,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer)) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node],
})
+2 -2
View File
@@ -7,7 +7,7 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import type { PlatformError } from "effect/PlatformError" import type { PlatformError } from "effect/PlatformError"
import { Glob } from "./util/glob" import { Glob } from "./util/glob"
import { serviceUse } from "./effect/service-use" import { serviceUse } from "./effect/service-use"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform" import { filesystem } from "./effect/layer-node-platform"
export namespace FSUtil { export namespace FSUtil {
@@ -201,7 +201,7 @@ export namespace FSUtil {
) )
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
// Pure helpers that don't need Effect (path manipulation, sync operations) // Pure helpers that don't need Effect (path manipulation, sync operations)
export function mimeType(p: string): string { export function mimeType(p: string): string {
+2 -2
View File
@@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
import { AbsolutePath, RelativePath } from "./schema" import { AbsolutePath, RelativePath } from "./schema"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { AppProcess } from "./process" import { AppProcess } from "./process"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { File } from "./file" import { File } from "./file"
import { KeyedMutex } from "./effect/keyed-mutex" import { KeyedMutex } from "./effect/keyed-mutex"
@@ -944,7 +944,7 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
interface Result { interface Result {
readonly exitCode: number readonly exitCode: number
+2 -2
View File
@@ -5,7 +5,7 @@ import os from "os"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Flock } from "./util/flock" import { Flock } from "./util/flock"
import { Flag } from "./flag/flag" import { Flag } from "./flag/flag"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
const app = "opencode" const app = "opencode"
const data = path.join(xdgData!, app) const data = path.join(xdgData!, app)
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
export const layerWith = (input: Partial<Interface>) => export const layerWith = (input: Partial<Interface>) =>
Layer.effect( Layer.effect(
-3
View File
@@ -1,6 +1,5 @@
export * as Image from "./image" export * as Image from "./image"
import { makeLocationNode } from "./effect/node"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { Config } from "./config" import { Config } from "./config"
import { FileSystem } from "./filesystem" import { FileSystem } from "./filesystem"
@@ -77,5 +76,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
-7
View File
@@ -9,7 +9,6 @@ import { Location } from "./location"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { SystemContext } from "./system-context/index" import { SystemContext } from "./system-context/index"
import { SystemContextRegistry } from "./system-context/registry" import { SystemContextRegistry } from "./system-context/registry"
import { makeLocationNode } from "./effect/node"
class File extends Schema.Class<File>("InstructionContext.File")({ class File extends Schema.Class<File>("InstructionContext.File")({
path: AbsolutePath, path: AbsolutePath,
@@ -88,12 +87,6 @@ export const layer = Layer.effectDiscard(
}), }),
) )
export const node = makeLocationNode({
name: "instruction-context",
layer,
deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node],
})
function render(files: ReadonlyArray<File>) { function render(files: ReadonlyArray<File>) {
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
} }
-3
View File
@@ -1,6 +1,5 @@
export * as Integration from "./integration" export * as Integration from "./integration"
import { makeLocationNode } from "./effect/node"
import { import {
Cause, Cause,
Clock, Clock,
@@ -516,5 +515,3 @@ export const locationLayer = Layer.effect(
}) })
}), }),
) )
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
+151
View File
@@ -0,0 +1,151 @@
import { Effect, Layer, LayerMap } from "effect"
import { Location } from "./location"
import { Policy } from "./policy"
import { Config } from "./config"
import { PluginV2 } from "./plugin"
import { Catalog } from "./catalog"
import { Integration } from "./integration"
import { CommandV2 } from "./command"
import { AgentV2 } from "./agent"
import { PluginInternal } from "./plugin/internal"
import { Project } from "./project"
import { ProjectCopy } from "./project/copy"
import { ProjectDirectories } from "./project/directories"
import { EventV2 } from "./event"
import { Credential } from "./credential"
import { Npm } from "./npm"
import { ModelsDev } from "./models-dev"
import { FSUtil } from "./fs-util"
import { Git } from "./git"
import { Global } from "./global"
import { Database } from "./database/database"
import { PermissionV2 } from "./permission"
import { PermissionSaved } from "./permission/saved"
import { FileSystem } from "./filesystem"
import { Ripgrep } from "./ripgrep"
import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { FileMutation } from "./file-mutation"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { BuiltInTools } from "./tool/builtins"
import { Image } from "./image"
import { ToolRegistry } from "./tool/registry"
import { ApplicationTools } from "./tool/application-tools"
import { ToolOutputStore } from "./tool-output-store"
import { AppProcess } from "./process"
import { SessionStore } from "./session/store"
import { SessionTodo } from "./session/todo"
import { QuestionV2 } from "./question"
import { LLMClient } from "@opencode-ai/llm"
import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
import { Snapshot } from "./snapshot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const boot = Layer.effectDiscard(
Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }),
)
const location = Location.layer(ref)
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
location,
Policy.locationLayer,
Config.locationLayer,
Reference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
Integration.locationLayer,
CommandV2.locationLayer,
AgentV2.locationLayer,
PluginInternal.locationLayer,
ProjectCopy.locationLayer,
FileSystem.locationLayer,
Watcher.locationLayer,
Pty.locationLayer,
SkillV2.locationLayer,
systemContext,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
const permissionsAndTools = ToolRegistry.layer.pipe(
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provide(resources),
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const image = Image.layer.pipe(Layer.provide(services))
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
Layer.provide(services),
Layer.provide(mutation),
Layer.provide(resources),
Layer.provide(todos),
Layer.provide(questions),
Layer.provide(image),
)
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(services),
Layer.provide(model),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(snapshot),
)
// Kick off a background project copy refresh to update locations now that we
// have a location
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
return Layer.mergeAll(
boot,
services,
image,
mutation,
resources,
todos,
questions,
model,
snapshot,
runner,
builtInTools,
referenceGuidance,
projectCopyRefresh,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
Project.defaultLayer,
EventV2.defaultLayer,
Credential.defaultLayer,
Npm.defaultLayer,
ModelsDev.defaultLayer,
FSUtil.defaultLayer,
Git.defaultLayer,
AppProcess.defaultLayer,
Global.defaultLayer,
Ripgrep.defaultLayer,
Database.defaultLayer,
ProjectDirectories.defaultLayer,
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
PermissionSaved.defaultLayer,
RepositoryCache.defaultLayer,
LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)),
FetchHttpClient.layer,
ToolOutputStore.defaultCleanupLayer,
ApplicationTools.layer,
],
}) {}
-7
View File
@@ -1,6 +1,5 @@
export * as LocationMutation from "./location-mutation" export * as LocationMutation from "./location-mutation"
import { makeLocationNode } from "./effect/node"
import path from "path" import path from "path"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
@@ -154,9 +153,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
})
-18
View File
@@ -1,18 +0,0 @@
import { Context, Effect, Layer, LayerMap } from "effect"
import { LayerNode } from "./effect/layer-node"
import { Node } from "./effect/node"
import { Location } from "./location"
import type { LocationError, LocationServices } from "./location-services"
export class Service extends Context.Service<
Service,
LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>
>()("@opencode/example/LocationServiceMap") {
static get(ref: Location.Ref) {
return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref)))
}
}
export const node = LayerNode.unbound(Service, Node.tags.values.global)
export * as LocationServiceMap from "./location-service-map"
-112
View File
@@ -1,112 +0,0 @@
import { Effect, Layer, LayerMap } from "effect"
import { AgentV2 } from "./agent"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { CommandV2 } from "./command"
import { Config } from "./config"
import { LayerNode, LayerNodeTree } from "./effect/layer-node"
import { Node } from "./effect/node"
import { FileMutation } from "./file-mutation"
import { FileSystem } from "./filesystem"
import { FileSystemSearch } from "./filesystem/search"
import { Watcher } from "./filesystem/watcher"
import { Image } from "./image"
import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { PluginInternal } from "./plugin/internal"
import { Policy } from "./policy"
import { ProjectCopy } from "./project/copy"
import { Pty } from "./pty"
import { QuestionV2 } from "./question"
import { Reference } from "./reference"
import { ReferenceGuidance } from "./reference/guidance"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionTodo } from "./session/todo"
import { SkillV2 } from "./skill"
import { SkillGuidance } from "./skill/guidance"
import { Snapshot } from "./snapshot"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { SystemContextRegistry } from "./system-context/registry"
import { BuiltInTools } from "./tool/builtins"
import { ReadToolFileSystem } from "./tool/read-filesystem"
import { ToolRegistry } from "./tool/registry"
import { ToolOutputStore } from "./tool-output-store"
export { LocationServiceMap } from "./location-service-map"
export const locationServices = LayerNode.group([
Location.node,
Policy.node,
Config.node,
AgentV2.node,
CommandV2.node,
Reference.node,
Integration.node,
Catalog.node,
AISDK.node,
PluginV2.node,
PluginInternal.node,
ProjectCopy.node,
ProjectCopy.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Watcher.node,
Pty.node,
SkillV2.node,
SystemContextRegistry.node,
SystemContextBuiltIns.node,
LocationMutation.node,
FileMutation.node,
PermissionV2.node,
ToolOutputStore.node,
ToolRegistry.node,
ToolRegistry.toolsNode,
Image.node,
SkillGuidance.node,
ReferenceGuidance.node,
SessionTodo.node,
QuestionV2.node,
ReadToolFileSystem.node,
BuiltInTools.node,
SessionRunnerModel.node,
Snapshot.node,
SessionRunnerLLM.node,
])
export type LocationServices = LayerNode.Output<typeof locationServices>
export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
): Layer.Layer<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref: Location.Ref) => {
const location = LayerNodeTree.hoist(
LayerNodeTree.bind(locationServices, Location.node, Location.boundNode(ref)),
Node.tags.values.global,
)
return LayerNodeTree.compile(location.node, replacements).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("booting location services", {
directory: ref.directory,
workspaceID: ref.workspaceID,
}),
),
Layer.provide(LayerNodeTree.compile(location.hoisted, replacements)),
)
},
{ idleTimeToLive: "60 minutes" },
),
)
}
// This is temporary for backwards compatibility
export const locationServiceMapLayer = buildLocationServiceMap()
-11
View File
@@ -1,8 +1,6 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location" import { Info, Ref, response } from "@opencode-ai/schema/location"
import { Project } from "./project" import { Project } from "./project"
import { LayerNode } from "./effect/layer-node"
import { makeLocationNode, tags } from "./effect/node"
export * as Location from "./location" export * as Location from "./location"
@@ -14,8 +12,6 @@ export interface Interface extends Info {
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
export const node = LayerNode.unbound(Service, tags.values.location)
export const layer = (ref: Ref) => export const layer = (ref: Ref) =>
Layer.effect( Layer.effect(
Service, Service,
@@ -30,10 +26,3 @@ export const layer = (ref: Ref) =>
}) })
}), }),
) )
export const boundNode = (ref: Ref) =>
makeLocationNode({
service: Service,
layer: layer(ref),
deps: [Project.node],
})
+2 -2
View File
@@ -9,7 +9,7 @@ import { Hash } from "./util/hash"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { InstallationChannel, InstallationVersion } from "./installation/version" import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event" import { EventV2 } from "./event"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { httpClient } from "./effect/layer-node-platform" import { httpClient } from "./effect/layer-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
@@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer), Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
) )
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export * as ModelsDev from "./models-dev" export * as ModelsDev from "./models-dev"
+2 -2
View File
@@ -7,7 +7,7 @@ import { NodeFileSystem } from "@effect/platform-node"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Global } from "./global" import { Global } from "./global"
import { EffectFlock } from "./util/effect-flock" import { EffectFlock } from "./util/effect-flock"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { filesystem } from "./effect/layer-node-platform" import { filesystem } from "./effect/layer-node-platform"
import { makeRuntime } from "./effect/runtime" import { makeRuntime } from "./effect/runtime"
import { NpmConfig } from "./npm-config" import { NpmConfig } from "./npm-config"
@@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Global.layer), Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
) )
export const node = makeGlobalNode({ export const node = LayerNode.make({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node], deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node],
-7
View File
@@ -1,6 +1,5 @@
export * as PermissionV2 from "./permission" export * as PermissionV2 from "./permission"
import { makeLocationNode } from "./effect/node"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission" import { Permission } from "@opencode-ai/schema/permission"
import { EventV2 } from "./event" import { EventV2 } from "./event"
@@ -301,9 +300,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer))
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
})
-3
View File
@@ -3,7 +3,6 @@ export * as PermissionSaved from "./saved"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { makeGlobalNode } from "../effect/node"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import { PermissionTable } from "./sql" import { PermissionTable } from "./sql"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved" import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
@@ -77,5 +76,3 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
-16
View File
@@ -1,6 +1,5 @@
export * as PluginV2 from "./plugin" export * as PluginV2 from "./plugin"
import { makeLocationNode } from "./effect/node"
import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect"
import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect" import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect"
import { Plugin } from "@opencode-ai/schema/plugin" import { Plugin } from "@opencode-ai/schema/plugin"
@@ -151,18 +150,3 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Reference.locationLayer), Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(SkillV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer),
) )
export const node = makeLocationNode({
service: Service,
layer,
deps: [
EventV2.node,
AgentV2.node,
AISDK.node,
Catalog.node,
CommandV2.node,
Integration.node,
Reference.node,
SkillV2.node,
],
})
+19 -43
View File
@@ -1,7 +1,5 @@
export * as PluginInternal from "./internal" export * as PluginInternal from "./internal"
import { makeLocationNode } from "../effect/node"
import { httpClient } from "../effect/layer-node-platform"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { Effect, Layer, Scope } from "effect" import { Effect, Layer, Scope } from "effect"
import { AgentV2 } from "../agent" import { AgentV2 } from "../agent"
@@ -25,6 +23,7 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin" import { PluginV2 } from "../plugin"
import { Reference } from "../reference" import { Reference } from "../reference"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { State } from "../state"
import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { FetchHttpClient, HttpClient } from "effect/unstable/http"
import { AgentPlugin } from "./agent" import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command" import { CommandPlugin } from "./command"
@@ -59,7 +58,7 @@ export function define<R>(plugin: Plugin<R>) {
return plugin return plugin
} }
const layer = Layer.effectDiscard( export const locationLayer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const catalog = yield* Catalog.Service const catalog = yield* Catalog.Service
const commands = yield* CommandV2.Service const commands = yield* CommandV2.Service
@@ -104,49 +103,26 @@ const layer = Layer.effectDiscard(
return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
} }
yield* Effect.gen(function* () { yield* State.batch(
yield* add(ConfigReferencePlugin.Plugin) Effect.gen(function* () {
yield* add(AgentPlugin.Plugin) yield* add(ConfigReferencePlugin.Plugin)
yield* add(CommandPlugin.Plugin) yield* add(AgentPlugin.Plugin)
yield* add(SkillPlugin.Plugin) yield* add(CommandPlugin.Plugin)
yield* add(ModelsDevPlugin) yield* add(SkillPlugin.Plugin)
yield* add(ConfigAgentPlugin.Plugin) yield* add(ModelsDevPlugin)
yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin)
for (const item of ProviderPlugins) yield* add(item) yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigExternalPlugin.Plugin) for (const item of ProviderPlugins) yield* add(item)
yield* add(ConfigProviderPlugin.Plugin) yield* add(ConfigExternalPlugin.Plugin)
yield* add(VariantPlugin.Plugin) yield* add(ConfigProviderPlugin.Plugin)
}).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) yield* add(VariantPlugin.Plugin)
}),
).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
}), }),
) ).pipe(
export const locationLayer = layer.pipe(
Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Config.locationLayer), Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(FileSystem.locationLayer), Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(FetchHttpClient.layer), Layer.provideMerge(FetchHttpClient.layer),
) )
export const node = makeLocationNode({
name: "plugin-internal",
layer,
deps: [
Catalog.node,
CommandV2.node,
PluginV2.node,
Integration.node,
AgentV2.node,
Config.node,
Location.node,
ModelsDev.node,
Npm.node,
EventV2.node,
FSUtil.node,
FileSystem.node,
Global.node,
httpClient,
SkillV2.node,
Reference.node,
],
})
@@ -1,4 +1,4 @@
import { Duration, Effect, Schema, Stream } from "effect" import { Duration, Effect, Schema, Semaphore, Stream } from "effect"
import type { Scope } from "effect" import type { Scope } from "effect"
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
import { define } from "@opencode-ai/plugin/v2/effect/plugin" import { define } from "@opencode-ai/plugin/v2/effect/plugin"
@@ -79,6 +79,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
effect: Effect.fn(function* (ctx) { effect: Effect.fn(function* (ctx) {
const events = yield* EventV2.Service const events = yield* EventV2.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const loading = Semaphore.makeUnsafe(1)
let connected = false let connected = false
let providers: typeof ConfigV1.Info.Type.provider | undefined let providers: typeof ConfigV1.Info.Type.provider | undefined
@@ -105,7 +106,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } }) draft.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
}) })
yield* load() connected = (yield* ctx.integration.connection.active("opencode")) !== undefined
yield* ctx.catalog.transform((catalog) => { yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(providers ?? {})) { for (const [providerID, item] of Object.entries(providers ?? {})) {
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
@@ -176,11 +177,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | EventV2.Service | S
} }
}) })
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")), Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))), Stream.runForEach(refresh),
Effect.forkScoped({ startImmediately: true }), Effect.forkScoped({ startImmediately: true }),
) )
yield* refresh().pipe(Effect.forkScoped)
}), }),
}) })
-3
View File
@@ -1,6 +1,5 @@
export * as Policy from "./policy" export * as Policy from "./policy"
import { makeLocationNode } from "./effect/node"
import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" import { Context, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Wildcard } from "./util/wildcard" import { Wildcard } from "./util/wildcard"
import { Location } from "./location" import { Location } from "./location"
@@ -45,5 +44,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] })
+21 -2
View File
@@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", { export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String, command: Schema.String,
@@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()(
} }
export interface RunOptions { export interface RunOptions {
readonly combineOutput?: boolean
readonly maxOutputBytes?: number readonly maxOutputBytes?: number
readonly maxErrorBytes?: number readonly maxErrorBytes?: number
readonly signal?: AbortSignal readonly signal?: AbortSignal
@@ -37,8 +38,10 @@ export interface RunStreamOptions {
export interface RunResult { export interface RunResult {
readonly command: string readonly command: string
readonly exitCode: number readonly exitCode: number
readonly output?: Buffer
readonly stdout: Buffer readonly stdout: Buffer
readonly stderr: Buffer readonly stderr: Buffer
readonly outputTruncated?: boolean
readonly stdoutTruncated: boolean readonly stdoutTruncated: boolean
readonly stderrTruncated: boolean readonly stderrTruncated: boolean
} }
@@ -143,6 +146,22 @@ export const layer = Layer.effect(
const collect = Effect.scoped( const collect = Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const handle = yield* spawner.spawn(command) const handle = yield* spawner.spawn(command)
if (options?.combineOutput) {
const [output, exitCode] = yield* Effect.all(
[collectStream(handle.all, options.maxOutputBytes), handle.exitCode],
{ concurrency: "unbounded" },
)
return {
command: description,
exitCode,
output: output.buffer,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
outputTruncated: output.truncated,
stdoutTruncated: false,
stderrTruncated: false,
} satisfies RunResult
}
const [stdout, stderr, exitCode] = yield* Effect.all( const [stdout, stderr, exitCode] = yield* Effect.all(
[ [
collectStream(handle.stdout, options?.maxOutputBytes), collectStream(handle.stdout, options?.maxOutputBytes),
@@ -238,6 +257,6 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] })
export * as AppProcess from "./process" export * as AppProcess from "./process"
+2 -2
View File
@@ -6,7 +6,7 @@ import path from "path"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { Git } from "./git" import { Git } from "./git"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { Hash } from "./util/hash" import { Hash } from "./util/hash"
import { ProjectDirectories } from "./project/directories" import { ProjectDirectories } from "./project/directories"
import { ProjectSchema } from "./project/schema" import { ProjectSchema } from "./project/schema"
@@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer), Layer.provide(Git.defaultLayer),
Layer.provideMerge(ProjectDirectories.defaultLayer), Layer.provideMerge(ProjectDirectories.defaultLayer),
) )
export const node = makeGlobalNode({ export const node = LayerNode.make({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node], deps: [FSUtil.node, Git.node, ProjectDirectories.node],
+2 -8
View File
@@ -5,7 +5,7 @@ import path from "path"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Git } from "../git" import { Git } from "../git"
import { makeLocationNode } from "../effect/node" import { LayerNode } from "../effect/layer-node"
import { Project } from "../project" import { Project } from "../project"
import { ProjectDirectories } from "./directories" import { ProjectDirectories } from "./directories"
import { makeGitWorktreeStrategy } from "./copy-strategies" import { makeGitWorktreeStrategy } from "./copy-strategies"
@@ -279,14 +279,8 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ export const node = LayerNode.make({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node],
}) })
export const refreshNode = makeLocationNode({
name: "project-copy-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
+2 -2
View File
@@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { makeGlobalNode } from "../effect/node" import { LayerNode } from "../effect/layer-node"
import { AbsolutePath, optional } from "../schema" import { AbsolutePath, optional } from "../schema"
import { ProjectSchema } from "./schema" import { ProjectSchema } from "./schema"
import { ProjectDirectoryTable } from "./sql" import { ProjectDirectoryTable } from "./sql"
@@ -156,4 +156,4 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
-3
View File
@@ -1,6 +1,5 @@
export * as Pty from "./pty" export * as Pty from "./pty"
import { makeLocationNode } from "./effect/node"
import type { Disp, Proc } from "#pty" import type { Disp, Proc } from "#pty"
import { Context, Effect, Layer, Schema, Types } from "effect" import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode-ai/schema/pty" import { Pty } from "@opencode-ai/schema/pty"
@@ -314,5 +313,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] })
+2 -2
View File
@@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace"
import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
import { PtyID } from "./schema" import { PtyID } from "./schema"
import { Cache, Context, Duration, Effect, Layer } from "effect" import { Cache, Context, Duration, Effect, Layer } from "effect"
import { makeGlobalNode } from "../effect/node" import { LayerNode } from "../effect/layer-node"
const DEFAULT_TTL = Duration.seconds(60) const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000 const CAPACITY = 10_000
@@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
export const layer = Layer.effect(Service, make()) export const layer = Layer.effect(Service, make())
export const defaultLayer = layer export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
+5 -1
View File
@@ -1,3 +1,7 @@
export * as PublicEventManifest from "./public-event-manifest" export * as PublicEventManifest from "./public-event-manifest"
export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest" import { Event } from "@opencode-ai/schema/event"
import { EventManifest } from "@opencode-ai/schema/event-manifest"
export const Definitions = EventManifest.ServerDefinitions
export const Latest = Event.latest(Definitions)
-3
View File
@@ -1,6 +1,5 @@
export * as QuestionV2 from "./question" export * as QuestionV2 from "./question"
import { makeLocationNode } from "./effect/node"
import { Context, Deferred, Effect, Layer, Schema } from "effect" import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Question } from "@opencode-ai/schema/question" import { Question } from "@opencode-ai/schema/question"
import { EventV2 } from "./event" import { EventV2 } from "./event"
@@ -149,5 +148,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
-7
View File
@@ -1,6 +1,5 @@
export * as Reference from "./reference" export * as Reference from "./reference"
import { makeLocationNode } from "./effect/node"
import { Context, Effect, Layer, Scope, Types } from "effect" import { Context, Effect, Layer, Scope, Types } from "effect"
import { Reference } from "@opencode-ai/schema/reference" import { Reference } from "@opencode-ai/schema/reference"
import { Global } from "./global" import { Global } from "./global"
@@ -121,9 +120,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer,
deps: [Global.node, EventV2.node, RepositoryCache.node],
})
-3
View File
@@ -1,6 +1,5 @@
export * as ReferenceGuidance from "./guidance" export * as ReferenceGuidance from "./guidance"
import { makeLocationNode } from "../effect/node"
import { Context, Effect, Layer, Schema } from "effect" import { Context, Effect, Layer, Schema } from "effect"
import { Reference } from "../reference" import { Reference } from "../reference"
import { SystemContext } from "../system-context/index" import { SystemContext } from "../system-context/index"
@@ -65,5 +64,3 @@ export const layer = Layer.effect(
) )
export const locationLayer = layer export const locationLayer = layer
export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] })
-7
View File
@@ -5,7 +5,6 @@ import { Git } from "./git"
import { Global } from "./global" import { Global } from "./global"
import { Repository } from "./repository" import { Repository } from "./repository"
import { AbsolutePath } from "./schema" import { AbsolutePath } from "./schema"
import { makeGlobalNode } from "./effect/node"
import { EffectFlock } from "./util/effect-flock" import { EffectFlock } from "./util/effect-flock"
export type Result = { export type Result = {
@@ -230,12 +229,6 @@ export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(Global.defaultLayer), Layer.provide(Global.defaultLayer),
) )
export const node = makeGlobalNode({
service: Service,
layer,
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
})
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const if (!input.reuse) return "cloned" as const
if (input.branchMatches === false || input.refresh) return "refreshed" as const if (input.branchMatches === false || input.refresh) return "refreshed" as const
+2 -2
View File
@@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep"
import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { Entry, Match } from "@opencode-ai/schema/filesystem" import { Entry, Match } from "@opencode-ai/schema/filesystem"
import { makeGlobalNode } from "./effect/node" import { LayerNode } from "./effect/layer-node"
import { AppProcess, collectStream, waitForAbort } from "./process" import { AppProcess, collectStream, waitForAbort } from "./process"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { RipgrepBinary } from "./ripgrep/binary" import { RipgrepBinary } from "./ripgrep/binary"
@@ -279,4 +279,4 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer)))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] })
+2 -2
View File
@@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "../cross-spawn-spawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { makeGlobalNode } from "../effect/node" import { LayerNode } from "../effect/layer-node"
import { httpClient } from "../effect/layer-node-platform" import { httpClient } from "../effect/layer-node-platform"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Global } from "../global" import { Global } from "../global"
@@ -130,7 +130,7 @@ export namespace RipgrepBinary {
Layer.provide(CrossSpawnSpawner.defaultLayer), Layer.provide(CrossSpawnSpawner.defaultLayer),
) )
export const node = makeGlobalNode({ export const node = LayerNode.make({
service: Service, service: Service,
layer: layer, layer: layer,
deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node],
+275 -287
View File
@@ -27,8 +27,6 @@ import { fromRow } from "./session/info"
import { SessionRunner } from "./session/runner/index" import { SessionRunner } from "./session/runner/index"
import { SessionStore } from "./session/store" import { SessionStore } from "./session/store"
import { SessionExecution } from "./session/execution" import { SessionExecution } from "./session/execution"
import { makeGlobalNode } from "./effect/node"
import { LocationServiceMap } from "./location-service-map"
import { MessageDecodeError } from "./session/error" import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input" import { SessionInput } from "./session/input"
@@ -36,7 +34,6 @@ import { Snapshot } from "./snapshot"
import { SessionRevert } from "./session/revert" import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert" import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest"
export const RevertState = Revert.State export const RevertState = Revert.State
export type RevertState = Revert.State export type RevertState = Revert.State
@@ -134,11 +131,6 @@ export interface Interface {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
after?: number after?: number
}) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError> }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
readonly history: (input: {
sessionID: SessionSchema.ID
after?: number
limit: number
}) => Effect.Effect<{ events: ReadonlyArray<SessionEvent.DurableEvent>; hasMore: boolean }, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError> readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, NotFoundError>
readonly switchModel: (input: { readonly switchModel: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -181,277 +173,287 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
export const layer = Layer.effect( export const layer = Layer.unwrap(
Service, Effect.promise(() => import("./location-layer")).pipe(
Effect.gen(function* () { Effect.map(({ LocationServiceMap }) =>
const database = yield* Database.Service Layer.effect(
const db = database.db Service,
const events = yield* EventV2.Service Effect.gen(function* () {
const projects = yield* ProjectV2.Service const database = yield* Database.Service
const execution = yield* SessionExecution.Service const db = database.db
const store = yield* SessionStore.Service const events = yield* EventV2.Service
const locations = yield* LocationServiceMap.Service const projects = yield* ProjectV2.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const execution = yield* SessionExecution.Service
const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const store = yield* SessionStore.Service
const decode = (row: typeof SessionMessageTable.$inferSelect) => const locations = yield* LocationServiceMap
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
Effect.mapError( const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
() => const decode = (row: typeof SessionMessageTable.$inferSelect) =>
new MessageDecodeError({ decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
sessionID: SessionSchema.ID.make(row.session_id), Effect.mapError(
messageID: SessionMessage.ID.make(row.id), () =>
}), new MessageDecodeError({
), sessionID: SessionSchema.ID.make(row.session_id),
) messageID: SessionMessage.ID.make(row.id),
}),
const result = Service.of({
create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const project = yield* projects.resolve(input.location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
title: `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
message: Effect.fn("V2Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
history: Effect.fn("V2Session.history")(function* (input) {
yield* result.get(input.sessionID)
return yield* EventV2.readAggregate(db, {
...input,
aggregateID: input.sessionID,
manifest: SessionDurable,
})
}),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
), ),
) )
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
active: execution.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
return result const result = Service.of({
}), create: Effect.fn("V2Session.create")(function* (input) {
const sessionID = input.id ?? SessionSchema.ID.create()
const recorded = yield* store.get(sessionID)
if (recorded) return recorded
const project = yield* projects.resolve(input.location.directory)
yield* db
.insert(ProjectTable)
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
const now = Date.now()
const info = SessionV1.SessionInfo.make({
id: sessionID,
slug: Slug.create(),
version: InstallationVersion,
projectID: project.id,
directory: input.location.directory,
path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
title: `New session - ${new Date(now).toISOString()}`,
agent: input.agent,
model: input.model
? {
id: ModelV2.ID.make(input.model.id),
providerID: input.model.providerID,
variant: input.model.variant,
}
: undefined,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: now, updated: now },
})
const projected = yield* events
.publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
.pipe(
Effect.as({ type: "created" } as const),
Effect.catchDefect((defect) => {
if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
return Effect.die(defect)
}
// Concurrent creation lost the projection race. The existing Session identity wins.
return store
.get(sessionID)
.pipe(
Effect.flatMap((session) =>
session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
),
)
}),
)
if (projected.type === "existing") return projected.session
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const session = yield* store.get(sessionID)
if (!session) return yield* new NotFoundError({ sessionID })
return session
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_created
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.anchor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.id, input.cursor.id),
),
)
.get()
.pipe(Effect.orDie)
: undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
message: Effect.fn("V2Session.message")(function* (input) {
const stored = yield* store.message(input.messageID)
return stored?.sessionID === input.sessionID ? stored.message : undefined
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
events: (input) =>
Stream.unwrap(
result
.get(input.sessionID)
.pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
prompt: Effect.fn("V2Session.prompt")((input) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
delivery,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (!SessionInput.equivalent(admitted, expected))
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) yield* execution.wake(admitted.sessionID)
return admitted
}),
),
),
shell: Effect.fn("V2Session.shell")(function* () {
return yield* new OperationUnavailableError({ operation: "shell" })
}),
skill: Effect.fn("V2Session.skill")(function* () {
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
agent: input.agent,
})
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
const session = yield* result.get(input.sessionID)
if (
session.model?.providerID === input.model.providerID &&
session.model.id === input.model.id &&
(session.model.variant ?? "default") === (input.model.variant ?? "default")
)
return
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
active: execution.active,
resume: Effect.fn("V2Session.resume")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(execution.interrupt(sessionID)),
),
revert: {
stage: Effect.fn("V2Session.revert.stage")(function* (input) {
const session = yield* result.get(input.sessionID)
return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe(
Effect.provideService(Database.Service, database),
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.clear(session).pipe(
Effect.provideService(EventV2.Service, events),
Effect.provide(locations.get(session.location)),
)
}),
commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) {
const session = yield* result.get(sessionID)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
}),
},
})
return result
}),
),
),
),
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(
Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))),
),
Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionStore.defaultLayer),
Layer.provide(SessionProjector.defaultLayer), Layer.provide(SessionProjector.defaultLayer),
Layer.provide(EventV2.defaultLayer), Layer.provide(EventV2.defaultLayer),
@@ -473,17 +475,3 @@ const resolvePrompt = (input: PromptInput.Prompt) =>
} }
}), }),
}) })
export const node = makeGlobalNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [
Database.node,
EventV2.node,
ProjectV2.node,
SessionExecution.node,
SessionStore.node,
LocationServiceMap.node,
SessionProjector.node,
],
})
-4
View File
@@ -1,8 +1,6 @@
export * as SessionExecution from "./execution" export * as SessionExecution from "./execution"
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { LayerNode } from "../effect/layer-node"
import { Node } from "../effect/node"
import { SessionRunner } from "./runner/index" import { SessionRunner } from "./runner/index"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
@@ -20,8 +18,6 @@ export interface Interface {
/** Routes execution from a Session ID to the runner owned by that Session's Location. */ /** Routes execution from a Session ID to the runner owned by that Session's Location. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
export const node = LayerNode.unbound(Service, Node.tags.values.global)
/** Low-level compatibility layer for callers that only need durable Session recording. */ /** Low-level compatibility layer for callers that only need durable Session recording. */
export const noopLayer = Layer.succeed( export const noopLayer = Layer.succeed(
Service, Service,
+2 -11
View File
@@ -1,6 +1,5 @@
import { Cause, Effect, Layer } from "effect" import { Cause, Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-service-map" import { LocationServiceMap } from "../../location-layer"
import { makeGlobalNode } from "../../effect/node"
import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner" import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
@@ -12,7 +11,7 @@ export const layer = Layer.effect(
SessionExecution.Service, SessionExecution.Service,
Effect.gen(function* () { Effect.gen(function* () {
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({ const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
@@ -38,11 +37,3 @@ export const layer = Layer.effect(
) )
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
export const node = makeGlobalNode({
service: SessionExecution.Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node],
})
export * as SessionExecutionLocal from "./local"

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