mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 11:16:13 +00:00
Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53b213571f | ||
|
|
3841955fbc | ||
|
|
5735a14188 | ||
|
|
3e583a3f5a | ||
|
|
1ce607c230 | ||
|
|
c26f6f95f7 | ||
|
|
d77b87ea65 | ||
|
|
6ca6566bd3 | ||
|
|
8dd993d25a | ||
|
|
24ab17e718 | ||
|
|
0a60662d71 | ||
|
|
194b0615e0 | ||
|
|
a5c51e11d0 | ||
|
|
8f1db7d06d | ||
|
|
b1ca070b3b | ||
|
|
12887e572e | ||
|
|
75715e2115 | ||
|
|
a1250cd690 | ||
|
|
23adaaaeab | ||
|
|
524ee8fc03 | ||
|
|
0f9719a7b5 | ||
|
|
1b83c08b8a | ||
|
|
02cb350880 | ||
|
|
4ce830a919 | ||
|
|
461a1c3ab4 | ||
|
|
6846542115 | ||
|
|
f928b5be07 | ||
|
|
ecfa918760 | ||
|
|
f80624cf17 | ||
|
|
fe59174c23 | ||
|
|
2fe057324f | ||
|
|
19a5b5a05d | ||
|
|
ff4cab03c1 | ||
|
|
b2d46ecd7e | ||
|
|
360d85a521 | ||
|
|
c65a7d50c1 | ||
|
|
fa73546a86 | ||
|
|
935ac2db91 | ||
|
|
01edae4a7f | ||
|
|
381d67572e | ||
|
|
e8ac44430b | ||
|
|
5ae93092aa | ||
|
|
595c6bd4a7 | ||
|
|
7073e8797f | ||
|
|
f7034a35a8 | ||
|
|
53b93b6991 | ||
|
|
6067019434 | ||
|
|
42a3cf9645 | ||
|
|
04c6bed240 | ||
|
|
94e3a29d2f | ||
|
+8 |
41283933ff | ||
|
|
11bf8d8a42 | ||
|
|
c22973ab9f | ||
|
|
612009e0f1 | ||
|
|
a0a6c9545e | ||
|
|
bceb6d0a9e | ||
|
|
49e7dc191f | ||
|
|
f4720be08e | ||
|
|
d1d7ebc2c6 | ||
|
|
cd942d0669 | ||
|
|
4741e3ee6b | ||
|
|
4673bfbaa0 | ||
|
|
b458dd8c63 | ||
|
|
7edd6c3a1d | ||
|
|
f43f066741 | ||
|
|
b6553d14e1 | ||
|
|
ab0042a666 | ||
|
|
5df049d081 | ||
|
|
573ab9c24b | ||
|
|
6a16c41e8f | ||
|
|
a491cbee64 | ||
|
|
658cbe9caf | ||
|
|
62b2bc39df | ||
|
|
beb2c52c3f | ||
|
|
655adbf46e | ||
|
|
ac2a78391f | ||
|
|
9a9bdaba95 | ||
|
|
8b682c42b6 | ||
|
|
ad4f1c1018 | ||
|
|
a0afb63ed0 | ||
|
|
df9ecb8f6a | ||
|
|
e6f660fecf |
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- ci
|
||||
- dev
|
||||
- v2
|
||||
- beta
|
||||
- fix/npm-native-binary-install
|
||||
- snapshot-*
|
||||
@@ -31,6 +32,9 @@ permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -122,7 +126,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -221,7 +225,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -447,6 +451,7 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: github.ref_name != 'v2'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -74,13 +75,9 @@ jobs:
|
||||
working-directory: packages/client
|
||||
run: bun run check:generated
|
||||
|
||||
- name: Run HttpApi exerciser gates
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/opencode
|
||||
run: bun run test:httpapi
|
||||
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
if: github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
id: "sample-agent-plugin",
|
||||
setup: async (ctx) => {
|
||||
await ctx.agent.transform((agents) => {
|
||||
agents.update("sample-plugin-agent", (agent) => {
|
||||
agent.description = "Example subagent registered by .opencode/plugins/sample-agent.ts"
|
||||
agent.mode = "subagent"
|
||||
agent.prompt = [
|
||||
"You are the sample plugin agent for this repository.",
|
||||
"Use this agent to verify that local plugin auto-discovery can add agents.",
|
||||
"Keep responses concise and explain which plugin registered you when asked.",
|
||||
].join("\n")
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -93,10 +94,11 @@
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
@@ -105,12 +107,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -299,6 +304,7 @@
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||
@@ -316,6 +322,7 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -576,6 +583,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -672,6 +680,9 @@
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -680,6 +691,7 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -734,6 +746,7 @@
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -927,6 +940,7 @@
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.11",
|
||||
"dependencies": {
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -934,6 +948,7 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -990,6 +1005,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
@@ -1048,6 +1064,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
@@ -3003,7 +3020,7 @@
|
||||
|
||||
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
@@ -3173,7 +3190,7 @@
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
||||
|
||||
@@ -3341,7 +3358,7 @@
|
||||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
@@ -3351,7 +3368,7 @@
|
||||
|
||||
"cookie-es": ["cookie-es@2.0.1", "", {}, "sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
@@ -3659,7 +3676,7 @@
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
@@ -3717,7 +3734,7 @@
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
@@ -3755,7 +3772,7 @@
|
||||
|
||||
"framer-motion": ["framer-motion@8.5.5", "", { "dependencies": { "@motionone/dom": "^10.15.3", "hey-listen": "^1.0.8", "tslib": "^2.4.0" }, "optionalDependencies": { "@emotion/is-prop-valid": "^0.8.2" }, "peerDependencies": { "react": "^18.0.0", "react-dom": "^18.0.0" } }, "sha512-5IDx5bxkjWHWUF3CVJoSyUVOtrbAxtzYBBowRE2uYI/6VYhkEBD+rbTHEGuUmbGHRj6YqqSfoG7Aa1cLyWCrBA=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
@@ -4319,11 +4336,11 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
@@ -4809,7 +4826,7 @@
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
@@ -4995,7 +5012,7 @@
|
||||
|
||||
"semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="],
|
||||
|
||||
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
|
||||
|
||||
@@ -5005,7 +5022,7 @@
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
||||
|
||||
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
@@ -5315,11 +5332,13 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
"type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
@@ -5897,14 +5916,10 @@
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
@@ -5973,6 +5988,8 @@
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@opencode-ai/cli/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -6035,8 +6052,12 @@
|
||||
|
||||
"@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@slack/bolt/express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||
|
||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"@slack/bolt/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
|
||||
"@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
@@ -6103,10 +6124,6 @@
|
||||
|
||||
"@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="],
|
||||
|
||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
||||
|
||||
"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=="],
|
||||
@@ -6161,10 +6178,6 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
@@ -6243,16 +6256,10 @@
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
@@ -6375,8 +6382,6 @@
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -6385,10 +6390,6 @@
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -6433,7 +6434,7 @@
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
"type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
@@ -6703,28 +6704,6 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
@@ -6847,6 +6826,34 @@
|
||||
|
||||
"@shikijs/stream/@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="],
|
||||
|
||||
"@slack/bolt/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"@slack/bolt/express/body-parser": ["body-parser@1.20.5", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA=="],
|
||||
|
||||
"@slack/bolt/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"@slack/bolt/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"@slack/bolt/express/cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"@slack/bolt/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"@slack/bolt/express/finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"@slack/bolt/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"@slack/bolt/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
"@slack/bolt/express/path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"@slack/bolt/express/send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
|
||||
"@slack/bolt/express/serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
|
||||
"@slack/bolt/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"@slack/bolt/raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
@@ -6877,8 +6884,6 @@
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@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=="],
|
||||
@@ -6943,8 +6948,6 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
@@ -7003,12 +7006,8 @@
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"filelist/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
|
||||
"finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||
@@ -7045,8 +7044,6 @@
|
||||
|
||||
"rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7061,8 +7058,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"unplugin/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
@@ -7251,10 +7246,6 @@
|
||||
|
||||
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
@@ -7287,6 +7278,20 @@
|
||||
|
||||
"@sentry/bundler-plugin-core/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
|
||||
"@slack/bolt/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/bolt/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"@slack/bolt/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"@slack/bolt/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"@slack/bolt/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"@slack/bolt/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"@slack/bolt/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||
@@ -7415,6 +7420,10 @@
|
||||
|
||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"@slack/bolt/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@slack/bolt/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
|
||||
|
||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=",
|
||||
"aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=",
|
||||
"aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=",
|
||||
"x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg="
|
||||
"x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=",
|
||||
"aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=",
|
||||
"aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=",
|
||||
"x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE="
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -2,11 +2,12 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.14",
|
||||
"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:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
@@ -153,6 +154,7 @@
|
||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch"
|
||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tw-animate-css": "1.4.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-icons-spritesheet": "3.0.1",
|
||||
|
||||
+22
-12
@@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
@@ -316,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
@@ -345,13 +343,23 @@ function NewAppLayout(props: ParentProps) {
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</NotificationProvider>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</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) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
@@ -560,11 +568,13 @@ export function AppInterface(props: {
|
||||
component={props.router ?? Router}
|
||||
root={(routerProps) => (
|
||||
<TabsProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
type Mem = Performance & {
|
||||
@@ -51,31 +52,62 @@ const bad = (n: number | undefined, limit: number, low = false) => {
|
||||
|
||||
const session = (path: string) => path.includes("/session")
|
||||
|
||||
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
function Cell(props: {
|
||||
bad?: boolean
|
||||
dim?: boolean
|
||||
inline?: boolean
|
||||
label: string
|
||||
tip: string
|
||||
value: string
|
||||
wide?: boolean
|
||||
}) {
|
||||
const content = () => (
|
||||
<div
|
||||
classList={{
|
||||
"flex min-w-0 items-center": true,
|
||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
||||
"justify-center text-center": !props.inline,
|
||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||
"col-span-2": !!props.wide && !props.inline,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||
"col-span-2": !!props.wide,
|
||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||
}}
|
||||
>
|
||||
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||
<div
|
||||
classList={{
|
||||
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
classList={{
|
||||
"uppercase leading-none font-bold tabular-nums": true,
|
||||
"text-[11px]": !!props.inline,
|
||||
"text-[13px] sm:text-[14px]": !props.inline,
|
||||
"text-text-on-critical-base": !!props.bad,
|
||||
"opacity-70": !!props.dim,
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (props.inline) {
|
||||
return (
|
||||
<TooltipV2 value={props.tip} placement="top">
|
||||
{content()}
|
||||
</TooltipV2>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
{content()}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function DebugBar() {
|
||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||
const language = useLanguage()
|
||||
const location = useLocation()
|
||||
const routing = useIsRouting()
|
||||
@@ -101,7 +133,7 @@ export function DebugBar() {
|
||||
},
|
||||
})
|
||||
|
||||
const na = () => language.t("debugBar.na")
|
||||
const na = () => language.t("debugBar.na").toUpperCase()
|
||||
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||
const heapv = () => {
|
||||
const value = heap()
|
||||
@@ -363,15 +395,30 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
aria-label={language.t("debugBar.ariaLabel")}
|
||||
class="pointer-events-auto fixed bottom-3 right-3 z-50 hidden w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] md:block sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||
classList={{
|
||||
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
|
||||
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
|
||||
"fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]":
|
||||
!props.inline,
|
||||
}}
|
||||
>
|
||||
<div class="grid grid-cols-5 gap-px font-mono">
|
||||
<div
|
||||
classList={{
|
||||
"font-mono": true,
|
||||
"gap-[9px]": !!props.inline,
|
||||
"gap-px": !props.inline,
|
||||
"flex w-full flex-nowrap items-center justify-start": !!props.inline,
|
||||
"grid-cols-5": !props.inline,
|
||||
grid: !props.inline,
|
||||
}}
|
||||
>
|
||||
<Cell
|
||||
label={language.t("debugBar.nav.label")}
|
||||
tip={language.t("debugBar.nav.tip")}
|
||||
value={navv()}
|
||||
bad={bad(state.nav.dur, 400)}
|
||||
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.fps.label")}
|
||||
@@ -379,6 +426,7 @@ export function DebugBar() {
|
||||
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||
bad={bad(state.fps, 50, true)}
|
||||
dim={state.fps === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.frame.label")}
|
||||
@@ -386,6 +434,7 @@ export function DebugBar() {
|
||||
value={time(state.gap) ?? na()}
|
||||
bad={bad(state.gap, 50)}
|
||||
dim={state.gap === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.jank.label")}
|
||||
@@ -393,6 +442,7 @@ export function DebugBar() {
|
||||
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||
bad={bad(state.jank, 8)}
|
||||
dim={state.jank === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.long.label")}
|
||||
@@ -400,6 +450,7 @@ export function DebugBar() {
|
||||
value={longv()}
|
||||
bad={bad(state.long.block, 200)}
|
||||
dim={state.long.count === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.delay.label")}
|
||||
@@ -407,6 +458,7 @@ export function DebugBar() {
|
||||
value={time(state.delay) ?? na()}
|
||||
bad={bad(state.delay, 100)}
|
||||
dim={state.delay === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.inp.label")}
|
||||
@@ -414,6 +466,7 @@ export function DebugBar() {
|
||||
value={time(state.inp) ?? na()}
|
||||
bad={bad(state.inp, 200)}
|
||||
dim={state.inp === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.cls.label")}
|
||||
@@ -421,6 +474,7 @@ export function DebugBar() {
|
||||
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||
bad={bad(state.cls, 0.1)}
|
||||
dim={state.cls === undefined}
|
||||
inline={props.inline}
|
||||
/>
|
||||
<Cell
|
||||
label={language.t("debugBar.mem.label")}
|
||||
@@ -435,6 +489,7 @@ export function DebugBar() {
|
||||
value={heapv()}
|
||||
bad={bad(heap(), 0.8)}
|
||||
dim={state.heap.used === undefined}
|
||||
inline={props.inline}
|
||||
wide
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
@@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
@@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
@@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.inputs") {
|
||||
draft.promptInputs = action.inputs
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
@@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "api" && method.prompts?.length) {
|
||||
if (!inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.inputs", inputs })
|
||||
return
|
||||
}
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
@@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
}
|
||||
|
||||
function OAuthPromptsView() {
|
||||
function AuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
value: {} as Record<string, string>,
|
||||
index: 0,
|
||||
@@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
|
||||
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||
const value = method()
|
||||
if (value?.type !== "oauth") return []
|
||||
return value.prompts ?? []
|
||||
return value?.prompts ?? []
|
||||
})
|
||||
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||
if (!prompt.when) return true
|
||||
@@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
if (method()?.type === "api") {
|
||||
dispatch({ type: "auth.inputs", inputs: value })
|
||||
return
|
||||
}
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
@@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
auth: {
|
||||
type: "api",
|
||||
key: apiKey,
|
||||
...(store.promptInputs ? { metadata: store.promptInputs } : {}),
|
||||
},
|
||||
})
|
||||
await complete()
|
||||
@@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<OAuthPromptsView />
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
|
||||
@@ -59,6 +59,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
|
||||
@@ -35,6 +35,8 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -1343,9 +1345,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const agentsLoading = () => props.controls.agents.loading
|
||||
const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading())
|
||||
const agentsShouldFadeIn = createMemo<boolean>((prev) => prev ?? agentsLoading())
|
||||
const providersLoading = () => props.controls.model.loading
|
||||
const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading())
|
||||
const providersShouldFadeIn = createMemo<boolean>((prev) => prev ?? providersLoading())
|
||||
|
||||
const [promptReady] = createResource(
|
||||
() => prompt.ready.promise,
|
||||
@@ -1359,9 +1361,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const modelControlState = createMemo<ComposerModelControlState>(() => ({
|
||||
loading: providersLoading(),
|
||||
shouldAnimate: providersShouldFadeIn(),
|
||||
paid: props.controls.model.paid,
|
||||
title: language.t("command.model.choose"),
|
||||
keybind: command.keybind("model.choose"),
|
||||
keybind: command.keybindParts("model.choose"),
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
@@ -1378,7 +1381,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
|
||||
const agentControlState = createMemo<ComposerAgentControlState>(() => ({
|
||||
title: language.t("command.agent.cycle"),
|
||||
keybind: command.keybind("agent.cycle"),
|
||||
keybind: command.keybindParts("agent.cycle"),
|
||||
options: props.controls.agents.options,
|
||||
current: props.controls.agents.current,
|
||||
style: control(),
|
||||
@@ -1496,10 +1499,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-0">
|
||||
{fileAttachmentInput()}
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
title={language.t("prompt.action.attachFile")}
|
||||
keybind={command.keybind("file.attach")}
|
||||
value={
|
||||
<>
|
||||
{language.t("prompt.action.attachFile")}
|
||||
<KeybindV2 keys={command.keybindParts("file.attach")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
data-action="prompt-attach"
|
||||
@@ -1513,25 +1520,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
aria-label={language.t("prompt.action.attachFile")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
<Show when={showAgentControl()}>
|
||||
<ComposerAgentControl state={agentControlState()} />
|
||||
</Show>
|
||||
{props.toolbar}
|
||||
<ComposerModelControl state={modelControlState()} />
|
||||
<Show when={store.mode !== "shell" && showVariantControl()}>
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
!props.controls.model.selection.variant.current() && !store.variantOpen,
|
||||
}}
|
||||
>
|
||||
<TooltipKeybind
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
title={language.t("command.model.variant.cycle")}
|
||||
keybind={command.keybind("model.variant.cycle")}
|
||||
value={
|
||||
<>
|
||||
{language.t("command.model.variant.cycle")}
|
||||
<KeybindV2 keys={command.keybindParts("model.variant.cycle")} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
@@ -1549,11 +1561,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<TooltipV2 placement="top" inactive={!working() && blank()} value={tip()}>
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
@@ -1568,7 +1580,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</DockShellForm>
|
||||
</div>
|
||||
@@ -1765,7 +1777,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!agentsLoading()}>
|
||||
<div
|
||||
data-component="prompt-agent-control"
|
||||
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": agentsShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1794,7 +1806,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={store.mode !== "shell"}>
|
||||
<div
|
||||
data-component="prompt-model-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<Show
|
||||
when={props.controls.model.paid}
|
||||
@@ -1873,7 +1885,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
|
||||
classList={{ "animate-in fade-in duration-300": providersShouldFadeIn() }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1914,7 +1926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
type ComposerAgentControlState = {
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
options: string[]
|
||||
current: string
|
||||
style: JSX.CSSProperties | undefined
|
||||
@@ -1923,9 +1935,10 @@ type ComposerAgentControlState = {
|
||||
|
||||
type ComposerModelControlState = {
|
||||
loading: boolean
|
||||
shouldAnimate: boolean
|
||||
paid: boolean
|
||||
title: string
|
||||
keybind: string
|
||||
keybind: string[]
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
@@ -1940,7 +1953,16 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center text-v2-icon-icon-muted">
|
||||
<Icon name="sliders" size="small" />
|
||||
</div>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={props.state.options}
|
||||
@@ -1952,7 +1974,7 @@ function ComposerAgentControl(props: { state: ComposerAgentControlState }) {
|
||||
triggerProps={{ "data-action": "prompt-agent" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1963,13 +1985,23 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Show
|
||||
when={props.state.paid}
|
||||
fallback={
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
data-action="prompt-model"
|
||||
as="div"
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
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}
|
||||
onClick={props.state.onUnpaidClick}
|
||||
>
|
||||
@@ -1987,10 +2019,19 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
}
|
||||
>
|
||||
<TooltipKeybind placement="top" gutter={4} title={props.state.title} keybind={props.state.keybind}>
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
<>
|
||||
{props.state.title}
|
||||
<KeybindV2 keys={props.state.keybind} variant="neutral" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
@@ -2000,6 +2041,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
style: props.state.style,
|
||||
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 },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
@@ -2018,7 +2060,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
</TooltipKeybind>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||
const creating = createMemo(() => {
|
||||
const route = layout.route()
|
||||
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
|
||||
if (!params.dir) return false
|
||||
if (params.id) return false
|
||||
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||
@@ -466,7 +468,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
<Show when={!(creating() && params.dir)}>
|
||||
<Show when={!creating()}>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
value={
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
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 = {
|
||||
directory?: string
|
||||
@@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
gate: false,
|
||||
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
init: () => {
|
||||
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const global = useGlobal()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const owner = getOwner()
|
||||
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
|
||||
|
||||
const empty: Notification[] = []
|
||||
|
||||
const currentDirectory = createMemo(() => {
|
||||
return props.directory?.() ?? decode64(params.dir)
|
||||
const activeServer = createMemo(() => {
|
||||
if (params.serverKey) return requireServerKey(params.serverKey)
|
||||
if (search.draftId) {
|
||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||
if (draft) return draft.server
|
||||
}
|
||||
return server.key
|
||||
})
|
||||
const activeDirectory = createMemo(() => decode64(params.dir))
|
||||
const activeSession = createMemo(() => params.id)
|
||||
|
||||
const currentSession = createMemo(() => props.sessionID?.() ?? params.id)
|
||||
|
||||
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 ensure = (key: ServerConnection.Key) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn) throw new Error(`Notification server not found: ${key}`)
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
const existing = states.get(ctx.sdk.scope)
|
||||
if (existing) return existing.state
|
||||
const root = createRoot(
|
||||
(dispose) => ({
|
||||
dispose,
|
||||
state: createServerNotificationState({
|
||||
sdk: ctx.sdk,
|
||||
sync: ctx.sync,
|
||||
active: () => server.scope(activeServer()) === ctx.sdk.scope,
|
||||
directory: activeDirectory,
|
||||
sessionID: activeSession,
|
||||
platform,
|
||||
settings,
|
||||
language,
|
||||
}),
|
||||
}),
|
||||
owner ?? undefined,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
states.set(ctx.sdk.scope, root)
|
||||
return root.state
|
||||
}
|
||||
|
||||
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 }))
|
||||
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
|
||||
states.forEach((value, scope) => {
|
||||
if (scopes.has(scope)) return
|
||||
value.dispose()
|
||||
states.delete(scope)
|
||||
})
|
||||
})
|
||||
|
||||
const append = (notification: Notification) => {
|
||||
const list = pruneNotifications([...store.list, notification])
|
||||
const keep = new Set(list)
|
||||
const removed = store.list.filter((n) => !keep.has(n))
|
||||
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||
|
||||
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()
|
||||
})
|
||||
const selected = () => ensure(activeServer())
|
||||
|
||||
return {
|
||||
ready,
|
||||
ready: () => selected().ready(),
|
||||
ensureServerState: ensure,
|
||||
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)
|
||||
})
|
||||
})
|
||||
},
|
||||
all: (session: string) => selected().session.all(session),
|
||||
unseen: (session: string) => selected().session.unseen(session),
|
||||
unseenCount: (session: string) => selected().session.unseenCount(session),
|
||||
unseenHasError: (session: string) => selected().session.unseenHasError(session),
|
||||
markViewed: (session: string) => selected().session.markViewed(session),
|
||||
},
|
||||
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)
|
||||
})
|
||||
})
|
||||
},
|
||||
all: (directory: string) => selected().project.all(directory),
|
||||
unseen: (directory: string) => selected().project.unseen(directory),
|
||||
unseenCount: (directory: string) => selected().project.unseenCount(directory),
|
||||
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
|
||||
markViewed: (directory: string) => selected().project.markViewed(directory),
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
@@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => {
|
||||
})
|
||||
|
||||
describe("coalesceServerEvents", () => {
|
||||
const delta = (value: string, field = "text") => ({
|
||||
const delta = (value: string, field = "text", partID = "part") => ({
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: { messageID: "msg", partID: "part", field, delta: value },
|
||||
properties: { messageID: "msg", partID, field, delta: value },
|
||||
} as Event,
|
||||
})
|
||||
|
||||
test("merges adjacent deltas for the same field", () => {
|
||||
const result = coalesceServerEvents([delta("hello "), delta("world")])
|
||||
const first = delta("hello ")
|
||||
const second = delta("world")
|
||||
first.payload.id = "first"
|
||||
second.payload.id = "second"
|
||||
const result = coalesceServerEvents([first, second])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } })
|
||||
expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("preserves event boundaries and distinct fields", () => {
|
||||
@@ -45,9 +49,112 @@ describe("coalesceServerEvents", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("drops stale deltas", () => {
|
||||
const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"]))
|
||||
test("preserves event ID order across interleaved deltas", () => {
|
||||
const first = delta("a")
|
||||
const other = delta("b", "text", "other")
|
||||
const last = delta("c")
|
||||
first.payload.id = "1"
|
||||
other.payload.id = "2"
|
||||
last.payload.id = "3"
|
||||
|
||||
expect(result).toEqual([])
|
||||
const result = coalesceServerEvents([first, other, last])
|
||||
|
||||
expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("enqueueServerEvent", () => {
|
||||
const partUpdated = (text: string) =>
|
||||
({
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
part: { id: "part", sessionID: "session", messageID: "message", type: "text", text },
|
||||
},
|
||||
}) as Event
|
||||
|
||||
test("preserves part updates across message remove and re-add barriers", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event)
|
||||
enqueue({
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
info: {
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
},
|
||||
},
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"message.removed",
|
||||
"message.updated",
|
||||
"message.part.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves deltas after a replacement snapshot", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("a"))
|
||||
enqueue(partUpdated("ab"))
|
||||
enqueue({
|
||||
type: "message.part.delta",
|
||||
properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" },
|
||||
} as Event)
|
||||
|
||||
const result = coalesceServerEvents(events)
|
||||
expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"])
|
||||
expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } })
|
||||
expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } })
|
||||
})
|
||||
|
||||
test("preserves updates after session deletion", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload })
|
||||
|
||||
enqueue(partUpdated("old"))
|
||||
enqueue({
|
||||
type: "session.deleted",
|
||||
properties: { sessionID: "session", info: { id: "session" } },
|
||||
} as Event)
|
||||
enqueue(partUpdated("new"))
|
||||
|
||||
expect(events.map((event) => event.payload.type)).toEqual([
|
||||
"message.part.updated",
|
||||
"session.deleted",
|
||||
"message.part.updated",
|
||||
])
|
||||
})
|
||||
|
||||
test("does not coalesce edge-triggered session statuses", () => {
|
||||
const events: Array<{ directory: string; payload: Event }> = []
|
||||
const enqueue = (status: "retry" | "busy") =>
|
||||
enqueueServerEvent(events, {
|
||||
directory: "/repo",
|
||||
payload: {
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session",
|
||||
status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" },
|
||||
},
|
||||
} as Event,
|
||||
})
|
||||
|
||||
enqueue("retry")
|
||||
enqueue("busy")
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,34 +17,56 @@ const isAbortError = (error: unknown) =>
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
type QueuedServerEvent = { directory: string; payload: Event }
|
||||
|
||||
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
|
||||
const coalescedKey = (event: QueuedServerEvent) => {
|
||||
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return `message.part.updated:${event.directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set<string>) {
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
const key = coalescedKey(event)
|
||||
const previous = queue[queue.length - 1]
|
||||
if (key && previous && coalescedKey(previous) === key) {
|
||||
queue[queue.length - 1] = event
|
||||
return false
|
||||
}
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
const deltas = new Map<string, number>()
|
||||
events.forEach((event) => {
|
||||
if (stale && event.payload.type === "message.part.delta") {
|
||||
const props = event.payload.properties
|
||||
if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return
|
||||
}
|
||||
if (event.payload.type !== "message.part.delta") {
|
||||
deltas.clear()
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
const props = event.payload.properties
|
||||
const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}`
|
||||
const index = deltas.get(id)
|
||||
const existing = index === undefined ? undefined : output[index]
|
||||
if (!existing || existing.payload.type !== "message.part.delta") {
|
||||
deltas.set(id, output.length)
|
||||
const previous = output[output.length - 1]
|
||||
if (
|
||||
!previous ||
|
||||
previous.payload.type !== "message.part.delta" ||
|
||||
previous.directory !== event.directory ||
|
||||
previous.payload.properties.messageID !== props.messageID ||
|
||||
previous.payload.properties.partID !== props.partID ||
|
||||
previous.payload.properties.field !== props.field
|
||||
) {
|
||||
output.push({
|
||||
directory: event.directory,
|
||||
payload: { ...event.payload, properties: { ...props } },
|
||||
})
|
||||
return
|
||||
}
|
||||
existing.payload.properties.delta += props.delta
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: { ...props, delta: previous.payload.properties.delta + props.delta },
|
||||
},
|
||||
}
|
||||
})
|
||||
return output
|
||||
}
|
||||
@@ -85,20 +107,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
const coalesced = new Map<string, number>()
|
||||
const staleDeltas = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
const key = (directory: string, payload: Event) => {
|
||||
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
|
||||
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
@@ -106,15 +117,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
coalesced.clear()
|
||||
staleDeltas.clear()
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events, skip)
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
@@ -184,29 +192,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
for await (const event of events.stream) {
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
if (event.payload.type === "sync") {
|
||||
continue
|
||||
if (event.payload.type !== "sync") {
|
||||
const directory = event.directory ?? "global"
|
||||
const payload = event.payload as Event
|
||||
if (enqueueServerEvent(queue, { directory, payload })) schedule()
|
||||
}
|
||||
|
||||
const payload = event.payload as Event
|
||||
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
if (i !== undefined) {
|
||||
queue[i] = { directory, payload }
|
||||
if (payload.type === "message.part.updated") {
|
||||
const part = payload.properties.part
|
||||
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
|
||||
}
|
||||
continue
|
||||
}
|
||||
coalesced.set(k, queue.length)
|
||||
}
|
||||
queue.push({ directory, payload })
|
||||
schedule()
|
||||
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,44 +18,67 @@ import { rootSession } from "@/utils/session-route"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
|
||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 2
|
||||
const historyMessagePageSize = 200
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
type OptimisticItem = {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
confirmedParts?: Part[]
|
||||
confirmedMessage?: boolean
|
||||
}
|
||||
|
||||
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
|
||||
if (!parts) return want.length === 0
|
||||
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
|
||||
type MessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
function mergeOptimisticPage(
|
||||
page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean },
|
||||
items: OptimisticItem[],
|
||||
) {
|
||||
if (items.length === 0) return { ...page, confirmed: [] as string[] }
|
||||
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
|
||||
type MessageLoadState = {
|
||||
touchedMessages: Set<string>
|
||||
removedMessages: Set<string>
|
||||
retainedMessages: Set<string>
|
||||
touchedParts: Map<string, Set<string>>
|
||||
deltaParts: Map<string, Set<string>>
|
||||
carriedDeltaParts: Map<string, Set<string>>
|
||||
removedParts: Map<string, Set<string>>
|
||||
optimisticParts: Map<string, Set<string>>
|
||||
orphanParents: Set<string>
|
||||
clearedMessageParts: Set<string>
|
||||
}
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
const part = new Map(page.part.map((item) => [item.id, item.part]))
|
||||
const confirmed: string[] = []
|
||||
const observed: { messageID: string; parts: Part[] }[] = []
|
||||
for (const item of items) {
|
||||
const result = Binary.search(session, item.message.id, (message) => message.id)
|
||||
if (!result.found) session.splice(result.index, 0, item.message)
|
||||
const current = part.get(item.message.id)
|
||||
if (result.found && hasParts(current, item.parts)) {
|
||||
confirmed.push(item.message.id)
|
||||
continue
|
||||
}
|
||||
part.set(item.message.id, merge(current ?? [], item.parts))
|
||||
const confirmed = result.found
|
||||
? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
|
||||
: []
|
||||
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
|
||||
part.set(
|
||||
item.message.id,
|
||||
merge(
|
||||
result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
|
||||
item.parts.filter((part) => !confirmed.includes(part)),
|
||||
),
|
||||
)
|
||||
}
|
||||
return {
|
||||
...page,
|
||||
session,
|
||||
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
|
||||
confirmed,
|
||||
observed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +98,38 @@ function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
|
||||
return [...items.values()].sort((x, y) => cmp(x.id, y.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient) {
|
||||
function reconcileFetched<T extends { id: string }>(
|
||||
fetched: T[],
|
||||
current: readonly T[],
|
||||
options: {
|
||||
touched?: ReadonlySet<string>
|
||||
retained?: ReadonlySet<string>
|
||||
preserveUnfetched?: boolean | ((item: T) => boolean)
|
||||
} = {},
|
||||
) {
|
||||
const result = new Map(fetched.map((item) => [item.id, item]))
|
||||
const live = new Map(current.map((item) => [item.id, item]))
|
||||
if (options.preserveUnfetched) {
|
||||
for (const item of current) {
|
||||
if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item)))
|
||||
result.set(item.id, item)
|
||||
}
|
||||
}
|
||||
for (const id of options.retained ?? emptyIDs) {
|
||||
if (result.has(id)) continue
|
||||
const item = live.get(id)
|
||||
if (item) result.set(id, item)
|
||||
}
|
||||
// Events observed while the request is pending are the freshest client state for those identities.
|
||||
for (const id of options.touched ?? emptyIDs) {
|
||||
const item = live.get(id)
|
||||
if (item) result.set(id, item)
|
||||
if (!item) result.delete(id)
|
||||
}
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) {
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
@@ -95,10 +149,32 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const optimistic = new Map<string, Map<string, OptimisticItem>>()
|
||||
const messageLoads = new Map<string, MessageLoadState>()
|
||||
const pendingParts = new Map<string, Map<string, Set<string>>>()
|
||||
const orphanParts = new Map<string, Set<string>>()
|
||||
const removedMessages = new Map<string, Set<string>>()
|
||||
const deltaBases = new Map<string, { base: string; sessionID: string }>()
|
||||
const deleteMessageParts = (
|
||||
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
|
||||
messageID: string,
|
||||
) => {
|
||||
for (const part of cache.part[messageID] ?? []) {
|
||||
delete cache.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
delete cache.part[messageID]
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const infoSeen = new Set<string>()
|
||||
const pinned = new Map<string, number>()
|
||||
const generations = new Map<string, number>()
|
||||
const generations = new Map<string, object>()
|
||||
const generation = (sessionID: string) => {
|
||||
const current = generations.get(sessionID)
|
||||
if (current) return current
|
||||
const created = {}
|
||||
generations.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number | undefined>,
|
||||
cursor: {} as Record<string, string | undefined>,
|
||||
@@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const preserve = new Set([
|
||||
...pinned.keys(),
|
||||
...requests.keys(),
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (!preserve.has(sessionID)) stale.push(sessionID)
|
||||
}
|
||||
stale.forEach((sessionID) => infoSeen.delete(sessionID))
|
||||
stale.forEach((sessionID) => generations.delete(sessionID))
|
||||
setData(
|
||||
"info",
|
||||
produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])),
|
||||
@@ -151,21 +233,27 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (cached && !options?.force) return Promise.resolve(cached)
|
||||
const pending = requests.get(sessionID)
|
||||
if (pending) return pending
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return result.data
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
requests.set(sessionID, request)
|
||||
void request.then(
|
||||
() => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
},
|
||||
() => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
},
|
||||
)
|
||||
const cleanup = () => {
|
||||
if (requests.get(sessionID) === request) requests.delete(sessionID)
|
||||
if (
|
||||
generations.get(sessionID) === active &&
|
||||
!data.info[sessionID] &&
|
||||
!requests.has(sessionID) &&
|
||||
!messageLoads.has(sessionID) &&
|
||||
!inflight.has(sessionID) &&
|
||||
!inflightDiff.has(sessionID) &&
|
||||
!inflightTodo.has(sessionID)
|
||||
)
|
||||
generations.delete(sessionID)
|
||||
}
|
||||
void request.then(cleanup, cleanup)
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -195,15 +283,121 @@ export function createServerSession(client: OpencodeClient) {
|
||||
if (items.size === 0) optimistic.delete(sessionID)
|
||||
}
|
||||
|
||||
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((part) => part.id !== partID)
|
||||
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
|
||||
}
|
||||
|
||||
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const parts = item.parts.filter((value) => value.id !== part.id)
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], [part]),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
|
||||
const items = optimistic.get(sessionID)
|
||||
const item = items?.get(messageID)
|
||||
if (!items || !item) return
|
||||
const confirmed = new Set(confirmedParts.map((part) => part.id))
|
||||
const parts = item.parts.filter((part) => !confirmed.has(part.id))
|
||||
if (parts.length === 0) {
|
||||
clearOptimistic(sessionID, messageID)
|
||||
return
|
||||
}
|
||||
items.set(messageID, {
|
||||
...item,
|
||||
parts,
|
||||
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
|
||||
confirmedMessage: true,
|
||||
})
|
||||
}
|
||||
|
||||
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
|
||||
const load = messageLoads.get(sessionID)
|
||||
if (!load) return
|
||||
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
const parts = load.touchedParts.get(messageID)
|
||||
if (parts) {
|
||||
parts.add(partID)
|
||||
return
|
||||
}
|
||||
load.touchedParts.set(messageID, new Set([partID]))
|
||||
}
|
||||
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||
load.touchedMessages.clear()
|
||||
load.retainedMessages.clear()
|
||||
load.touchedParts.clear()
|
||||
load.carriedDeltaParts.clear()
|
||||
load.clearedMessageParts.clear()
|
||||
for (const messageID of load.removedMessages) {
|
||||
load.touchedMessages.add(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.deltaParts) {
|
||||
load.touchedParts.set(messageID, new Set(parts))
|
||||
load.carriedDeltaParts.set(messageID, new Set(parts))
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.removedParts) {
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
const messages = data.message[sessionID]
|
||||
if (messages && Binary.search(messages, messageID, (message) => message.id).found)
|
||||
load.retainedMessages.add(messageID)
|
||||
}
|
||||
for (const [messageID, parts] of load.optimisticParts) {
|
||||
load.removedMessages.delete(messageID)
|
||||
load.clearedMessageParts.add(messageID)
|
||||
load.touchedMessages.add(messageID)
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
}
|
||||
|
||||
const evict = (sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
const evicted = new Set(sessionIDs)
|
||||
for (const [partID, item] of deltaBases) {
|
||||
if (evicted.has(item.sessionID)) deltaBases.delete(partID)
|
||||
}
|
||||
sessionIDs.forEach((sessionID) => {
|
||||
generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1)
|
||||
generations.delete(sessionID)
|
||||
clearOptimistic(sessionID)
|
||||
requests.delete(sessionID)
|
||||
inflight.delete(sessionID)
|
||||
inflightDiff.delete(sessionID)
|
||||
inflightTodo.delete(sessionID)
|
||||
messageLoads.delete(sessionID)
|
||||
pendingParts.delete(sessionID)
|
||||
orphanParts.delete(sessionID)
|
||||
removedMessages.delete(sessionID)
|
||||
})
|
||||
setData(
|
||||
produce((draft) => {
|
||||
@@ -230,6 +424,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
...inflight.keys(),
|
||||
...inflightDiff.keys(),
|
||||
...inflightTodo.keys(),
|
||||
...messageLoads.keys(),
|
||||
...optimistic.keys(),
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
@@ -247,8 +442,11 @@ export function createServerSession(client: OpencodeClient) {
|
||||
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
|
||||
)
|
||||
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string) => {
|
||||
const response = await retry(() => client.session.messages({ sessionID, limit, before }))
|
||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
})
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
@@ -261,30 +459,153 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
}
|
||||
|
||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
const messageIDs = new Set(messages.map((message) => message.id))
|
||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const message of dropped) deleteMessageParts(draft, message.id)
|
||||
}),
|
||||
)
|
||||
return messageIDs
|
||||
}
|
||||
|
||||
const replaceParts = (
|
||||
sessionID: string,
|
||||
items: MessagePage["part"],
|
||||
messageIDs: Set<string>,
|
||||
load?: MessageLoadState,
|
||||
) => {
|
||||
for (const item of items) {
|
||||
if (!messageIDs.has(item.id)) continue
|
||||
const fetched = load?.clearedMessageParts.has(item.id)
|
||||
? []
|
||||
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
const fetchedIDs = new Set(fetched.map((part) => part.id))
|
||||
const pending = pendingParts.get(sessionID)?.get(item.id)
|
||||
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
|
||||
for (const part of fetched) {
|
||||
const accumulated = data.part_text_accum_delta[part.id]
|
||||
const base = deltaBases.get(part.id)?.base
|
||||
const preserveDelta =
|
||||
base !== undefined &&
|
||||
accumulated !== undefined &&
|
||||
"text" in part &&
|
||||
typeof part.text === "string" &&
|
||||
part.text.startsWith(base) &&
|
||||
accumulated.startsWith(part.text) &&
|
||||
accumulated !== part.text
|
||||
if (preserveDelta) touched.add(part.id)
|
||||
if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id)
|
||||
}
|
||||
for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) {
|
||||
if (!fetchedIDs.has(partID)) touched.delete(partID)
|
||||
}
|
||||
const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched })
|
||||
if (!parts.length) {
|
||||
orphanParts.get(sessionID)?.delete(item.id)
|
||||
setData(produce((draft) => deleteMessageParts(draft, item.id)))
|
||||
continue
|
||||
}
|
||||
const partIDs = new Set(parts.map((part) => part.id))
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of data.part[item.id] ?? []) {
|
||||
if (!partIDs.has(part.id) || !touched.has(part.id)) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||
orphanParts.get(sessionID)?.delete(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
const applyMessagePage = (
|
||||
sessionID: string,
|
||||
page: MessagePage,
|
||||
load: MessageLoadState | undefined,
|
||||
preserveUnfetched: boolean | ((message: Message) => boolean),
|
||||
cleanupOrphans: boolean,
|
||||
) => {
|
||||
const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
merged.observed.forEach((item) => {
|
||||
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
|
||||
})
|
||||
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
|
||||
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
|
||||
touched: touchedMessages,
|
||||
retained: load?.retainedMessages,
|
||||
preserveUnfetched,
|
||||
})
|
||||
batch(() => {
|
||||
const messageIDs = replaceMessages(sessionID, messages)
|
||||
replaceParts(sessionID, merged.part, messageIDs, load)
|
||||
const orphans = orphanParts.get(sessionID)
|
||||
if (cleanupOrphans && page.complete && orphans) {
|
||||
for (const messageID of orphans) {
|
||||
if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
}
|
||||
orphanParts.delete(sessionID)
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, merged.cursor)
|
||||
setMeta("complete", sessionID, merged.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
}
|
||||
|
||||
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
|
||||
if (meta.loading[sessionID]) return
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
const load: MessageLoadState = {
|
||||
touchedMessages: new Set(),
|
||||
removedMessages: new Set(),
|
||||
retainedMessages: new Set(),
|
||||
touchedParts: new Map(),
|
||||
deltaParts: new Map(),
|
||||
carriedDeltaParts: new Map(),
|
||||
removedParts: new Map(),
|
||||
optimisticParts: new Map(),
|
||||
orphanParents: new Set(),
|
||||
clearedMessageParts: new Set(),
|
||||
}
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
await fetchMessages(sessionID, limit, before)
|
||||
let applied = false
|
||||
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
.then((page) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])])
|
||||
next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID))
|
||||
const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session
|
||||
batch(() => {
|
||||
setData("message", sessionID, reconcile(messages, { key: "id" }))
|
||||
for (const item of next.part) {
|
||||
const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type))
|
||||
if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" }))
|
||||
}
|
||||
setMeta("limit", sessionID, messages.length)
|
||||
setMeta("cursor", sessionID, next.cursor)
|
||||
setMeta("complete", sessionID, next.complete)
|
||||
setMeta("at", sessionID, Date.now())
|
||||
})
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
page,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
})
|
||||
.finally(() => {
|
||||
if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false)
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -339,7 +660,13 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const eventID = eventSessionID(event)
|
||||
if (eventID) {
|
||||
touch(eventID)
|
||||
if (!data.info[eventID]) void resolve(eventID).catch(() => {})
|
||||
if (
|
||||
!data.info[eventID] &&
|
||||
event.type !== "session.created" &&
|
||||
event.type !== "session.updated" &&
|
||||
event.type !== "session.deleted"
|
||||
)
|
||||
void resolve(eventID).catch(() => {})
|
||||
}
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
@@ -378,6 +705,21 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = cleanMessage((event.properties as { info: Message }).info)
|
||||
const load = messageLoads.get(info.sessionID)
|
||||
load?.touchedMessages.add(info.id)
|
||||
load?.removedMessages.delete(info.id)
|
||||
const items = optimistic.get(info.sessionID)
|
||||
const item = items?.get(info.id)
|
||||
if (items && item) {
|
||||
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
|
||||
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
|
||||
}
|
||||
const orphans = orphanParts.get(info.sessionID)
|
||||
orphans?.delete(info.id)
|
||||
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(info.sessionID)
|
||||
removedMessagesForSession?.delete(info.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID)
|
||||
const messages = data.message[info.sessionID]
|
||||
if (!messages) {
|
||||
setData("message", info.sessionID, [info])
|
||||
@@ -395,6 +737,20 @@ export function createServerSession(client: OpencodeClient) {
|
||||
}
|
||||
case "message.removed": {
|
||||
const props = event.properties as { sessionID: string; messageID: string }
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
load?.touchedMessages.add(props.messageID)
|
||||
load?.removedMessages.add(props.messageID)
|
||||
load?.clearedMessageParts.add(props.messageID)
|
||||
load?.deltaParts.delete(props.messageID)
|
||||
load?.carriedDeltaParts.delete(props.messageID)
|
||||
load?.removedParts.delete(props.messageID)
|
||||
load?.optimisticParts.delete(props.messageID)
|
||||
pendingParts.get(props.sessionID)?.delete(props.messageID)
|
||||
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
|
||||
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
|
||||
removedMessagesForSession.add(props.messageID)
|
||||
removedMessages.set(props.sessionID, removedMessagesForSession)
|
||||
clearOptimistic(props.sessionID, props.messageID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[props.sessionID]
|
||||
@@ -402,8 +758,7 @@ export function createServerSession(client: OpencodeClient) {
|
||||
const result = Binary.search(messages, props.messageID, (message) => message.id)
|
||||
if (result.found) messages.splice(result.index, 1)
|
||||
}
|
||||
for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id]
|
||||
delete draft.part[props.messageID]
|
||||
deleteMessageParts(draft, props.messageID)
|
||||
}),
|
||||
)
|
||||
return
|
||||
@@ -411,6 +766,42 @@ export function createServerSession(client: OpencodeClient) {
|
||||
case "message.part.updated": {
|
||||
const part = (event.properties as { part: Part }).part
|
||||
if (SKIP_PARTS.has(part.type)) return
|
||||
const messages = data.message[part.sessionID]
|
||||
const load = messageLoads.get(part.sessionID)
|
||||
const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
|
||||
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
|
||||
if (
|
||||
missing &&
|
||||
(!load ||
|
||||
load.clearedMessageParts.has(part.messageID) ||
|
||||
removedMessages.get(part.sessionID)?.has(part.messageID))
|
||||
)
|
||||
return
|
||||
if (missing) {
|
||||
const orphans = orphanParts.get(part.sessionID) ?? new Set<string>()
|
||||
orphans.add(part.messageID)
|
||||
orphanParts.set(part.sessionID, orphans)
|
||||
load?.orphanParents.add(part.messageID)
|
||||
}
|
||||
const deltas = load?.deltaParts.get(part.messageID)
|
||||
deltas?.delete(part.id)
|
||||
if (deltas?.size === 0) load?.deltaParts.delete(part.messageID)
|
||||
const carried = load?.carriedDeltaParts.get(part.messageID)
|
||||
carried?.delete(part.id)
|
||||
if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID)
|
||||
const removed = load?.removedParts.get(part.messageID)
|
||||
removed?.delete(part.id)
|
||||
if (removed?.size === 0) load?.removedParts.delete(part.messageID)
|
||||
const pending = pendingParts.get(part.sessionID)?.get(part.messageID)
|
||||
pending?.delete(part.id)
|
||||
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
|
||||
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
|
||||
const optimistic = load?.optimisticParts.get(part.messageID)
|
||||
optimistic?.delete(part.id)
|
||||
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
|
||||
deltaBases.delete(part.id)
|
||||
trackPartChange(part.sessionID, part.messageID, part.id)
|
||||
confirmOptimisticPart(part.sessionID, part.messageID, part)
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => void delete draft[part.id]),
|
||||
@@ -431,10 +822,34 @@ export function createServerSession(client: OpencodeClient) {
|
||||
return
|
||||
}
|
||||
case "message.part.removed": {
|
||||
const props = event.properties as { messageID: string; partID: string }
|
||||
const props = event.properties as { sessionID: string; messageID: string; partID: string }
|
||||
// Part removal is event-only on the server, so its tombstone lasts until a later update or eviction.
|
||||
const pending = pendingParts.get(props.sessionID) ?? new Map<string, Set<string>>()
|
||||
const parts = pending.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
pending.set(props.messageID, parts)
|
||||
pendingParts.set(props.sessionID, pending)
|
||||
const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID)
|
||||
deltas?.delete(props.partID)
|
||||
if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
const carried = load?.carriedDeltaParts.get(props.messageID)
|
||||
carried?.delete(props.partID)
|
||||
if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID)
|
||||
if (load) {
|
||||
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.removedParts.set(props.messageID, parts)
|
||||
const optimistic = load.optimisticParts.get(props.messageID)
|
||||
optimistic?.delete(props.partID)
|
||||
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
|
||||
}
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
|
||||
setData(
|
||||
produce((draft) => {
|
||||
delete draft.part_text_accum_delta[props.partID]
|
||||
deltaBases.delete(props.partID)
|
||||
const parts = draft.part[props.messageID]
|
||||
if (!parts) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
@@ -445,13 +860,31 @@ export function createServerSession(client: OpencodeClient) {
|
||||
return
|
||||
}
|
||||
case "message.part.delta": {
|
||||
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
|
||||
const props = event.properties as {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
partID: string
|
||||
field: string
|
||||
delta: string
|
||||
}
|
||||
const parts = data.part[props.messageID]
|
||||
if (!parts) return
|
||||
const result = Binary.search(parts, props.partID, (part) => part.id)
|
||||
if (!result.found) return
|
||||
trackPartChange(props.sessionID, props.messageID, props.partID)
|
||||
const load = messageLoads.get(props.sessionID)
|
||||
if (load) {
|
||||
const parts = load.deltaParts.get(props.messageID) ?? new Set<string>()
|
||||
parts.add(props.partID)
|
||||
load.deltaParts.set(props.messageID, parts)
|
||||
const carried = load.carriedDeltaParts.get(props.messageID)
|
||||
carried?.delete(props.partID)
|
||||
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
|
||||
}
|
||||
const field = props.field as keyof (typeof parts)[number]
|
||||
const current = parts[result.index]?.[field]
|
||||
if (!deltaBases.has(props.partID) && typeof current === "string")
|
||||
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
|
||||
setData(
|
||||
"part_text_accum_delta",
|
||||
props.partID,
|
||||
@@ -559,32 +992,70 @@ export function createServerSession(client: OpencodeClient) {
|
||||
},
|
||||
optimistic: {
|
||||
add(input: { sessionID: string; message: Message; parts: Part[] }) {
|
||||
const parts = input.parts
|
||||
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
const load = messageLoads.get(input.sessionID)
|
||||
if (load?.clearedMessageParts.has(input.message.id)) {
|
||||
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
|
||||
parts.forEach((part) => touched.add(part.id))
|
||||
load.touchedParts.set(input.message.id, touched)
|
||||
}
|
||||
if (load) {
|
||||
load.removedMessages.delete(input.message.id)
|
||||
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
|
||||
}
|
||||
const items = optimistic.get(input.sessionID)
|
||||
if (items) items.set(input.message.id, input)
|
||||
if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]]))
|
||||
const removedMessagesForSession = removedMessages.get(input.sessionID)
|
||||
removedMessagesForSession?.delete(input.message.id)
|
||||
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
|
||||
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
|
||||
if (!items)
|
||||
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
|
||||
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
|
||||
setData(
|
||||
"part",
|
||||
input.message.id,
|
||||
input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
"part_text_accum_delta",
|
||||
produce((draft) => {
|
||||
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
|
||||
delete draft[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
setData("part", input.message.id, parts)
|
||||
},
|
||||
remove(input: { sessionID: string; messageID: string }) {
|
||||
const item = optimistic.get(input.sessionID)?.get(input.messageID)
|
||||
if (!item) return
|
||||
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
|
||||
clearOptimistic(input.sessionID, input.messageID)
|
||||
if (item.confirmedMessage) {
|
||||
const partIDs = new Set(item.parts.map((part) => part.id))
|
||||
setData(
|
||||
produce((draft) => {
|
||||
for (const part of item.parts) {
|
||||
delete draft.part_text_accum_delta[part.id]
|
||||
deltaBases.delete(part.id)
|
||||
}
|
||||
const parts = draft.part[input.messageID]
|
||||
if (!parts) return
|
||||
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
|
||||
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
|
||||
setData(
|
||||
"part",
|
||||
produce((draft) => void delete draft[input.messageID]),
|
||||
)
|
||||
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
|
||||
},
|
||||
},
|
||||
diff(sessionID: string, options?: { force?: boolean }) {
|
||||
touch(sessionID)
|
||||
if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightDiff, sessionID, () => {
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.diff({ sessionID })).then((result) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" }))
|
||||
})
|
||||
})
|
||||
@@ -593,9 +1064,9 @@ export function createServerSession(client: OpencodeClient) {
|
||||
touch(sessionID)
|
||||
if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve()
|
||||
return runInflight(inflightTodo, sessionID, () => {
|
||||
const generation = generations.get(sessionID) ?? 0
|
||||
const active = generation(sessionID)
|
||||
return retry(() => client.session.todo({ sessionID })).then((result) => {
|
||||
if ((generations.get(sessionID) ?? 0) !== generation) return
|
||||
if (generations.get(sessionID) !== active) return
|
||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -163,27 +163,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
|
||||
const draftID = uuid()
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
tabs.push({ type: "draft", draftID, ...draft })
|
||||
}),
|
||||
)
|
||||
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
|
||||
})
|
||||
},
|
||||
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
||||
produce((tab) => Object.assign(tab, draft)),
|
||||
)
|
||||
})
|
||||
},
|
||||
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
||||
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
|
||||
// replaces the draft tab with a session tab, so the draft route would stop resolving
|
||||
// and fall back home. Navigate to the new session first so we leave /new-session
|
||||
// before the draft is removed from the store.
|
||||
// Keep the replacement and navigation atomic so /new-session never renders
|
||||
// after its backing draft tab has been removed from the store.
|
||||
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
||||
const next = { type: "session" as const, ...session }
|
||||
startTransition(() => {
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "@opencode-ai/ui/styles/tailwind";
|
||||
@import "@opencode-ai/session-ui/styles";
|
||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
@@ -131,13 +132,4 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const SHOW_HOME_SESSION_ARCHIVE = false
|
||||
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"
|
||||
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
|
||||
@@ -301,6 +302,7 @@ export function NewHome() {
|
||||
|
||||
function selectProject(conn: ServerConnection.Any, directory: string) {
|
||||
const key = ServerConnection.key(conn)
|
||||
if (global.servers.health[key]?.healthy === false) return
|
||||
if (
|
||||
!global
|
||||
.ensureServerCtx(conn)
|
||||
@@ -341,15 +343,15 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (ServerConnection.key(conn) !== server.key) return 0
|
||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
|
||||
}
|
||||
|
||||
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
|
||||
if (ServerConnection.key(conn) !== server.key) return
|
||||
const state = notification.ensureServerState(ServerConnection.key(conn))
|
||||
directories(project)
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
.filter((directory) => state.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
@@ -407,7 +409,7 @@ export function NewHome() {
|
||||
|
||||
return (
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 pb-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 lg:pb-16">
|
||||
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
selected={selection()}
|
||||
@@ -454,7 +456,7 @@ export function NewHome() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
@@ -467,7 +469,7 @@ export function NewHome() {
|
||||
when={groups().length > 0}
|
||||
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
|
||||
>
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-6 pt-3 pr-3 pb-16">
|
||||
<For each={groups()}>
|
||||
{(group, index) => (
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
@@ -766,15 +768,18 @@ function HomeProjectRow(props: {
|
||||
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
return (
|
||||
<div class="group/project relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-project-row"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
aria-current={props.selected ? "page" : undefined}
|
||||
disabled={serverUnreachable()}
|
||||
onClick={() => props.selectProject(props.server, props.project.worktree)}
|
||||
>
|
||||
<HomeProjectAvatar project={props.project} />
|
||||
@@ -964,7 +969,7 @@ function HomeSessionSearch(props: {
|
||||
style={{
|
||||
top: "-6px",
|
||||
left: "-6px",
|
||||
width: "calc(100% + 14px)",
|
||||
width: "calc(100% + 12px)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col pt-9">
|
||||
@@ -1132,7 +1137,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-[18px]">
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
@@ -1189,22 +1194,24 @@ function HomeSessionRow(props: {
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<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")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
<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">
|
||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={language.t("common.archive")}>
|
||||
<IconButtonV2
|
||||
data-action="home-session-archive"
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="archive" />}
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.archiveSession(props.record.session)
|
||||
}}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { HelpButton } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setNavigate } from "@/utils/notification-click"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const notification = useNotification()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams<{ id?: string }>()
|
||||
setNavigate(navigate)
|
||||
|
||||
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 = {
|
||||
version: () => {
|
||||
@@ -44,7 +36,7 @@ export default function NewLayout(props: ParentProps) {
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && <DebugBar />}
|
||||
{import.meta.env.DEV && <DebugBar inline />}
|
||||
<HelpButton />
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# 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
|
||||
|
||||
# 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` for most debugging so the TUI starts with a private V2 server.
|
||||
- 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
|
||||
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, use `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
|
||||
```
|
||||
|
||||
## Server/API debugging
|
||||
|
||||
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
|
||||
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
|
||||
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
|
||||
|
||||
```bash
|
||||
bun dev api get /health
|
||||
bun dev api get /openapi.json
|
||||
bun dev api <operationId> --param key=value
|
||||
```
|
||||
|
||||
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
|
||||
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
|
||||
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
- 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 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 arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch()
|
||||
const base = "@opencode-ai/cli-" + platform + "-" + arch
|
||||
const binary = platform === "windows" ? "lildax.exe" : "lildax"
|
||||
const binary = platform === "windows" ? "opencode2.exe" : "opencode2"
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
@@ -121,7 +121,7 @@ function findBinary(startDir) {
|
||||
const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir))
|
||||
if (!resolved) {
|
||||
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 ") +
|
||||
" package",
|
||||
)
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs"
|
||||
"opencode2": "./bin/opencode2.cjs"
|
||||
},
|
||||
"files": [
|
||||
"bin"
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
@@ -25,12 +26,15 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "lildax"
|
||||
const binary = "opencode2"
|
||||
process.chdir(dir)
|
||||
|
||||
await rm("dist", { recursive: true, force: true })
|
||||
|
||||
@@ -25,14 +25,15 @@ for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" }
|
||||
}
|
||||
console.log("binaries", binaries)
|
||||
const version = Object.values(binaries)[0]
|
||||
const name = pkg.name
|
||||
|
||||
await $`mkdir -p ./dist/${pkg.name}/bin`
|
||||
await $`cp ./bin/lildax.cjs ./dist/${pkg.name}/bin/lildax`
|
||||
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
await $`mkdir -p ./dist/${name}/bin`
|
||||
await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2`
|
||||
await Bun.file(`./dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: pkg.name,
|
||||
bin: { lildax: "./bin/lildax" },
|
||||
name,
|
||||
bin: { opencode2: "./bin/opencode2" },
|
||||
version,
|
||||
license: pkg.license,
|
||||
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),
|
||||
),
|
||||
)
|
||||
await publish(`./dist/${pkg.name}`, pkg.name, version)
|
||||
await publish(`./dist/${name}`, name, version)
|
||||
|
||||
@@ -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", {
|
||||
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: [
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
@@ -26,6 +36,43 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [Spec.make("agents", { description: "List all agents" })],
|
||||
}),
|
||||
Spec.make("mcp", {
|
||||
description: "Manage MCP (Model Context Protocol) servers",
|
||||
commands: [
|
||||
Spec.make("list", { description: "List configured MCP servers and their status" }),
|
||||
Spec.make("add", {
|
||||
description: "Add an MCP server to your configuration",
|
||||
params: {
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.string("command").pipe(
|
||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.keyValuePair("header").pipe(
|
||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
env: Flag.keyValuePair("env").pipe(
|
||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
global: Flag.boolean("global").pipe(
|
||||
Flag.withDescription("Write to the global config instead of the project config"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("service", {
|
||||
description: "Manage the background server",
|
||||
@@ -34,18 +81,27 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Spec.make("restart", { description: "Restart the background server" }),
|
||||
Spec.make("status", { description: "Show background server status" }),
|
||||
Spec.make("stop", { description: "Stop the background server" }),
|
||||
Spec.make("password", {
|
||||
description: "Get or set the server password",
|
||||
params: { value: Argument.string("value").pipe(Argument.optional) },
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: { key: Argument.string("key").pipe(Argument.optional) },
|
||||
}),
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: { key: Argument.string("key"), value: Argument.string("value") },
|
||||
}),
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: { key: Argument.string("key") },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.withDefault("127.0.0.1")),
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
register: Flag.boolean("register").pipe(Flag.withDefault(false)),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Standalone } from "../../services/standalone"
|
||||
import { Updater } from "../../services/updater"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = Option.getOrUndefined(input.directory)
|
||||
if (directory !== undefined) process.chdir(directory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check()
|
||||
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"))
|
||||
yield* runTui(transport)
|
||||
yield* runTui(
|
||||
transport,
|
||||
input.standalone
|
||||
? undefined
|
||||
: async () => {
|
||||
await Effect.runPromise(daemon.stop())
|
||||
return Effect.runPromise(daemon.transport())
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { Effect, Option } from "effect"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.add,
|
||||
Effect.fn("cli.mcp.add")(function* (input) {
|
||||
const url = Option.getOrUndefined(input.url)
|
||||
const headers = Option.getOrUndefined(input.header)
|
||||
const environment = Option.getOrUndefined(input.env)
|
||||
// The CLI framework strands `--` operands on the root command, so read the local server command
|
||||
// straight from argv after `--`. This also lets the command carry its own flags (e.g. `npx -y`).
|
||||
const dash = process.argv.indexOf("--")
|
||||
const command = dash === -1 ? [...input.command] : process.argv.slice(dash + 1)
|
||||
|
||||
const hasCommand = command.length > 0
|
||||
if (url && hasCommand)
|
||||
return yield* Effect.fail(new Error("Provide either --url <url> or a command after --, not both"))
|
||||
if (!url && !hasCommand) return yield* Effect.fail(new Error("Provide either --url <url> or a command after --"))
|
||||
if (url && !URL.canParse(url)) return yield* Effect.fail(new Error(`Invalid URL: ${url}`))
|
||||
if (url && environment) return yield* Effect.fail(new Error("--env is only valid for local MCP servers"))
|
||||
if (hasCommand && headers) return yield* Effect.fail(new Error("--header is only valid for remote MCP servers"))
|
||||
|
||||
const server = url
|
||||
? { type: "remote" as const, url, ...(headers ? { headers } : {}) }
|
||||
: { type: "local" as const, command, ...(environment ? { environment } : {}) }
|
||||
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(input.global ? Global.Path.config : process.cwd()))
|
||||
yield* Effect.promise(() => write(configPath, input.name, server))
|
||||
process.stdout.write(`MCP server "${input.name}" added to ${configPath}` + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
async function resolveConfigPath(directory: string) {
|
||||
const candidates = [
|
||||
path.join(directory, "opencode.json"),
|
||||
path.join(directory, "opencode.jsonc"),
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
if (await Bun.file(candidate).exists()) return candidate
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
async function write(configPath: string, name: string, server: unknown) {
|
||||
const file = Bun.file(configPath)
|
||||
const text = (await file.exists()) ? await file.text() : "{}"
|
||||
const edits = modify(text, ["mcp", "servers", name], server, {
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
})
|
||||
await Bun.write(configPath, applyEdits(text, edits))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import type { IntegrationAttemptStatus, IntegrationOAuthMethod, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.auth,
|
||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
const method = integration.methods.find(
|
||||
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
|
||||
)
|
||||
if (!method)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.v2.integration.connect.oauth({ integrationID: integration.id, methodID: method.id, inputs: {}, location }),
|
||||
)
|
||||
const attempt = started.data?.data
|
||||
if (!attempt) return yield* Effect.fail(new Error(started.error?.message ?? "Failed to start OAuth attempt"))
|
||||
if (attempt.mode === "code")
|
||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
||||
|
||||
process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
|
||||
|
||||
const result = yield* poll(client, attempt.attemptID)
|
||||
if (result.status === "complete") {
|
||||
process.stdout.write(`Authenticated with ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
const reason = result.status === "failed" ? `: ${result.message}` : ""
|
||||
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
|
||||
}),
|
||||
)
|
||||
|
||||
const poll = (
|
||||
client: OpencodeClient,
|
||||
attemptID: string,
|
||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => client.v2.integration.attempt.status({ attemptID, location }))
|
||||
const status = response.data?.data
|
||||
if (!status || status.status === "pending") {
|
||||
yield* Effect.sleep("1 second")
|
||||
return yield* poll(client, attemptID)
|
||||
}
|
||||
return status
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EOL } from "node:os"
|
||||
import * as Effect from "effect/Effect"
|
||||
import type { McpServer } from "@opencode-ai/sdk/v2/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
const response = yield* Effect.promise(() => client.v2.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = (response.data?.data ?? []).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (servers.length === 0) {
|
||||
process.stdout.write("No MCP servers configured" + EOL)
|
||||
return
|
||||
}
|
||||
const width = Math.max(...servers.map((server) => server.name.length))
|
||||
const lines = servers.map(
|
||||
(server) => `${icon(server.status)} ${server.name.padEnd(width)} ${describe(server.status)}`,
|
||||
)
|
||||
process.stdout.write(lines.join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
function icon(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "connected":
|
||||
return "✓"
|
||||
case "needs_auth":
|
||||
return "⚠"
|
||||
case "failed":
|
||||
case "needs_client_registration":
|
||||
return "✗"
|
||||
default:
|
||||
return "○"
|
||||
}
|
||||
}
|
||||
|
||||
function describe(status: McpServer["status"]) {
|
||||
switch (status.status) {
|
||||
case "needs_auth":
|
||||
return "needs authentication"
|
||||
case "needs_client_registration":
|
||||
return `needs client registration: ${status.error}`
|
||||
case "failed":
|
||||
return `failed: ${status.error}`
|
||||
default:
|
||||
return status.status
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const client = yield* daemon.client()
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration) {
|
||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const credentials = integration.connections.filter((connection) => connection.type === "credential")
|
||||
if (credentials.length === 0) {
|
||||
process.stdout.write(`No stored credentials for ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
credentials,
|
||||
(connection) => Effect.promise(() => client.v2.credential.remove({ credentialID: connection.id, location })),
|
||||
{ discard: true },
|
||||
)
|
||||
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Effect } from "effect"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
// Resolve through the MCP-owned integrationID rather than matching integration names: the shared
|
||||
// integration registry also holds provider/plugin integrations, whose names could collide with a server.
|
||||
// Fails when the server is unknown; returns undefined when the server has no integration (e.g. a local
|
||||
// or anonymous server), leaving that case for the caller to interpret.
|
||||
export const resolveIntegration = (client: OpencodeClient, name: string, location: { directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const servers = yield* Effect.promise(() => client.v2.mcp.list({ location }))
|
||||
const server = (servers.data?.data ?? []).find((entry) => entry.name === name)
|
||||
if (!server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
|
||||
const integrationID = server.integrationID
|
||||
if (!integrationID) return undefined
|
||||
const found = yield* Effect.promise(() => client.v2.integration.get({ integrationID, location }))
|
||||
return found.data?.data
|
||||
})
|
||||
@@ -1,14 +1,18 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import { Context, Layer, Option, Schedule } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
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 { Runtime } from "../../framework/runtime"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { randomBytes } from "crypto"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.serve,
|
||||
@@ -16,15 +20,53 @@ export default Runtime.handler(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const address = yield* listen(input.hostname, input.port, yield* daemon.password())
|
||||
if (input.register) yield* daemon.register(address)
|
||||
console.log(`server listening on ${HttpServer.formatAddress(address)}`)
|
||||
return yield* Effect.never
|
||||
}),
|
||||
const standalonePassword = process.env.OPENCODE_SERVER_PASSWORD
|
||||
if (input.stdio) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
const config = input.service ? yield* daemon.config() : {}
|
||||
const password = input.service
|
||||
? yield* daemon.password()
|
||||
: standalonePassword || randomBytes(32).toString("base64url")
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const hostname = Option.getOrUndefined(input.hostname) ?? config.hostname ?? "127.0.0.1"
|
||||
const port = Option.isSome(input.port)
|
||||
? input.port
|
||||
: config.port === undefined
|
||||
? Option.none<number>()
|
||||
: Option.some(config.port)
|
||||
const address = yield* listen(hostname, port, password)
|
||||
yield* Effect.tryPromise(() =>
|
||||
createOpencodeClient({
|
||||
baseUrl: HttpServer.formatAddress(address),
|
||||
headers: ServerAuth.headers({ password }),
|
||||
}).v2.health.get({}),
|
||||
)
|
||||
if (input.service) yield* daemon.register(address)
|
||||
const url = HttpServer.formatAddress(address)
|
||||
console.log(input.stdio ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (!input.service && !input.stdio && !standalonePassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
||||
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) {
|
||||
if (Option.isSome(port)) return bind(hostname, port.value, password)
|
||||
const next = (port: number): ReturnType<typeof bind> =>
|
||||
@@ -35,11 +77,15 @@ function listen(hostname: string, port: Option.Option<number>, password: string)
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number, password: string) {
|
||||
const server = createServer()
|
||||
return Layer.build(
|
||||
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(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),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-5
@@ -6,11 +6,9 @@ import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.password,
|
||||
Effect.fn("cli.service.password")(function* (input) {
|
||||
Commands.commands.service.commands.get,
|
||||
Effect.fn("cli.service.get")(function* (input) {
|
||||
const daemon = yield* Daemon.Service
|
||||
const value = Option.getOrUndefined(input.value)
|
||||
if (value !== undefined) yield* daemon.stop()
|
||||
process.stdout.write((yield* daemon.password(value)) + EOL)
|
||||
process.stdout.write((yield* daemon.get(Option.getOrUndefined(input.key))) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.set,
|
||||
Effect.fn("cli.service.set")(function* (input) {
|
||||
yield* (yield* Daemon.Service).set(input.key, input.value)
|
||||
}),
|
||||
)
|
||||
@@ -8,6 +8,6 @@ export default Runtime.handler(
|
||||
Commands.commands.service.commands.status,
|
||||
Effect.fn("cli.service.status")(function* () {
|
||||
const url = yield* (yield* Daemon.Service).status()
|
||||
process.stdout.write((url ? `running ${url}` : "stopped") + EOL)
|
||||
process.stdout.write((url ? url : "stopped") + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Daemon } from "../../../services/daemon"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.service.commands.unset,
|
||||
Effect.fn("cli.service.unset")(function* (input) {
|
||||
yield* (yield* Daemon.Service).unset(input.key)
|
||||
}),
|
||||
)
|
||||
@@ -2,6 +2,8 @@ import * as Effect from "effect/Effect"
|
||||
import * as Command from "effect/unstable/cli/Command"
|
||||
import { Spec } from "./spec"
|
||||
import { Daemon } from "../services/daemon"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Scope } from "effect"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -10,11 +12,11 @@ export type Input<Value> =
|
||||
? Input
|
||||
: never
|
||||
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service>
|
||||
type RuntimeHandler = (input: unknown) => Effect.Effect<void, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
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 | Updater.Service | Scope.Scope>
|
||||
}>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service>
|
||||
type ProvidedCommand = Command.Command<string, unknown, unknown, unknown, Daemon.Service | Updater.Service | Scope.Scope>
|
||||
|
||||
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never
|
||||
? Loader<Node>
|
||||
|
||||
@@ -2,10 +2,21 @@
|
||||
|
||||
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||
import * as NodeServices from "@effect/platform-node/NodeServices"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { Layer, Logger, References } from "effect"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
import { Logging } from "@opencode-ai/core/observability/logging"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
|
||||
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, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -13,20 +24,33 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
mcp: {
|
||||
list: () => import("./commands/handlers/mcp/list"),
|
||||
add: () => import("./commands/handlers/mcp/add"),
|
||||
auth: () => import("./commands/handlers/mcp/auth"),
|
||||
logout: () => import("./commands/handlers/mcp/logout"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
service: {
|
||||
start: () => import("./commands/handlers/service/start"),
|
||||
restart: () => import("./commands/handlers/service/restart"),
|
||||
status: () => import("./commands/handlers/service/status"),
|
||||
stop: () => import("./commands/handlers/service/stop"),
|
||||
password: () => import("./commands/handlers/service/password"),
|
||||
get: () => import("./commands/handlers/service/get"),
|
||||
set: () => import("./commands/handlers/service/set"),
|
||||
unset: () => import("./commands/handlers/service/unset"),
|
||||
},
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
|
||||
Effect.logInfo("cli starting", { version: InstallationVersion, channel: InstallationChannel, local: InstallationLocal }).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Daemon.defaultLayer),
|
||||
Effect.provide(Updater.defaultLayer),
|
||||
Effect.provide(LoggingLayer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(0))),
|
||||
NodeRuntime.runMain,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
@@ -15,6 +15,10 @@ export interface Interface {
|
||||
readonly status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
readonly password: (value?: string) => Effect.Effect<string, unknown>
|
||||
readonly config: () => Effect.Effect<ServiceConfig, unknown>
|
||||
readonly get: (key?: string) => Effect.Effect<string, unknown>
|
||||
readonly set: (key: string, value: string) => Effect.Effect<void, unknown>
|
||||
readonly unset: (key: string) => Effect.Effect<void, unknown>
|
||||
readonly register: (address: HttpServer.Address) => Effect.Effect<void, unknown, Scope.Scope>
|
||||
}
|
||||
|
||||
@@ -28,6 +32,22 @@ const Registration = Schema.Struct({
|
||||
})
|
||||
type Registration = typeof Registration.Type
|
||||
|
||||
const ServiceConfig = Schema.Struct({
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
autostart: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type ServiceConfig = typeof ServiceConfig.Type
|
||||
|
||||
const serviceConfigKeys = ["hostname", "port", "password", "autostart"] as const
|
||||
type ServiceConfigKey = (typeof serviceConfigKeys)[number]
|
||||
|
||||
function serviceConfigKey(key: string): ServiceConfigKey {
|
||||
if (serviceConfigKeys.includes(key as ServiceConfigKey)) return key as ServiceConfigKey
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
function sameRegistration(left: Registration, right: Registration) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
@@ -36,23 +56,115 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = Global.Path.state
|
||||
const file = path.join(directory, "server.json")
|
||||
const passwordFile = path.join(directory, "password")
|
||||
const global = yield* Global.Service
|
||||
const directory = global.state
|
||||
const filename = InstallationChannel === "local" ? "service-local.json" : "service.json"
|
||||
const file = path.join(directory, filename)
|
||||
const configFile = path.join(global.config, filename)
|
||||
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Registration))
|
||||
const decodeServiceConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(ServiceConfig))
|
||||
|
||||
const config = Effect.fn("cli.daemon.config")(function* () {
|
||||
return yield* fs.readFileString(configFile).pipe(
|
||||
Effect.flatMap(decodeServiceConfig),
|
||||
Effect.catch(() => Effect.succeed({} as ServiceConfig)),
|
||||
)
|
||||
})
|
||||
|
||||
const writeConfig = Effect.fn("cli.daemon.writeConfig")(function* (value: ServiceConfig) {
|
||||
const temp = configFile + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(configFile), { recursive: true })
|
||||
yield* fs.writeFileString(temp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 })
|
||||
yield* fs.rename(temp, configFile)
|
||||
})
|
||||
|
||||
const password = Effect.fn("cli.daemon.password")(function* (value?: string) {
|
||||
const existing = yield* fs.readFileString(passwordFile).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (value === undefined && existing) return existing
|
||||
const existing = yield* config()
|
||||
if (value === undefined && existing.password) return existing.password
|
||||
const next = value ?? randomBytes(32).toString("base64url")
|
||||
|
||||
// Keep one private credential across server restarts so discovered clients
|
||||
// can reconnect without exposing a password flag or environment variable.
|
||||
const generated = value ?? randomBytes(32).toString("base64url")
|
||||
const temp = passwordFile + ".tmp"
|
||||
yield* fs.makeDirectory(directory, { recursive: true })
|
||||
yield* fs.writeFileString(temp, generated, { mode: 0o600 })
|
||||
yield* fs.rename(temp, passwordFile)
|
||||
return generated
|
||||
yield* writeConfig({ ...existing, password: next })
|
||||
return next
|
||||
})
|
||||
|
||||
const get = Effect.fn("cli.daemon.get")(function* (key?: string) {
|
||||
if (key === undefined) {
|
||||
const { password: _password, ...safe } = yield* config()
|
||||
return JSON.stringify(safe, null, 2)
|
||||
}
|
||||
switch (serviceConfigKey(key)) {
|
||||
case "hostname": {
|
||||
return (yield* config()).hostname ?? ""
|
||||
}
|
||||
case "port": {
|
||||
const port = (yield* config()).port
|
||||
return port === undefined ? "" : String(port)
|
||||
}
|
||||
case "password": {
|
||||
return yield* password()
|
||||
}
|
||||
case "autostart": {
|
||||
const autostart = (yield* config()).autostart
|
||||
return autostart === undefined ? "" : String(autostart)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const set = Effect.fn("cli.daemon.set")(function* (key: string, value: string) {
|
||||
switch (serviceConfigKey(key)) {
|
||||
case "hostname": {
|
||||
yield* stop()
|
||||
yield* writeConfig({ ...(yield* config()), hostname: value })
|
||||
return
|
||||
}
|
||||
case "port": {
|
||||
const port = Number(value)
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) throw new Error("Port must be between 1 and 65535")
|
||||
yield* stop()
|
||||
yield* writeConfig({ ...(yield* config()), port })
|
||||
return
|
||||
}
|
||||
case "password": {
|
||||
yield* stop()
|
||||
yield* password(value)
|
||||
return
|
||||
}
|
||||
case "autostart": {
|
||||
if (value !== "true" && value !== "false") throw new Error("Autostart must be true or false")
|
||||
yield* writeConfig({ ...(yield* config()), autostart: value === "true" })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const unset = Effect.fn("cli.daemon.unset")(function* (key: string) {
|
||||
switch (serviceConfigKey(key)) {
|
||||
case "hostname": {
|
||||
yield* stop()
|
||||
const { hostname: _hostname, ...next } = yield* config()
|
||||
yield* writeConfig(next)
|
||||
return
|
||||
}
|
||||
case "port": {
|
||||
yield* stop()
|
||||
const { port: _port, ...next } = yield* config()
|
||||
yield* writeConfig(next)
|
||||
return
|
||||
}
|
||||
case "password": {
|
||||
yield* stop()
|
||||
const { password: _password, ...next } = yield* config()
|
||||
yield* writeConfig(next)
|
||||
return
|
||||
}
|
||||
case "autostart": {
|
||||
const { autostart: _autostart, ...next } = yield* config()
|
||||
yield* writeConfig(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const registration = Effect.fnUntraced(function* () {
|
||||
@@ -71,6 +183,16 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.fail(new Error("Registered server is not healthy"))
|
||||
})
|
||||
|
||||
const remoteTransport = Effect.fn("cli.daemon.remoteTransport")(function* (input: ServiceConfig) {
|
||||
const url = serviceURL(input)
|
||||
const headers = ServerAuth.headers({ password: input.password })
|
||||
const response = yield* Effect.tryPromise(() =>
|
||||
createOpencodeClient({ baseUrl: url, headers }).v2.health.get({ signal: AbortSignal.timeout(2_000) }),
|
||||
)
|
||||
if (response.data?.healthy === true) return { url, headers }
|
||||
return yield* Effect.fail(new Error(`Server is not healthy: ${url}`))
|
||||
})
|
||||
|
||||
const compatible = Effect.fnUntraced(function* () {
|
||||
const info = yield* healthy()
|
||||
if (info.version === InstallationVersion) return info
|
||||
@@ -111,7 +233,7 @@ export const layer = Layer.effect(
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
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)
|
||||
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
@@ -119,7 +241,7 @@ export const layer = Layer.effect(
|
||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
|
||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--service"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref()
|
||||
@@ -135,6 +257,8 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
||||
const current = yield* config()
|
||||
if (current.autostart === false) return yield* remoteTransport(current)
|
||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
||||
})
|
||||
|
||||
@@ -185,10 +309,17 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ client, transport, start, status, stop, password, register })
|
||||
return Service.of({ client, transport, start, status, stop, password, config, get, set, unset, register })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
function serviceURL(config: ServiceConfig) {
|
||||
const hostname = config.hostname ?? "127.0.0.1"
|
||||
const result = new URL(`http://${hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname}`)
|
||||
result.port = String(config.port ?? 4096)
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
export * as Daemon from "./daemon"
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { action, decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("skips when autoupdate is disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
||||
})
|
||||
|
||||
test("never automatically updates majors", () => {
|
||||
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import {
|
||||
InstallationChannel,
|
||||
InstallationLocal,
|
||||
InstallationVersion,
|
||||
} from "@opencode-ai/core/installation/version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import semver from "semver"
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName = "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
// The CLI only projects this host-level preference instead of initializing
|
||||
// the location-scoped server configuration graph.
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
|
||||
const value = input.autoupdate
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs
|
||||
.readFileString(path.join(global.config, name))
|
||||
.pipe(Effect.map(decodePolicy), Effect.catch(() => Effect.succeed(undefined))),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? true
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
maxOutputBytes: 100_000,
|
||||
maxErrorBytes: 100_000,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
code: result.exitCode,
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.catch(() => Effect.succeed({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(packageName)}/${encodeURIComponent(InstallationChannel)}`,
|
||||
{ headers: { "User-Agent": `opencode/${InstallationVersion}` }, signal: AbortSignal.timeout(10_000) },
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
})
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Method, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "install", "--global", target],
|
||||
bun: ["bun", "install", "--global", target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* run(commands[method], "5 minutes")
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (InstallationLocal || Flag.OPENCODE_DISABLE_AUTOUPDATE)
|
||||
return yield* Effect.logInfo("update check skipped", {
|
||||
reason: InstallationLocal ? "local-install" : "disabled",
|
||||
version: InstallationVersion,
|
||||
channel: InstallationChannel,
|
||||
})
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: InstallationVersion,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(InstallationVersion, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: InstallationVersion, to: version, method: detected })
|
||||
})
|
||||
}, Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })))
|
||||
|
||||
return Service.of({ check })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer), Layer.provide(Global.defaultLayer))
|
||||
|
||||
export * as Updater from "./updater"
|
||||
+39
-28
@@ -2,35 +2,46 @@ import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
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 })
|
||||
return run({
|
||||
...transport,
|
||||
args: {},
|
||||
config,
|
||||
fetch: gracefulFetch,
|
||||
pluginHost: {
|
||||
async start() {},
|
||||
async dispose() {},
|
||||
},
|
||||
let disposeSlots: (() => void) | undefined
|
||||
return Effect.gen(function* () {
|
||||
const options = { baseUrl: transport.url, headers: transport.headers }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() =>
|
||||
Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory)),
|
||||
),
|
||||
)
|
||||
return yield* run({
|
||||
client: createOpencodeClient({ ...options, directory }),
|
||||
api,
|
||||
reload: reload
|
||||
? async () => {
|
||||
const next = await reload()
|
||||
return {
|
||||
client: createOpencodeClient({ baseUrl: next.url, headers: next.headers, directory }),
|
||||
api: OpenCode.make({ baseUrl: next.url, headers: next.headers }),
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
args: {},
|
||||
config,
|
||||
pluginHost: {
|
||||
async start(input) {
|
||||
disposeSlots = await loadBuiltinPlugins(input.api, input.runtime)
|
||||
},
|
||||
async dispose() {
|
||||
disposeSlots?.()
|
||||
},
|
||||
},
|
||||
})
|
||||
}).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,30 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Daemon } from "../src/services/daemon"
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-daemon-"))
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
yield* daemon.set("autostart", "false")
|
||||
}).pipe(
|
||||
Effect.provide(Daemon.layer),
|
||||
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(await Bun.file(path.join(root, "config", "service-local.json")).json()).toEqual({
|
||||
autostart: false,
|
||||
})
|
||||
expect(await Bun.file(path.join(root, "config", "service.json")).exists()).toBe(false)
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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/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 standard HTTP 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. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. 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.
|
||||
|
||||
|
||||
@@ -1,23 +1,41 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { compile, emitEffectImported, emitEffectShape, emitPromise, write } from "@opencode-ai/httpapi-codegen"
|
||||
import {
|
||||
ClientApi,
|
||||
effectOmitEndpoints,
|
||||
endpointNames,
|
||||
groupNames,
|
||||
promiseOmitEndpoints,
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), {
|
||||
groupNames: { "server.session": "sessions" },
|
||||
})
|
||||
const promiseContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: promiseOmitEndpoints })
|
||||
const effectContract = compile(ClientApi, { groupNames, endpointNames, omitEndpoints: effectOmitEndpoints })
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
[
|
||||
write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))),
|
||||
write(
|
||||
emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }),
|
||||
emitPromise(promiseContract, {
|
||||
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(effectContract, { module: "../contract", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../src/generated-effect", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectShape(effectContract, { module: "@opencode-ai/protocol/client", api: "ClientApi" }),
|
||||
fileURLToPath(new URL("../../plugin/src/v2/effect/generated", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 2, discard: true },
|
||||
{ concurrency: 3, discard: true },
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
|
||||
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
|
||||
"@opencode-ai/client/LocationMiddleware",
|
||||
) {}
|
||||
|
||||
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
|
||||
"@opencode-ai/client/SessionLocationMiddleware",
|
||||
{ error: [InvalidRequestError, SessionNotFoundError] },
|
||||
) {}
|
||||
|
||||
const Api = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
export const SessionGroup = Api.groups["server.session"]
|
||||
export {
|
||||
ClientApi,
|
||||
effectOmitEndpoints,
|
||||
endpointNames,
|
||||
groupNames,
|
||||
promiseOmitEndpoints,
|
||||
} from "@opencode-ai/protocol/client"
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
|
||||
export * from "./generated-effect/index"
|
||||
export { Agent } from "@opencode-ai/schema/agent"
|
||||
export { Command } from "@opencode-ai/schema/command"
|
||||
export { Credential } from "@opencode-ai/schema/credential"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionInput } from "@opencode-ai/schema/session-input"
|
||||
export { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
export { Skill } from "@opencode-ai/schema/skill"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
|
||||
@@ -2,202 +2,851 @@
|
||||
import { Effect, Stream, Schema } from "effect"
|
||||
import { Sse } from "effect/unstable/encoding"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { SessionGroup } from "../contract"
|
||||
import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../contract"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
const Api = HttpApi.make("generated").add(SessionGroup)
|
||||
|
||||
type RawClient = HttpApiClient.ForApi<typeof Api>
|
||||
type RawClient = HttpApiClient.ForApi<typeof ClientApi>
|
||||
|
||||
const mapClientError = <E>(error: E) =>
|
||||
HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error)
|
||||
? new ClientError({ cause: error })
|
||||
: error
|
||||
|
||||
type Endpoint0_0Request = Parameters<RawClient["server.session"]["session.list"]>[0]
|
||||
type Endpoint0_0Input = {
|
||||
readonly workspace?: Endpoint0_0Request["query"]["workspace"]
|
||||
readonly limit?: Endpoint0_0Request["query"]["limit"]
|
||||
readonly order?: Endpoint0_0Request["query"]["order"]
|
||||
readonly search?: Endpoint0_0Request["query"]["search"]
|
||||
readonly directory?: Endpoint0_0Request["query"]["directory"]
|
||||
readonly project?: Endpoint0_0Request["query"]["project"]
|
||||
readonly subpath?: Endpoint0_0Request["query"]["subpath"]
|
||||
readonly cursor?: Endpoint0_0Request["query"]["cursor"]
|
||||
const Endpoint0_0 = (raw: RawClient["server.health"]) => () =>
|
||||
raw["health.get"]({}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.health"]) => ({ get: Endpoint0_0(raw) })
|
||||
|
||||
type Endpoint1_0Request = Parameters<RawClient["server.location"]["location.get"]>[0]
|
||||
type Endpoint1_0Input = { readonly location?: Endpoint1_0Request["query"]["location"] }
|
||||
const Endpoint1_0 = (raw: RawClient["server.location"]) => (input?: Endpoint1_0Input) =>
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
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"]({
|
||||
query: {
|
||||
workspace: input?.workspace,
|
||||
limit: input?.limit,
|
||||
order: input?.order,
|
||||
search: input?.search,
|
||||
directory: input?.directory,
|
||||
project: input?.project,
|
||||
subpath: input?.subpath,
|
||||
cursor: input?.cursor,
|
||||
workspace: input?.["workspace"],
|
||||
limit: input?.["limit"],
|
||||
order: input?.["order"],
|
||||
search: input?.["search"],
|
||||
directory: input?.["directory"],
|
||||
project: input?.["project"],
|
||||
subpath: input?.["subpath"],
|
||||
cursor: input?.["cursor"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
type Endpoint0_1Input = {
|
||||
readonly id?: Endpoint0_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint0_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint0_1Request["payload"]["model"]
|
||||
readonly location?: Endpoint0_1Request["payload"]["location"]
|
||||
type Endpoint3_1Request = Parameters<RawClient["server.session"]["session.create"]>[0]
|
||||
type Endpoint3_1Input = {
|
||||
readonly id?: Endpoint3_1Request["payload"]["id"]
|
||||
readonly agent?: Endpoint3_1Request["payload"]["agent"]
|
||||
readonly model?: Endpoint3_1Request["payload"]["model"]
|
||||
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"]({
|
||||
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(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const Endpoint0_2 = (raw: RawClient["server.session"]) => () =>
|
||||
const Endpoint3_2 = (raw: RawClient["server.session"]) => () =>
|
||||
raw["session.active"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint0_3Input = { readonly sessionID: Endpoint0_3Request["params"]["sessionID"] }
|
||||
const Endpoint0_3 = (raw: RawClient["server.session"]) => (input: Endpoint0_3Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
type Endpoint3_3Request = Parameters<RawClient["server.session"]["session.get"]>[0]
|
||||
type Endpoint3_3Input = { readonly sessionID: Endpoint3_3Request["params"]["sessionID"] }
|
||||
const Endpoint3_3 = (raw: RawClient["server.session"]) => (input: Endpoint3_3Input) =>
|
||||
raw["session.get"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_4Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint0_4Input = {
|
||||
readonly sessionID: Endpoint0_4Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint0_4Request["payload"]["agent"]
|
||||
type Endpoint3_4Request = Parameters<RawClient["server.session"]["session.fork"]>[0]
|
||||
type Endpoint3_4Input = {
|
||||
readonly sessionID: Endpoint3_4Request["params"]["sessionID"]
|
||||
readonly messageID?: Endpoint3_4Request["payload"]["messageID"]
|
||||
}
|
||||
const Endpoint0_4 = (raw: RawClient["server.session"]) => (input: Endpoint0_4Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input.sessionID }, payload: { agent: input.agent } }).pipe(
|
||||
const Endpoint3_4 = (raw: RawClient["server.session"]) => (input: Endpoint3_4Input) =>
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint3_5Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
|
||||
type Endpoint3_5Input = {
|
||||
readonly sessionID: Endpoint3_5Request["params"]["sessionID"]
|
||||
readonly agent: Endpoint3_5Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint3_5 = (raw: RawClient["server.session"]) => (input: Endpoint3_5Input) =>
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_5Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint0_5Input = {
|
||||
readonly sessionID: Endpoint0_5Request["params"]["sessionID"]
|
||||
readonly model: Endpoint0_5Request["payload"]["model"]
|
||||
type Endpoint3_6Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
|
||||
type Endpoint3_6Input = {
|
||||
readonly sessionID: Endpoint3_6Request["params"]["sessionID"]
|
||||
readonly model: Endpoint3_6Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint0_5 = (raw: RawClient["server.session"]) => (input: Endpoint0_5Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input.sessionID }, payload: { model: input.model } }).pipe(
|
||||
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint0_6Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint0_6Input = {
|
||||
readonly sessionID: Endpoint0_6Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint0_6Request["payload"]["id"]
|
||||
readonly prompt: Endpoint0_6Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint0_6Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint0_6Request["payload"]["resume"]
|
||||
type Endpoint3_7Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
|
||||
type Endpoint3_7Input = {
|
||||
readonly sessionID: Endpoint3_7Request["params"]["sessionID"]
|
||||
readonly title: Endpoint3_7Request["payload"]["title"]
|
||||
}
|
||||
const Endpoint0_6 = (raw: RawClient["server.session"]) => (input: Endpoint0_6Input) =>
|
||||
const Endpoint3_7 = (raw: RawClient["server.session"]) => (input: Endpoint3_7Input) =>
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint3_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
|
||||
type Endpoint3_8Input = {
|
||||
readonly sessionID: Endpoint3_8Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_8Request["payload"]["id"]
|
||||
readonly prompt: Endpoint3_8Request["payload"]["prompt"]
|
||||
readonly delivery?: Endpoint3_8Request["payload"]["delivery"]
|
||||
readonly resume?: Endpoint3_8Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint3_8 = (raw: RawClient["server.session"]) => (input: Endpoint3_8Input) =>
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { id: input.id, prompt: input.prompt, delivery: input.delivery, resume: input.resume },
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_7Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint0_7Input = { readonly sessionID: Endpoint0_7Request["params"]["sessionID"] }
|
||||
const Endpoint0_7 = (raw: RawClient["server.session"]) => (input: Endpoint0_7Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_8Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint0_8Input = { readonly sessionID: Endpoint0_8Request["params"]["sessionID"] }
|
||||
const Endpoint0_8 = (raw: RawClient["server.session"]) => (input: Endpoint0_8Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_9Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint0_9Input = {
|
||||
readonly sessionID: Endpoint0_9Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_9Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint0_9Request["payload"]["files"]
|
||||
type Endpoint3_9Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
|
||||
type Endpoint3_9Input = {
|
||||
readonly sessionID: Endpoint3_9Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint3_9Request["payload"]["id"]
|
||||
readonly skill: Endpoint3_9Request["payload"]["skill"]
|
||||
readonly resume?: Endpoint3_9Request["payload"]["resume"]
|
||||
}
|
||||
const Endpoint0_9 = (raw: RawClient["server.session"]) => (input: Endpoint0_9Input) =>
|
||||
const Endpoint3_9 = (raw: RawClient["server.session"]) => (input: Endpoint3_9Input) =>
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_10Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
|
||||
type Endpoint3_10Input = { readonly sessionID: Endpoint3_10Request["params"]["sessionID"] }
|
||||
const Endpoint3_10 = (raw: RawClient["server.session"]) => (input: Endpoint3_10Input) =>
|
||||
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_11Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
|
||||
type Endpoint3_11Input = { readonly sessionID: Endpoint3_11Request["params"]["sessionID"] }
|
||||
const Endpoint3_11 = (raw: RawClient["server.session"]) => (input: Endpoint3_11Input) =>
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_12Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
|
||||
type Endpoint3_12Input = {
|
||||
readonly sessionID: Endpoint3_12Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_12Request["payload"]["messageID"]
|
||||
readonly files?: Endpoint3_12Request["payload"]["files"]
|
||||
}
|
||||
const Endpoint3_12 = (raw: RawClient["server.session"]) => (input: Endpoint3_12Input) =>
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input.sessionID },
|
||||
payload: { messageID: input.messageID, files: input.files },
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_10Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint0_10Input = { readonly sessionID: Endpoint0_10Request["params"]["sessionID"] }
|
||||
const Endpoint0_10 = (raw: RawClient["server.session"]) => (input: Endpoint0_10Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_13Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
|
||||
type Endpoint3_13Input = { readonly sessionID: Endpoint3_13Request["params"]["sessionID"] }
|
||||
const Endpoint3_13 = (raw: RawClient["server.session"]) => (input: Endpoint3_13Input) =>
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_11Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint0_11Input = { readonly sessionID: Endpoint0_11Request["params"]["sessionID"] }
|
||||
const Endpoint0_11 = (raw: RawClient["server.session"]) => (input: Endpoint0_11Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_14Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
|
||||
type Endpoint3_14Input = { readonly sessionID: Endpoint3_14Request["params"]["sessionID"] }
|
||||
const Endpoint3_14 = (raw: RawClient["server.session"]) => (input: Endpoint3_14Input) =>
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_12Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint0_12Input = { readonly sessionID: Endpoint0_12Request["params"]["sessionID"] }
|
||||
const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input.sessionID } }).pipe(
|
||||
type Endpoint3_15Request = Parameters<RawClient["server.session"]["session.context"]>[0]
|
||||
type Endpoint3_15Input = { readonly sessionID: Endpoint3_15Request["params"]["sessionID"] }
|
||||
const Endpoint3_15 = (raw: RawClient["server.session"]) => (input: Endpoint3_15Input) =>
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint0_13Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint0_13Input = {
|
||||
readonly sessionID: Endpoint0_13Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint0_13Request["query"]["after"]
|
||||
type Endpoint3_16Request = Parameters<RawClient["server.session"]["session.history"]>[0]
|
||||
type Endpoint3_16Input = {
|
||||
readonly sessionID: Endpoint3_16Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint3_16Request["query"]["limit"]
|
||||
readonly after?: Endpoint3_16Request["query"]["after"]
|
||||
}
|
||||
const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) =>
|
||||
const Endpoint3_16 = (raw: RawClient["server.session"]) => (input: Endpoint3_16Input) =>
|
||||
raw["session.history"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { limit: input["limit"], after: input["after"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint3_17Request = Parameters<RawClient["server.session"]["session.events"]>[0]
|
||||
type Endpoint3_17Input = {
|
||||
readonly sessionID: Endpoint3_17Request["params"]["sessionID"]
|
||||
readonly after?: Endpoint3_17Request["query"]["after"]
|
||||
}
|
||||
const Endpoint3_17 = (raw: RawClient["server.session"]) => (input: Endpoint3_17Input) =>
|
||||
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.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
type Endpoint0_14Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] }
|
||||
const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError))
|
||||
type Endpoint3_18Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
|
||||
type Endpoint3_18Input = { readonly sessionID: Endpoint3_18Request["params"]["sessionID"] }
|
||||
const Endpoint3_18 = (raw: RawClient["server.session"]) => (input: Endpoint3_18Input) =>
|
||||
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint0_15Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint0_15Input = {
|
||||
readonly sessionID: Endpoint0_15Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint0_15Request["params"]["messageID"]
|
||||
type Endpoint3_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
|
||||
type Endpoint3_19Input = {
|
||||
readonly sessionID: Endpoint3_19Request["params"]["sessionID"]
|
||||
readonly messageID: Endpoint3_19Request["params"]["messageID"]
|
||||
}
|
||||
const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe(
|
||||
const Endpoint3_19 = (raw: RawClient["server.session"]) => (input: Endpoint3_19Input) =>
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const adaptGroup0 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint0_0(raw),
|
||||
create: Endpoint0_1(raw),
|
||||
active: Endpoint0_2(raw),
|
||||
get: Endpoint0_3(raw),
|
||||
switchAgent: Endpoint0_4(raw),
|
||||
switchModel: Endpoint0_5(raw),
|
||||
prompt: Endpoint0_6(raw),
|
||||
compact: Endpoint0_7(raw),
|
||||
wait: Endpoint0_8(raw),
|
||||
stage: Endpoint0_9(raw),
|
||||
clear: Endpoint0_10(raw),
|
||||
commit: Endpoint0_11(raw),
|
||||
context: Endpoint0_12(raw),
|
||||
events: Endpoint0_13(raw),
|
||||
interrupt: Endpoint0_14(raw),
|
||||
message: Endpoint0_15(raw),
|
||||
const adaptGroup3 = (raw: RawClient["server.session"]) => ({
|
||||
list: Endpoint3_0(raw),
|
||||
create: Endpoint3_1(raw),
|
||||
active: Endpoint3_2(raw),
|
||||
get: Endpoint3_3(raw),
|
||||
fork: Endpoint3_4(raw),
|
||||
switchAgent: Endpoint3_5(raw),
|
||||
switchModel: Endpoint3_6(raw),
|
||||
rename: Endpoint3_7(raw),
|
||||
prompt: Endpoint3_8(raw),
|
||||
skill: Endpoint3_9(raw),
|
||||
compact: Endpoint3_10(raw),
|
||||
wait: Endpoint3_11(raw),
|
||||
revertStage: Endpoint3_12(raw),
|
||||
revertClear: Endpoint3_13(raw),
|
||||
revertCommit: Endpoint3_14(raw),
|
||||
context: Endpoint3_15(raw),
|
||||
history: Endpoint3_16(raw),
|
||||
events: Endpoint3_17(raw),
|
||||
interrupt: Endpoint3_18(raw),
|
||||
message: Endpoint3_19(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) })
|
||||
type Endpoint4_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
|
||||
type Endpoint4_0Input = {
|
||||
readonly sessionID: Endpoint4_0Request["params"]["sessionID"]
|
||||
readonly limit?: Endpoint4_0Request["query"]["limit"]
|
||||
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.generate"]["generate.text"]>[0]
|
||||
type Endpoint6_0Input = {
|
||||
readonly location?: Endpoint6_0Request["query"]["location"]
|
||||
readonly prompt: Endpoint6_0Request["payload"]["prompt"]
|
||||
readonly model?: Endpoint6_0Request["payload"]["model"]
|
||||
}
|
||||
const Endpoint6_0 = (raw: RawClient["server.generate"]) => (input: Endpoint6_0Input) =>
|
||||
raw["generate.text"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { prompt: input["prompt"], model: input["model"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
const adaptGroup6 = (raw: RawClient["server.generate"]) => ({ text: Endpoint6_0(raw) })
|
||||
|
||||
type Endpoint7_0Request = Parameters<RawClient["server.provider"]["provider.list"]>[0]
|
||||
type Endpoint7_0Input = { readonly location?: Endpoint7_0Request["query"]["location"] }
|
||||
const Endpoint7_0 = (raw: RawClient["server.provider"]) => (input?: Endpoint7_0Input) =>
|
||||
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint7_1Request = Parameters<RawClient["server.provider"]["provider.get"]>[0]
|
||||
type Endpoint7_1Input = {
|
||||
readonly providerID: Endpoint7_1Request["params"]["providerID"]
|
||||
readonly location?: Endpoint7_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint7_1 = (raw: RawClient["server.provider"]) => (input: Endpoint7_1Input) =>
|
||||
raw["provider.get"]({ params: { providerID: input["providerID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup7 = (raw: RawClient["server.provider"]) => ({ list: Endpoint7_0(raw), get: Endpoint7_1(raw) })
|
||||
|
||||
type Endpoint8_0Request = Parameters<RawClient["server.integration"]["integration.list"]>[0]
|
||||
type Endpoint8_0Input = { readonly location?: Endpoint8_0Request["query"]["location"] }
|
||||
const Endpoint8_0 = (raw: RawClient["server.integration"]) => (input?: Endpoint8_0Input) =>
|
||||
raw["integration.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint8_1Request = Parameters<RawClient["server.integration"]["integration.get"]>[0]
|
||||
type Endpoint8_1Input = {
|
||||
readonly integrationID: Endpoint8_1Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint8_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint8_1 = (raw: RawClient["server.integration"]) => (input: Endpoint8_1Input) =>
|
||||
raw["integration.get"]({
|
||||
params: { integrationID: input["integrationID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint8_2Request = Parameters<RawClient["server.integration"]["integration.connect.key"]>[0]
|
||||
type Endpoint8_2Input = {
|
||||
readonly integrationID: Endpoint8_2Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint8_2Request["query"]["location"]
|
||||
readonly key: Endpoint8_2Request["payload"]["key"]
|
||||
readonly label?: Endpoint8_2Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint8_2 = (raw: RawClient["server.integration"]) => (input: Endpoint8_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 Endpoint8_3Request = Parameters<RawClient["server.integration"]["integration.connect.oauth"]>[0]
|
||||
type Endpoint8_3Input = {
|
||||
readonly integrationID: Endpoint8_3Request["params"]["integrationID"]
|
||||
readonly location?: Endpoint8_3Request["query"]["location"]
|
||||
readonly methodID: Endpoint8_3Request["payload"]["methodID"]
|
||||
readonly inputs: Endpoint8_3Request["payload"]["inputs"]
|
||||
readonly label?: Endpoint8_3Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint8_3 = (raw: RawClient["server.integration"]) => (input: Endpoint8_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 Endpoint8_4Request = Parameters<RawClient["server.integration"]["integration.attempt.status"]>[0]
|
||||
type Endpoint8_4Input = {
|
||||
readonly attemptID: Endpoint8_4Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint8_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint8_4 = (raw: RawClient["server.integration"]) => (input: Endpoint8_4Input) =>
|
||||
raw["integration.attempt.status"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint8_5Request = Parameters<RawClient["server.integration"]["integration.attempt.complete"]>[0]
|
||||
type Endpoint8_5Input = {
|
||||
readonly attemptID: Endpoint8_5Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint8_5Request["query"]["location"]
|
||||
readonly code?: Endpoint8_5Request["payload"]["code"]
|
||||
}
|
||||
const Endpoint8_5 = (raw: RawClient["server.integration"]) => (input: Endpoint8_5Input) =>
|
||||
raw["integration.attempt.complete"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { code: input["code"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint8_6Request = Parameters<RawClient["server.integration"]["integration.attempt.cancel"]>[0]
|
||||
type Endpoint8_6Input = {
|
||||
readonly attemptID: Endpoint8_6Request["params"]["attemptID"]
|
||||
readonly location?: Endpoint8_6Request["query"]["location"]
|
||||
}
|
||||
const Endpoint8_6 = (raw: RawClient["server.integration"]) => (input: Endpoint8_6Input) =>
|
||||
raw["integration.attempt.cancel"]({
|
||||
params: { attemptID: input["attemptID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup8 = (raw: RawClient["server.integration"]) => ({
|
||||
list: Endpoint8_0(raw),
|
||||
get: Endpoint8_1(raw),
|
||||
connectKey: Endpoint8_2(raw),
|
||||
connectOauth: Endpoint8_3(raw),
|
||||
attemptStatus: Endpoint8_4(raw),
|
||||
attemptComplete: Endpoint8_5(raw),
|
||||
attemptCancel: Endpoint8_6(raw),
|
||||
})
|
||||
|
||||
type Endpoint9_0Request = Parameters<RawClient["server.mcp"]["mcp.list"]>[0]
|
||||
type Endpoint9_0Input = { readonly location?: Endpoint9_0Request["query"]["location"] }
|
||||
const Endpoint9_0 = (raw: RawClient["server.mcp"]) => (input?: Endpoint9_0Input) =>
|
||||
raw["mcp.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup9 = (raw: RawClient["server.mcp"]) => ({ list: Endpoint9_0(raw) })
|
||||
|
||||
type Endpoint10_0Request = Parameters<RawClient["server.credential"]["credential.update"]>[0]
|
||||
type Endpoint10_0Input = {
|
||||
readonly credentialID: Endpoint10_0Request["params"]["credentialID"]
|
||||
readonly location?: Endpoint10_0Request["query"]["location"]
|
||||
readonly label: Endpoint10_0Request["payload"]["label"]
|
||||
}
|
||||
const Endpoint10_0 = (raw: RawClient["server.credential"]) => (input: Endpoint10_0Input) =>
|
||||
raw["credential.update"]({
|
||||
params: { credentialID: input["credentialID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { label: input["label"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint10_1Request = Parameters<RawClient["server.credential"]["credential.remove"]>[0]
|
||||
type Endpoint10_1Input = {
|
||||
readonly credentialID: Endpoint10_1Request["params"]["credentialID"]
|
||||
readonly location?: Endpoint10_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint10_1 = (raw: RawClient["server.credential"]) => (input: Endpoint10_1Input) =>
|
||||
raw["credential.remove"]({
|
||||
params: { credentialID: input["credentialID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup10 = (raw: RawClient["server.credential"]) => ({ update: Endpoint10_0(raw), remove: Endpoint10_1(raw) })
|
||||
|
||||
type Endpoint11_0Request = Parameters<RawClient["server.project"]["project.current"]>[0]
|
||||
type Endpoint11_0Input = { readonly location?: Endpoint11_0Request["query"]["location"] }
|
||||
const Endpoint11_0 = (raw: RawClient["server.project"]) => (input?: Endpoint11_0Input) =>
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint11_1Request = Parameters<RawClient["server.project"]["project.directories"]>[0]
|
||||
type Endpoint11_1Input = {
|
||||
readonly projectID: Endpoint11_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint11_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint11_1 = (raw: RawClient["server.project"]) => (input: Endpoint11_1Input) =>
|
||||
raw["project.directories"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup11 = (raw: RawClient["server.project"]) => ({
|
||||
current: Endpoint11_0(raw),
|
||||
directories: Endpoint11_1(raw),
|
||||
})
|
||||
|
||||
type Endpoint12_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
type Endpoint12_0Input = { readonly location?: Endpoint12_0Request["query"]["location"] }
|
||||
const Endpoint12_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_0Input) =>
|
||||
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint12_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
type Endpoint12_1Input = { readonly projectID?: Endpoint12_1Request["query"]["projectID"] }
|
||||
const Endpoint12_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint12_1Input) =>
|
||||
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint12_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
type Endpoint12_2Input = { readonly id: Endpoint12_2Request["params"]["id"] }
|
||||
const Endpoint12_2 = (raw: RawClient["server.permission"]) => (input: Endpoint12_2Input) =>
|
||||
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint12_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
type Endpoint12_3Input = {
|
||||
readonly sessionID: Endpoint12_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint12_3Request["payload"]["id"]
|
||||
readonly action: Endpoint12_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint12_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint12_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint12_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint12_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint12_3Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint12_3 = (raw: RawClient["server.permission"]) => (input: Endpoint12_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 Endpoint12_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
type Endpoint12_4Input = { readonly sessionID: Endpoint12_4Request["params"]["sessionID"] }
|
||||
const Endpoint12_4 = (raw: RawClient["server.permission"]) => (input: Endpoint12_4Input) =>
|
||||
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint12_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
type Endpoint12_5Input = {
|
||||
readonly sessionID: Endpoint12_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint12_5Request["params"]["requestID"]
|
||||
}
|
||||
const Endpoint12_5 = (raw: RawClient["server.permission"]) => (input: Endpoint12_5Input) =>
|
||||
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint12_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
type Endpoint12_6Input = {
|
||||
readonly sessionID: Endpoint12_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint12_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint12_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint12_6Request["payload"]["message"]
|
||||
}
|
||||
const Endpoint12_6 = (raw: RawClient["server.permission"]) => (input: Endpoint12_6Input) =>
|
||||
raw["session.permission.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { reply: input["reply"], message: input["message"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup12 = (raw: RawClient["server.permission"]) => ({
|
||||
listRequests: Endpoint12_0(raw),
|
||||
listSaved: Endpoint12_1(raw),
|
||||
removeSaved: Endpoint12_2(raw),
|
||||
create: Endpoint12_3(raw),
|
||||
list: Endpoint12_4(raw),
|
||||
get: Endpoint12_5(raw),
|
||||
reply: Endpoint12_6(raw),
|
||||
})
|
||||
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
type Endpoint13_0Input = {
|
||||
readonly location?: Endpoint13_0Request["query"]["location"]
|
||||
readonly path?: Endpoint13_0Request["query"]["path"]
|
||||
}
|
||||
const Endpoint13_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint13_0Input) =>
|
||||
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint13_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
type Endpoint13_1Input = {
|
||||
readonly location?: Endpoint13_1Request["query"]["location"]
|
||||
readonly query: Endpoint13_1Request["query"]["query"]
|
||||
readonly type?: Endpoint13_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint13_1Request["query"]["limit"]
|
||||
}
|
||||
const Endpoint13_1 = (raw: RawClient["server.fs"]) => (input: Endpoint13_1Input) =>
|
||||
raw["fs.find"]({
|
||||
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.fs"]) => ({ list: Endpoint13_0(raw), find: Endpoint13_1(raw) })
|
||||
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
|
||||
const Endpoint14_0 = (raw: RawClient["server.command"]) => (input?: Endpoint14_0Input) =>
|
||||
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup14 = (raw: RawClient["server.command"]) => ({ list: Endpoint14_0(raw) })
|
||||
|
||||
type Endpoint15_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
|
||||
const Endpoint15_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint15_0Input) =>
|
||||
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup15 = (raw: RawClient["server.skill"]) => ({ list: Endpoint15_0(raw) })
|
||||
|
||||
const Endpoint16_0 = (raw: RawClient["server.event"]) => () =>
|
||||
Stream.unwrap(
|
||||
raw["event.subscribe"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup16 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint16_0(raw) })
|
||||
|
||||
type Endpoint17_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
|
||||
const Endpoint17_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_0Input) =>
|
||||
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint17_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
type Endpoint17_1Input = {
|
||||
readonly location?: Endpoint17_1Request["query"]["location"]
|
||||
readonly command?: Endpoint17_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint17_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint17_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint17_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint17_1Request["payload"]["env"]
|
||||
}
|
||||
const Endpoint17_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint17_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 Endpoint17_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
type Endpoint17_2Input = {
|
||||
readonly ptyID: Endpoint17_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint17_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint17_2 = (raw: RawClient["server.pty"]) => (input: Endpoint17_2Input) =>
|
||||
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint17_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
type Endpoint17_3Input = {
|
||||
readonly ptyID: Endpoint17_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint17_3Request["query"]["location"]
|
||||
readonly title?: Endpoint17_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint17_3Request["payload"]["size"]
|
||||
}
|
||||
const Endpoint17_3 = (raw: RawClient["server.pty"]) => (input: Endpoint17_3Input) =>
|
||||
raw["pty.update"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { title: input["title"], size: input["size"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint17_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
type Endpoint17_4Input = {
|
||||
readonly ptyID: Endpoint17_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint17_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint17_4 = (raw: RawClient["server.pty"]) => (input: Endpoint17_4Input) =>
|
||||
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup17 = (raw: RawClient["server.pty"]) => ({
|
||||
list: Endpoint17_0(raw),
|
||||
create: Endpoint17_1(raw),
|
||||
get: Endpoint17_2(raw),
|
||||
update: Endpoint17_3(raw),
|
||||
remove: Endpoint17_4(raw),
|
||||
})
|
||||
|
||||
type Endpoint18_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
|
||||
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
||||
const Endpoint18_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint18_0Input) =>
|
||||
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint18_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
|
||||
type Endpoint18_1Input = {
|
||||
readonly location?: Endpoint18_1Request["query"]["location"]
|
||||
readonly command: Endpoint18_1Request["payload"]["command"]
|
||||
readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
|
||||
readonly timeout?: Endpoint18_1Request["payload"]["timeout"]
|
||||
readonly metadata?: Endpoint18_1Request["payload"]["metadata"]
|
||||
}
|
||||
const Endpoint18_1 = (raw: RawClient["server.shell"]) => (input: Endpoint18_1Input) =>
|
||||
raw["shell.create"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint18_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
|
||||
type Endpoint18_2Input = {
|
||||
readonly id: Endpoint18_2Request["params"]["id"]
|
||||
readonly location?: Endpoint18_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint18_2 = (raw: RawClient["server.shell"]) => (input: Endpoint18_2Input) =>
|
||||
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint18_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
|
||||
type Endpoint18_3Input = {
|
||||
readonly id: Endpoint18_3Request["params"]["id"]
|
||||
readonly location?: Endpoint18_3Request["query"]["location"]
|
||||
readonly cursor?: Endpoint18_3Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint18_3Request["query"]["limit"]
|
||||
}
|
||||
const Endpoint18_3 = (raw: RawClient["server.shell"]) => (input: Endpoint18_3Input) =>
|
||||
raw["shell.output"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint18_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
type Endpoint18_4Input = {
|
||||
readonly id: Endpoint18_4Request["params"]["id"]
|
||||
readonly location?: Endpoint18_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint18_4 = (raw: RawClient["server.shell"]) => (input: Endpoint18_4Input) =>
|
||||
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup18 = (raw: RawClient["server.shell"]) => ({
|
||||
list: Endpoint18_0(raw),
|
||||
create: Endpoint18_1(raw),
|
||||
get: Endpoint18_2(raw),
|
||||
output: Endpoint18_3(raw),
|
||||
remove: Endpoint18_4(raw),
|
||||
})
|
||||
|
||||
type Endpoint19_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
|
||||
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
|
||||
const Endpoint19_0 = (raw: RawClient["server.question"]) => (input?: Endpoint19_0Input) =>
|
||||
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint19_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
|
||||
type Endpoint19_1Input = { readonly sessionID: Endpoint19_1Request["params"]["sessionID"] }
|
||||
const Endpoint19_1 = (raw: RawClient["server.question"]) => (input: Endpoint19_1Input) =>
|
||||
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint19_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
|
||||
type Endpoint19_2Input = {
|
||||
readonly sessionID: Endpoint19_2Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint19_2Request["params"]["requestID"]
|
||||
readonly answers: Endpoint19_2Request["payload"]["answers"]
|
||||
}
|
||||
const Endpoint19_2 = (raw: RawClient["server.question"]) => (input: Endpoint19_2Input) =>
|
||||
raw["session.question.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { answers: input["answers"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint19_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
|
||||
type Endpoint19_3Input = {
|
||||
readonly sessionID: Endpoint19_3Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint19_3Request["params"]["requestID"]
|
||||
}
|
||||
const Endpoint19_3 = (raw: RawClient["server.question"]) => (input: Endpoint19_3Input) =>
|
||||
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup19 = (raw: RawClient["server.question"]) => ({
|
||||
listRequests: Endpoint19_0(raw),
|
||||
list: Endpoint19_1(raw),
|
||||
reply: Endpoint19_2(raw),
|
||||
reject: Endpoint19_3(raw),
|
||||
})
|
||||
|
||||
type Endpoint20_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
|
||||
type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
|
||||
const Endpoint20_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint20_0Input) =>
|
||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup20 = (raw: RawClient["server.reference"]) => ({ list: Endpoint20_0(raw) })
|
||||
|
||||
type Endpoint21_0Request = Parameters<RawClient["server.projectCopy"]["projectCopy.create"]>[0]
|
||||
type Endpoint21_0Input = {
|
||||
readonly projectID: Endpoint21_0Request["params"]["projectID"]
|
||||
readonly location?: Endpoint21_0Request["query"]["location"]
|
||||
readonly strategy: Endpoint21_0Request["payload"]["strategy"]
|
||||
readonly directory: Endpoint21_0Request["payload"]["directory"]
|
||||
readonly name?: Endpoint21_0Request["payload"]["name"]
|
||||
}
|
||||
const Endpoint21_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_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 Endpoint21_1Request = Parameters<RawClient["server.projectCopy"]["projectCopy.remove"]>[0]
|
||||
type Endpoint21_1Input = {
|
||||
readonly projectID: Endpoint21_1Request["params"]["projectID"]
|
||||
readonly location?: Endpoint21_1Request["query"]["location"]
|
||||
readonly directory: Endpoint21_1Request["payload"]["directory"]
|
||||
readonly force: Endpoint21_1Request["payload"]["force"]
|
||||
}
|
||||
const Endpoint21_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_1Input) =>
|
||||
raw["projectCopy.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint21_2Request = Parameters<RawClient["server.projectCopy"]["projectCopy.refresh"]>[0]
|
||||
type Endpoint21_2Input = {
|
||||
readonly projectID: Endpoint21_2Request["params"]["projectID"]
|
||||
readonly location?: Endpoint21_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint21_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint21_2Input) =>
|
||||
raw["projectCopy.refresh"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup21 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
create: Endpoint21_0(raw),
|
||||
remove: Endpoint21_1(raw),
|
||||
refresh: Endpoint21_2(raw),
|
||||
})
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
location: adaptGroup1(raw["server.location"]),
|
||||
agent: adaptGroup2(raw["server.agent"]),
|
||||
session: adaptGroup3(raw["server.session"]),
|
||||
message: adaptGroup4(raw["server.message"]),
|
||||
model: adaptGroup5(raw["server.model"]),
|
||||
generate: adaptGroup6(raw["server.generate"]),
|
||||
provider: adaptGroup7(raw["server.provider"]),
|
||||
integration: adaptGroup8(raw["server.integration"]),
|
||||
"server.mcp": adaptGroup9(raw["server.mcp"]),
|
||||
credential: adaptGroup10(raw["server.credential"]),
|
||||
project: adaptGroup11(raw["server.project"]),
|
||||
permission: adaptGroup12(raw["server.permission"]),
|
||||
file: adaptGroup13(raw["server.fs"]),
|
||||
command: adaptGroup14(raw["server.command"]),
|
||||
skill: adaptGroup15(raw["server.skill"]),
|
||||
event: adaptGroup16(raw["server.event"]),
|
||||
pty: adaptGroup17(raw["server.pty"]),
|
||||
shell: adaptGroup18(raw["server.shell"]),
|
||||
question: adaptGroup19(raw["server.question"]),
|
||||
reference: adaptGroup20(raw["server.reference"]),
|
||||
projectCopy: adaptGroup21(raw["server.projectCopy"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient))
|
||||
HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1 +1,3 @@
|
||||
export * from "./generated/index"
|
||||
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
|
||||
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Schema } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Location as CoreLocation } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionInput as CoreSessionInput } from "@opencode-ai/core/session/input"
|
||||
import { SessionMessage as CoreSessionMessage } from "@opencode-ai/core/session/message"
|
||||
@@ -19,29 +20,31 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||
import { HttpApi } from "effect/unstable/httpapi"
|
||||
import { SessionGroup } from "../src/contract"
|
||||
import { ClientApi, endpointNames, groupNames, omitEndpoints } from "../src/contract"
|
||||
|
||||
test("Core and Server reuse the authoritative Schema and Protocol values", () => {
|
||||
expect(AgentV2.ID).toBe(Agent.ID)
|
||||
expect(CoreLocation.Ref).toBe(Location.Ref)
|
||||
expect(ModelV2.Ref).toBe(Model.Ref)
|
||||
expect(SessionV2.Info).toBe(Session.Info)
|
||||
expect(ProjectV2.Current).toBe(Project.Current)
|
||||
expect(ProjectV2.Directory).toBe(Project.Directory)
|
||||
expect(ProjectV2.Directories).toBe(Project.Directories)
|
||||
expect(CoreSessionInput.Admitted).toBe(SessionInput.Admitted)
|
||||
expect(CoreSessionMessage.Message).toBe(SessionMessage.Message)
|
||||
expect(CorePrompt).toBe(Prompt)
|
||||
expect(Api.groups["server.session"].identifier).toBe("server.session")
|
||||
expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier)
|
||||
expect(Api.groups["server.project"].identifier).toBe("server.project")
|
||||
expect(Object.keys(ClientApi.groups)).toEqual(Object.keys(Api.groups))
|
||||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Project.ID.global).toBe("global")
|
||||
expect(Provider.ID.anthropic).toBe("anthropic")
|
||||
expect(Workspace.ID.create()).toStartWith("wrk_")
|
||||
})
|
||||
|
||||
test("client and Server Session contracts generate identically", () => {
|
||||
const options = { groupNames: { "server.session": "sessions" } }
|
||||
const server = compile(HttpApi.make("server").add(Api.groups["server.session"]), options)
|
||||
const client = compile(HttpApi.make("client").add(SessionGroup), options)
|
||||
test("client and Server contracts generate identically", () => {
|
||||
const server = compile(Api, { groupNames, endpointNames, omitEndpoints })
|
||||
const client = compile(ClientApi, { groupNames, endpointNames, omitEndpoints })
|
||||
|
||||
expect(emitPromise(client)).toEqual(emitPromise(server))
|
||||
})
|
||||
|
||||
@@ -3,19 +3,65 @@ import { DateTime, Effect, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect"
|
||||
|
||||
test("sessions.get returns the decoded Effect projection", async () => {
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.sessions.get({ sessionID: Session.ID.make("ses_test") })
|
||||
return yield* client.session.get({ sessionID: Session.ID.make("ses_test") })
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("event.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.event.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("event.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.event.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 () => {
|
||||
const historyQueries: Array<Record<string, string>> = []
|
||||
let historyPage = 0
|
||||
const httpClient = HttpClient.make((request) => {
|
||||
const url = request.url
|
||||
if (url.includes("/event")) {
|
||||
@@ -28,6 +74,18 @@ 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")) {
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
|
||||
}
|
||||
@@ -54,33 +112,45 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
})
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
const page = yield* client.sessions.list({ limit: 10 })
|
||||
const active = yield* client.sessions.active()
|
||||
const created = yield* client.sessions.create({
|
||||
const page = yield* client.session.list({ limit: 10 })
|
||||
const active = yield* client.session.active()
|
||||
const created = yield* client.session.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
|
||||
})
|
||||
yield* client.sessions.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||
yield* client.sessions.switchModel({
|
||||
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
|
||||
yield* client.session.switchModel({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
model: Model.Ref.make({ id: "claude", providerID: "anthropic" }),
|
||||
})
|
||||
const admitted = yield* client.sessions.prompt({
|
||||
const admitted = yield* client.session.prompt({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: Prompt.make({ text: "Hello" }),
|
||||
resume: false,
|
||||
})
|
||||
yield* client.sessions.compact({ 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 events = yield* client.sessions
|
||||
yield* client.session.compact({ sessionID: Session.ID.make("ses_test") })
|
||||
yield* client.session.wait({ sessionID: Session.ID.make("ses_test") })
|
||||
const context = yield* client.session.context({ sessionID: Session.ID.make("ses_test") })
|
||||
const history = yield* client.session.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: 0,
|
||||
limit: 1,
|
||||
})
|
||||
const historyNext = history.hasMore
|
||||
? yield* client.session.history({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
after: history.data.at(-1)?.durable?.seq,
|
||||
limit: 2,
|
||||
})
|
||||
: undefined
|
||||
const events = yield* client.session
|
||||
.events({ sessionID: Session.ID.make("ses_test"), after: 0 })
|
||||
.pipe(Stream.runCollect)
|
||||
yield* client.sessions.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.sessions.message({
|
||||
yield* client.session.interrupt({ sessionID: Session.ID.make("ses_test") })
|
||||
const message = yield* client.session.message({
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
messageID: SessionMessage.ID.make("msg_model"),
|
||||
})
|
||||
return { page, active, created, admitted, context, events, message }
|
||||
return { page, active, created, admitted, context, history, historyNext, events, message }
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
|
||||
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||
@@ -92,10 +162,39 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
|
||||
expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype)
|
||||
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
|
||||
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(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
|
||||
})
|
||||
|
||||
test("session.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.session
|
||||
.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 = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
|
||||
@@ -1,7 +1,131 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { isUnauthorizedError, OpenCode } from "../src"
|
||||
import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src"
|
||||
|
||||
test("sessions.get returns the wire projection", async () => {
|
||||
test("exposes every standard HTTP API group", () => {
|
||||
const client = OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
|
||||
expect(Object.keys(client)).toEqual([
|
||||
"health",
|
||||
"location",
|
||||
"agent",
|
||||
"session",
|
||||
"message",
|
||||
"model",
|
||||
"generate",
|
||||
"provider",
|
||||
"integration",
|
||||
"credential",
|
||||
"project",
|
||||
"permission",
|
||||
"file",
|
||||
"command",
|
||||
"skill",
|
||||
"event",
|
||||
"pty",
|
||||
"shell",
|
||||
"question",
|
||||
"reference",
|
||||
"projectCopy",
|
||||
])
|
||||
expect(Object.keys(client.message)).toEqual(["list"])
|
||||
expect(Object.keys(client.integration)).toEqual([
|
||||
"list",
|
||||
"get",
|
||||
"connectKey",
|
||||
"connectOauth",
|
||||
"attemptStatus",
|
||||
"attemptComplete",
|
||||
"attemptCancel",
|
||||
])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["current", "directories"])
|
||||
})
|
||||
|
||||
test("file.read returns binary content from the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
request = input instanceof Request ? input : new Request(input)
|
||||
return new Response(new Uint8Array([104, 105]))
|
||||
},
|
||||
})
|
||||
|
||||
const content = await client.file.read({
|
||||
path: "src/a b#c.ts",
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(Array.from(content)).toEqual([104, 105])
|
||||
expect(request?.url).toBe(
|
||||
"http://localhost:3000/api/fs/read/src/a%20b%23c.ts?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
)
|
||||
})
|
||||
|
||||
test("project methods use the public HTTP contract", async () => {
|
||||
const requests: string[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
requests.push(url)
|
||||
if (url.includes("/directories")) return Response.json([])
|
||||
return Response.json({ id: "proj_test", directory: "/tmp/project" })
|
||||
},
|
||||
})
|
||||
|
||||
const current = await client.project.current({ location: { workspace: "wrk_test" } })
|
||||
const directories = await client.project.directories({
|
||||
projectID: current.id,
|
||||
location: { directory: current.directory },
|
||||
})
|
||||
|
||||
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
|
||||
expect(directories).toEqual([])
|
||||
expect(requests).toEqual([
|
||||
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
|
||||
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
])
|
||||
})
|
||||
|
||||
test("shell list and remove use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const shell = {
|
||||
id: "sh_test",
|
||||
status: "running",
|
||||
command: "pwd",
|
||||
cwd: "/tmp/project",
|
||||
shell: "/bin/zsh",
|
||||
file: "/tmp/opencode-shell",
|
||||
metadata: { sessionID: "ses_test" },
|
||||
time: { started: 1_717_171_717_000 },
|
||||
}
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
if (request.method === "DELETE") return new Response(null, { status: 204 })
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: [shell],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.shell.list({ location: { directory: "/tmp/project" } })
|
||||
await client.shell.remove({ id: shell.id })
|
||||
|
||||
expect(result.data).toEqual([shell])
|
||||
expect(requests).toEqual([
|
||||
{ method: "GET", url: "http://localhost:3000/api/shell?location%5Bdirectory%5D=%2Ftmp%2Fproject" },
|
||||
{ method: "DELETE", url: "http://localhost:3000/api/shell/sh_test" },
|
||||
])
|
||||
})
|
||||
|
||||
test("session.get returns the wire projection", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
@@ -12,13 +136,43 @@ test("sessions.get returns the wire projection", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.sessions.get({ sessionID: "ses_test" })
|
||||
const result = await client.session.get({ sessionID: "ses_test" })
|
||||
|
||||
expect(result.time.created).toBe(1_717_171_717_000)
|
||||
})
|
||||
|
||||
test("event.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.event.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("event.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.event.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({
|
||||
name: "ClientError",
|
||||
reason: "MalformedResponse",
|
||||
})
|
||||
})
|
||||
|
||||
test("session methods use the public HTTP contract", async () => {
|
||||
const requests: Array<{ url: string; init?: RequestInit }> = []
|
||||
let historyPage = 0
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
@@ -29,6 +183,12 @@ test("session methods use the public HTTP contract", async () => {
|
||||
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("/context")) return Response.json({ data: [] })
|
||||
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
|
||||
@@ -39,32 +199,39 @@ test("session methods use the public HTTP contract", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
const page = await client.sessions.list({ limit: "10", order: "desc" })
|
||||
const active = await client.sessions.active()
|
||||
const created = await client.sessions.create({ location: { directory: "/tmp/project" } })
|
||||
await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.sessions.switchModel({
|
||||
const page = await client.session.list({ limit: 10, order: "desc" })
|
||||
const active = await client.session.active()
|
||||
const created = await client.session.create({ location: { directory: "/tmp/project" } })
|
||||
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
|
||||
await client.session.switchModel({
|
||||
sessionID: "ses_test",
|
||||
model: { id: "claude", providerID: "anthropic" },
|
||||
})
|
||||
const admitted = await client.sessions.prompt({
|
||||
const admitted = await client.session.prompt({
|
||||
sessionID: "ses_test",
|
||||
prompt: { text: "Hello" },
|
||||
resume: false,
|
||||
})
|
||||
await client.sessions.compact({ sessionID: "ses_test" })
|
||||
await client.sessions.wait({ sessionID: "ses_test" })
|
||||
const context = await client.sessions.context({ sessionID: "ses_test" })
|
||||
await client.session.compact({ sessionID: "ses_test" })
|
||||
await client.session.wait({ sessionID: "ses_test" })
|
||||
const context = await client.session.context({ sessionID: "ses_test" })
|
||||
const history = await client.session.history({ sessionID: "ses_test", after: 0, limit: 1 })
|
||||
const historyAfter = history.data.at(-1)?.durable?.seq
|
||||
const historyNext = history.hasMore
|
||||
? await client.session.history({ sessionID: "ses_test", after: historyAfter, limit: 2 })
|
||||
: undefined
|
||||
const events = []
|
||||
for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event)
|
||||
await client.sessions.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
for await (const event of client.session.events({ sessionID: "ses_test", after: 0 })) events.push(event)
|
||||
await client.session.interrupt({ sessionID: "ses_test" })
|
||||
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
|
||||
|
||||
expect(page.cursor.next).toBe("next")
|
||||
expect(active).toEqual({ ses_test: { type: "running" } })
|
||||
expect(created.id).toBe("ses_test")
|
||||
expect(admitted.id).toBe("msg_test")
|
||||
expect(context).toEqual([])
|
||||
expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true })
|
||||
expect(historyNext).toEqual({ data: [], hasMore: false })
|
||||
expect(events).toEqual([modelSwitchedEvent])
|
||||
expect(message).toEqual(modelSwitchedMessage)
|
||||
expect(requests.map((request) => [request.init?.method, request.url])).toEqual([
|
||||
@@ -77,6 +244,8 @@ 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/wait"],
|
||||
["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"],
|
||||
["POST", "http://localhost:3000/api/session/ses_test/interrupt"],
|
||||
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
|
||||
@@ -97,13 +266,31 @@ test("middleware errors remain declared client errors", async () => {
|
||||
})
|
||||
|
||||
try {
|
||||
await client.sessions.create({})
|
||||
await client.session.create({})
|
||||
throw new Error("Expected request to fail")
|
||||
} catch (error) {
|
||||
expect(isUnauthorizedError(error)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("session.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.session.history({ sessionID: "ses_missing" })
|
||||
throw new Error("Expected request to fail")
|
||||
} catch (error) {
|
||||
expect(isSessionNotFoundError(error)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
const session = {
|
||||
data: {
|
||||
id: "ses_test",
|
||||
|
||||
@@ -215,6 +215,16 @@ export async function handler(
|
||||
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) {
|
||||
logger.metric({
|
||||
"llm.error.code": res.status,
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./effect/layer-node": "./src/effect/layer-node.ts",
|
||||
"./effect/app-node": "./src/effect/app-node.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./system-context": "./src/system-context/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
@@ -84,6 +86,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@npmcli/config": "10.8.1",
|
||||
@@ -102,6 +105,7 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bun-pty": "0.4.8",
|
||||
"cross-spawn": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { EventV2 } from "./event"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Agent.ID
|
||||
@@ -13,6 +15,8 @@ export const Color = Agent.Color
|
||||
export const Info = Agent.Info
|
||||
export type Info = Agent.Info
|
||||
|
||||
export const Event = Agent.Event
|
||||
|
||||
export interface Selection {
|
||||
readonly id: ID
|
||||
readonly info: Info | undefined
|
||||
@@ -36,7 +40,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
@@ -44,6 +48,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
initial: () => ({ agents: new Map() }),
|
||||
draft: (draft) => ({
|
||||
@@ -62,6 +67,7 @@ export const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
@@ -98,7 +104,7 @@ export const layer = Layer.effect(
|
||||
const info = selectedDefault()
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
all: Effect.fn("AgentV2.all")(function* () {
|
||||
list: Effect.fn("AgentV2.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
@@ -106,3 +112,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Cause, Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -231,4 +232,6 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Catalog from "./catalog"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
@@ -296,3 +297,5 @@ export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Policy.locationLayer),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as CommandV2 from "./command"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state"
|
||||
@@ -59,3 +60,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { type ParseError, parse } from "jsonc-parser"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
@@ -138,7 +139,7 @@ export const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const policy = yield* Policy.Service
|
||||
const names = ["config.json", "opencode.json", "opencode.jsonc"]
|
||||
const names = ["opencode.json", "opencode.jsonc"]
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
|
||||
@@ -218,3 +219,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -29,6 +29,12 @@ const PluginModule = Schema.Struct({
|
||||
]),
|
||||
})
|
||||
|
||||
const PluginPackage = Schema.Struct({
|
||||
exports: Schema.optional(Schema.Unknown),
|
||||
main: Schema.optional(Schema.String),
|
||||
module: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Plugin = define({
|
||||
id: "config-plugin",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
@@ -65,8 +71,30 @@ export const Plugin = define({
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(Effect.orElseSucceed(() => []))
|
||||
const directories = yield* fs
|
||||
.glob("{plugin,plugins}/*", {
|
||||
cwd: entry.path,
|
||||
absolute: true,
|
||||
include: "all",
|
||||
dot: true,
|
||||
symlink: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) =>
|
||||
Effect.filter(items, (item) => fs.isDir(item), {
|
||||
concurrency: "unbounded",
|
||||
}),
|
||||
),
|
||||
Effect.orElseSucceed(() => []),
|
||||
)
|
||||
const packages = yield* Effect.forEach(
|
||||
directories.sort(),
|
||||
(directory) => resolvePackageEntrypoint(fs, directory),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((items) => items.filter((item): item is string => item !== undefined)))
|
||||
files.sort()
|
||||
for (const file of files) configured.push({ package: file })
|
||||
for (const file of packages) configured.push({ package: file })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +104,7 @@ export const Plugin = define({
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
if (!entrypoint) return
|
||||
|
||||
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
|
||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||
@@ -86,6 +114,21 @@ export const Plugin = define({
|
||||
})
|
||||
}).pipe(Effect.ignoreCause)
|
||||
}
|
||||
}).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
)
|
||||
const exported = typeof pkg?.exports === "string" ? pkg.exports : undefined
|
||||
const entries = [exported, pkg?.module, pkg?.main, "index.ts", "index.js"]
|
||||
|
||||
return yield* Effect.forEach(entries, (entry) => {
|
||||
if (!entry) return Effect.succeed(undefined)
|
||||
const file = path.resolve(directory, entry)
|
||||
return fs.isFile(file).pipe(Effect.map((exists) => (exists ? file : undefined)))
|
||||
}).pipe(Effect.map((items) => items.find((item): item is string => item !== undefined)))
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Database } from "./database/database"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { CredentialTable } from "./credential/sql"
|
||||
|
||||
export const ID = Credential.ID
|
||||
@@ -135,3 +136,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
import * as NodeChildProcess from "node:child_process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import launch from "cross-spawn"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem, path } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem, path } from "./effect/app-node-platform"
|
||||
|
||||
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 node = LayerNode.make({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
|
||||
|
||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Flag } from "../flag/flag"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||
@@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap(
|
||||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Layer } from "effect"
|
||||
import { buildLocationServiceMap } from "../location-services"
|
||||
import { LocationServiceMap } from "../location-service-map"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-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]))
|
||||
|
||||
if (!LayerNode.hasUnbound(root, LocationServiceMap.node)) {
|
||||
// If the location service map is not needed, we shouldn't pull it
|
||||
// in. Compile the graph normally
|
||||
return LayerNode.compile(root, replacementMap)
|
||||
}
|
||||
|
||||
const locationMap = buildLocationServiceMap(replacementMap)
|
||||
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
|
||||
|
||||
const app = LayerNode.bind(root, LocationServiceMap.node, locationMapNode)
|
||||
|
||||
return LayerNode.compile(app, replacementMap)
|
||||
}
|
||||
|
||||
export * as AppNodeBuilder from "./app-node-builder"
|
||||
+7
-7
@@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { FileSystem, Path } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { LayerNode } from "./layer-node"
|
||||
import { makeGlobalNode } from "./app-node"
|
||||
|
||||
export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = LayerNode.make({
|
||||
export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] })
|
||||
export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] })
|
||||
export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] })
|
||||
export const requestExecutor = makeGlobalNode({
|
||||
service: RequestExecutor.Service,
|
||||
layer: RequestExecutor.layer,
|
||||
deps: [httpClient],
|
||||
})
|
||||
export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export * as LayerNodePlatform from "./layer-node-platform"
|
||||
export * as LayerNodePlatform from "./app-node-platform"
|
||||
@@ -0,0 +1,14 @@
|
||||
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 "./app-node"
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Brand, Context, Layer } from "effect"
|
||||
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type AnyNode = Node<unknown, unknown, any>
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
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
|
||||
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>,
|
||||
@@ -14,17 +15,17 @@ type CheckDependencies<Implementation extends Layer.Any, Dependencies extends No
|
||||
declare const $OutputType: unique symbol
|
||||
declare const $ErrorType: unique symbol
|
||||
|
||||
export type Tier<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tier">
|
||||
export type Tag<Name extends string = string> = Name & Brand.Brand<"LayerNode.Tag">
|
||||
|
||||
const makeTier = Brand.nominal<Tier>()
|
||||
const makeTag = Brand.nominal<Tag>()
|
||||
|
||||
export type Node<A, E = never, T extends Tier | undefined = undefined> = {
|
||||
readonly kind: "layer" | "group"
|
||||
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 tier?: T
|
||||
readonly tag?: T
|
||||
readonly [$OutputType]?: () => A
|
||||
readonly [$ErrorType]?: () => E
|
||||
}
|
||||
@@ -34,22 +35,55 @@ type NodeIdentity =
|
||||
| { readonly name: string; readonly service?: never }
|
||||
type DistributiveOmit<A, K extends PropertyKey> = A extends unknown ? Omit<A, K> : never
|
||||
|
||||
type NodeInput<
|
||||
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"],
|
||||
}
|
||||
}
|
||||
|
||||
// Nodes ---------------------------------------------------------------------
|
||||
|
||||
type MakeInput<
|
||||
Implementation extends Layer.Any,
|
||||
Items extends NodeList,
|
||||
T extends Tier | undefined = undefined,
|
||||
T extends Tag | undefined = undefined,
|
||||
> = NodeIdentity & {
|
||||
readonly layer: Implementation
|
||||
readonly deps: Items & CheckDependencies<Implementation, NoInfer<Items>>
|
||||
readonly tier?: T
|
||||
readonly tag?: T
|
||||
}
|
||||
|
||||
export function make<
|
||||
const Implementation extends Layer.Any,
|
||||
const Items extends NodeList,
|
||||
const T extends Tier | undefined = undefined,
|
||||
const T extends Tag | undefined = undefined,
|
||||
>(
|
||||
input: NodeInput<Implementation, Items, T>,
|
||||
input: MakeInput<Implementation, Items, T>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>, T> {
|
||||
return {
|
||||
kind: "layer",
|
||||
@@ -57,53 +91,26 @@ export function make<
|
||||
service: input.service,
|
||||
implementation: input.layer,
|
||||
dependencies: input.deps,
|
||||
tier: input.tier,
|
||||
tag: input.tag,
|
||||
}
|
||||
}
|
||||
|
||||
export function group<const Items extends NodeList>(
|
||||
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]>> {
|
||||
): Node<Output<Items[number]>, Error<Items[number]>, NodeTag<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
|
||||
@@ -115,134 +122,152 @@ type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<Replacemen
|
||||
|
||||
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: Layer.Layer<NoInfer<A>, E2, NoInfer<R>> & 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
|
||||
// Tree -----------------------------------------------------------------------
|
||||
|
||||
type Visit<Result> = (node: AnyNode, context: VisitContext<Result>) => Result
|
||||
|
||||
type VisitContext<Result> = {
|
||||
readonly cache: Map<AnyNode, Result>
|
||||
readonly visit: (node: AnyNode) => Result
|
||||
}
|
||||
|
||||
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(
|
||||
function walk<Result>(
|
||||
root: AnyNode,
|
||||
tiers: Tiers<readonly [string, ...string[]]>,
|
||||
replacements: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
visit: Visit<Result>,
|
||||
options: {
|
||||
readonly cache?: Map<AnyNode, Result>
|
||||
readonly resolve?: (node: AnyNode) => AnyNode
|
||||
readonly detectCycles?: boolean
|
||||
} = {},
|
||||
) {
|
||||
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 cache = options.cache ?? new Map<AnyNode, Result>()
|
||||
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 recur = (node: AnyNode): Result => {
|
||||
const target = options.resolve?.(node) ?? node
|
||||
const cached = cache.get(target)
|
||||
if (cached !== undefined || cache.has(target)) return cached!
|
||||
|
||||
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)
|
||||
if (options.detectCycles !== false && visiting.has(target)) {
|
||||
const start = stack.indexOf(target)
|
||||
throw new Error(
|
||||
`Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`,
|
||||
`Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
visiting.add(node)
|
||||
stack.push(node)
|
||||
visiting.add(target)
|
||||
stack.push(target)
|
||||
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)
|
||||
const result = visit(target, { cache, visit: recur })
|
||||
if (!cache.has(target)) cache.set(target, result)
|
||||
return result
|
||||
} finally {
|
||||
stack.pop()
|
||||
visiting.delete(node)
|
||||
visiting.delete(target)
|
||||
}
|
||||
}
|
||||
|
||||
visit(root)
|
||||
return plans
|
||||
return recur(root)
|
||||
}
|
||||
|
||||
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 function hoist<A, E, T extends Tag>(
|
||||
root: Node<A, E, any>,
|
||||
tag: T,
|
||||
): {
|
||||
readonly node: Node<A, E>
|
||||
readonly hoisted: Node<unknown, E>
|
||||
} {
|
||||
const hoisted = new Map<string, AnyNode>()
|
||||
|
||||
const node = walk<AnyNode>(root, (node, context) => {
|
||||
if (node.kind === "group") {
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
}
|
||||
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)
|
||||
return group([])
|
||||
}
|
||||
if (node.kind === "unbound") {
|
||||
return node
|
||||
}
|
||||
return { ...node, dependencies: node.dependencies.map(context.visit) }
|
||||
})
|
||||
|
||||
return {
|
||||
node: node as Node<A, E>,
|
||||
hoisted: group(Array.from(hoisted.values())) as Node<unknown, E>,
|
||||
}
|
||||
}
|
||||
|
||||
export function compile<A, E>(
|
||||
root: Node<A, E, any>,
|
||||
replacements?: ReadonlyMap<Layer.Any, Layer.Any>,
|
||||
): Layer.Layer<A, E> {
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
const compileNode = (node: AnyNode) =>
|
||||
walk<RuntimeLayer>(
|
||||
node,
|
||||
(node, context) => {
|
||||
if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`)
|
||||
const dependencies = node.dependencies.flatMap(flatten).map(context.visit)
|
||||
const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer
|
||||
return dependencies.length === 0
|
||||
? implementation
|
||||
: implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]]))
|
||||
},
|
||||
{ cache },
|
||||
)
|
||||
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 hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||
if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`)
|
||||
return walk<boolean>(root, (node, context) => {
|
||||
if (node === source) return true
|
||||
return node.dependencies.some(context.visit)
|
||||
})
|
||||
}
|
||||
|
||||
export function bind<A, E, T extends Tag | undefined>(
|
||||
root: Node<A, E, T>,
|
||||
source: AnyNode,
|
||||
replacement: AnyNode,
|
||||
): 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`)
|
||||
}
|
||||
return walk<AnyNode>(
|
||||
root,
|
||||
(target, context) => {
|
||||
if (target.kind === "unbound") return target
|
||||
const dependencies: AnyNode[] = []
|
||||
const clone = { ...target, dependencies }
|
||||
context.cache.set(target, clone)
|
||||
dependencies.push(...target.dependencies.map(context.visit))
|
||||
return clone
|
||||
},
|
||||
{ detectCycles: false, resolve: (node) => (node === source ? replacement : node) },
|
||||
) as Node<A, E, T>
|
||||
}
|
||||
|
||||
function flatten(node: AnyNode): readonly AnyNode[] {
|
||||
return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node]
|
||||
}
|
||||
|
||||
export * as LayerNode from "./layer-node"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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"
|
||||
+100
-18
@@ -1,13 +1,13 @@
|
||||
export * as EventV2 from "./event"
|
||||
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
import { and, asc, eq, gt } from "drizzle-orm"
|
||||
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
import { EventSequenceTable, EventTable } from "./event/sql"
|
||||
import { Location } from "./location"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
@@ -31,6 +31,22 @@ export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
seq: number,
|
||||
) {
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: sql`max(${EventSequenceTable.seq}, ${seq})` },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: ID
|
||||
readonly type: string
|
||||
@@ -47,6 +63,71 @@ 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 versionedType = Event.versionedType
|
||||
|
||||
@@ -84,6 +165,20 @@ export interface Interface {
|
||||
|
||||
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 {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
}
|
||||
@@ -248,7 +343,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq,
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
@@ -459,19 +554,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
|
||||
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) =>
|
||||
(options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe(
|
||||
Effect.andThen(
|
||||
@@ -569,6 +651,6 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
)
|
||||
|
||||
export const layer = layerWith()
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as FileMutation from "./file-mutation"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
@@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||
|
||||
/**
|
||||
* Deferred until the corresponding V2 integrations exist.
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as FileSystem from "./filesystem"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
@@ -113,3 +114,9 @@ const baseLayer = Layer.effect(
|
||||
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: baseLayer,
|
||||
deps: [FSUtil.node, Location.node, FileSystemSearch.node],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as FileSystemSearch from "./search"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { Fff } from "#fff"
|
||||
@@ -229,6 +230,8 @@ export const fffLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = Layer.unwrap(
|
||||
Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)),
|
||||
)
|
||||
const layer = 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] })
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Watcher from "./watcher"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Cause, Context, Effect, Layer } from "effect"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import path from "path"
|
||||
@@ -133,3 +134,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
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],
|
||||
})
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { Glob } from "./util/glob"
|
||||
import { serviceUse } from "./effect/service-use"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { filesystem } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { filesystem } from "./effect/app-node-platform"
|
||||
|
||||
export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedErrorClass<FileSystemError>()("FileSystemError", {
|
||||
@@ -201,7 +201,7 @@ export namespace FSUtil {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
|
||||
|
||||
// Pure helpers that don't need Effect (path manipulation, sync operations)
|
||||
export function mimeType(p: string): string {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
export * as Generate from "./generate"
|
||||
|
||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Catalog } from "./catalog"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { llmClient } from "./effect/app-node-platform"
|
||||
import { Integration } from "./integration"
|
||||
import { ModelV2 } from "./model"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
|
||||
export interface TextInput {
|
||||
readonly prompt: string
|
||||
readonly model?: ModelV2.Ref
|
||||
}
|
||||
|
||||
export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionError>()(
|
||||
"Generate.ModelSelectionError",
|
||||
{ message: Schema.String },
|
||||
) {}
|
||||
|
||||
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
|
||||
"Generate.UnavailableError",
|
||||
{ message: Schema.String, service: Schema.optional(Schema.String) },
|
||||
) {}
|
||||
|
||||
export type Error = ModelSelectionError | UnavailableError
|
||||
|
||||
export interface Interface {
|
||||
readonly text: (input: TextInput) => Effect.Effect<string, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Generate") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
|
||||
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
|
||||
const selected = requested
|
||||
? yield* catalog.model.get(requested.providerID, requested.id)
|
||||
: yield* catalog.model.default().pipe(
|
||||
Effect.flatMap((model) =>
|
||||
model && SessionRunnerModel.supported(model)
|
||||
? Effect.succeed(model)
|
||||
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
|
||||
),
|
||||
)
|
||||
if (!selected)
|
||||
return yield* new ModelSelectionError({
|
||||
message: requested
|
||||
? `Model unavailable: ${requested.providerID}/${requested.id}`
|
||||
: "No model specified and no supported model is available",
|
||||
})
|
||||
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new ModelSelectionError({
|
||||
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
|
||||
const selected = yield* selectModel(input.model)
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
|
||||
Effect.mapError((error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: selected.providerID }),
|
||||
),
|
||||
)
|
||||
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
|
||||
Effect.mapError(
|
||||
(error: LLMError) =>
|
||||
new UnavailableError({
|
||||
message: error.message,
|
||||
service: selected.providerID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return response.text
|
||||
})
|
||||
|
||||
const text: Interface["text"] = (input) =>
|
||||
runText(input).pipe(
|
||||
Effect.catchTag(
|
||||
"Integration.Authorization",
|
||||
() =>
|
||||
new UnavailableError({
|
||||
message: "Generation credentials are unavailable",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ text })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
|
||||
@@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath, RelativePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { AppProcess } from "./process"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { File } from "./file"
|
||||
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 node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
|
||||
@@ -5,7 +5,7 @@ import os from "os"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flock } from "./util/flock"
|
||||
import { Flag } from "./flag/flag"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
|
||||
const app = "opencode"
|
||||
const data = path.join(xdgData!, app)
|
||||
@@ -77,7 +77,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
|
||||
|
||||
export const layerWith = (input: Partial<Interface>) =>
|
||||
Layer.effect(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Image from "./image"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FileSystem } from "./filesystem"
|
||||
@@ -76,3 +77,5 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Location } from "./location"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { SystemContext } from "./system-context/index"
|
||||
import { SystemContextRegistry } from "./system-context/registry"
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
|
||||
class File extends Schema.Class<File>("InstructionContext.File")({
|
||||
path: AbsolutePath,
|
||||
@@ -87,6 +88,12 @@ 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>) {
|
||||
return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as Integration from "./integration"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import {
|
||||
Cause,
|
||||
Clock,
|
||||
@@ -515,3 +516,5 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] })
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export * as BackgroundJob from "./background-job"
|
||||
export * as Job from "./job"
|
||||
|
||||
import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { Identifier } from "./id/id"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
|
||||
export type Status = "running" | "completed" | "error" | "cancelled"
|
||||
|
||||
@@ -20,14 +22,11 @@ export type Info = {
|
||||
type Active = {
|
||||
info: Info
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
scope: Scope.Closeable
|
||||
token: object
|
||||
pending: number
|
||||
next: number
|
||||
output?: { sequence: number; text: string }
|
||||
tail: Deferred.Deferred<void>
|
||||
promoted: Deferred.Deferred<Info>
|
||||
onPromote?: Effect.Effect<void>
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
@@ -41,36 +40,29 @@ type FinishResult = {
|
||||
scope?: Scope.Closeable
|
||||
}
|
||||
|
||||
type PromoteResult = {
|
||||
type BackgroundResult = {
|
||||
info?: Info
|
||||
promoted?: Deferred.Deferred<Info>
|
||||
onPromote?: Effect.Effect<void>
|
||||
backgrounded?: Deferred.Deferred<Info>
|
||||
}
|
||||
|
||||
type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable; token: object }
|
||||
|
||||
type ExtendResult =
|
||||
| { extended: false }
|
||||
| {
|
||||
extended: true
|
||||
previous: Deferred.Deferred<void>
|
||||
scope: Scope.Closeable
|
||||
tail: Deferred.Deferred<void>
|
||||
token: object
|
||||
sequence: number
|
||||
}
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
}
|
||||
|
||||
type BlockStart =
|
||||
| { type: "missing" }
|
||||
| { type: "finished"; info: Info }
|
||||
| { type: "backgrounded"; info: Info }
|
||||
| { type: "wait"; wait: BlockWait }
|
||||
|
||||
export type StartInput = {
|
||||
id?: string
|
||||
type: string
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
onPromote?: Effect.Effect<void>
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
export type ExtendInput = {
|
||||
id: string
|
||||
run: Effect.Effect<string, unknown>
|
||||
}
|
||||
|
||||
@@ -84,18 +76,30 @@ export type WaitResult = {
|
||||
timedOut: boolean
|
||||
}
|
||||
|
||||
export type BlockInput = {
|
||||
id: string
|
||||
sessionID: SessionSchema.ID
|
||||
}
|
||||
|
||||
export type BlockResult = { type: "finished"; info: Info } | { type: "backgrounded"; info: Info }
|
||||
|
||||
export type BackgroundAllInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly get: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly start: (input: StartInput) => Effect.Effect<Info>
|
||||
readonly extend: (input: ExtendInput) => Effect.Effect<boolean>
|
||||
readonly wait: (input: WaitInput) => Effect.Effect<WaitResult>
|
||||
readonly waitForPromotion: (id: string) => Effect.Effect<Info>
|
||||
readonly promote: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly block: (input: BlockInput) => Effect.Effect<BlockResult | undefined>
|
||||
readonly background: (id: string) => Effect.Effect<Info | undefined>
|
||||
readonly backgroundAll: (input: BackgroundAllInput) => Effect.Effect<Info[]>
|
||||
readonly cancel: (id: string) => Effect.Effect<Info | undefined>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/BackgroundJob") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Job") {}
|
||||
|
||||
function snapshot(job: Active): Info {
|
||||
return {
|
||||
@@ -109,6 +113,19 @@ function errorText(error: unknown) {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
|
||||
return new Map(input).set(sessionID, (input.get(sessionID) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
|
||||
const count = input.get(sessionID)
|
||||
if (count === undefined) return input
|
||||
const next = new Map(input)
|
||||
if (count <= 1) next.delete(sessionID)
|
||||
else next.set(sessionID, count - 1)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Entries are intentionally not
|
||||
* durable: process restart or owner-scope closure loses status and interrupts
|
||||
@@ -122,26 +139,13 @@ export const make = Effect.gen(function* () {
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const settle = Effect.fn("BackgroundJob.settle")(function* (
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
exit: Exit.Exit<string, unknown>,
|
||||
) {
|
||||
const settle = Effect.fn("Job.settle")(function* (id: string, token: object, exit: Exit.Exit<string, unknown>) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.token !== token) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const pending = job.pending - 1
|
||||
const output =
|
||||
Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence)
|
||||
? { sequence, text: exit.value }
|
||||
: job.output
|
||||
if (Exit.isSuccess(exit) && pending > 0) {
|
||||
return [{}, new Map(jobs).set(id, { ...job, pending, output })]
|
||||
}
|
||||
const status: Exclude<Status, "running"> = Exit.isSuccess(exit)
|
||||
? "completed"
|
||||
: Cause.hasInterruptsOnly(exit.cause)
|
||||
@@ -149,14 +153,12 @@ export const make = Effect.gen(function* () {
|
||||
: "error"
|
||||
const next = {
|
||||
...job,
|
||||
onPromote: undefined,
|
||||
pending: 0,
|
||||
output,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status,
|
||||
completed_at,
|
||||
...(output ? { output: output.text } : {}),
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
},
|
||||
}
|
||||
@@ -169,43 +171,41 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
const fork = Effect.fn("BackgroundJob.fork")(function* (
|
||||
const fork = Effect.fn("Job.fork")(function* (
|
||||
scope: Scope.Scope,
|
||||
id: string,
|
||||
token: object,
|
||||
sequence: number,
|
||||
run: Effect.Effect<string, unknown>,
|
||||
) {
|
||||
return yield* run.pipe(
|
||||
Effect.matchCauseEffect({
|
||||
onSuccess: (output) => settle(id, token, sequence, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, sequence, Exit.failCause(cause)),
|
||||
onSuccess: (output) => settle(id, token, Exit.succeed(output)),
|
||||
onFailure: (cause) => settle(id, token, Exit.failCause(cause)),
|
||||
}),
|
||||
Effect.asVoid,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
const list: Interface["list"] = Effect.fn("BackgroundJob.list")(function* () {
|
||||
const list: Interface["list"] = Effect.fn("Job.list")(function* () {
|
||||
return Array.from((yield* SynchronizedRef.get(state.jobs)).values())
|
||||
.map(snapshot)
|
||||
.toSorted((a, b) => a.started_at - b.started_at)
|
||||
})
|
||||
|
||||
const get: Interface["get"] = Effect.fn("BackgroundJob.get")(function* (id) {
|
||||
const get: Interface["get"] = Effect.fn("Job.get")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job) return
|
||||
if (!job) return undefined
|
||||
return snapshot(job)
|
||||
})
|
||||
|
||||
const start: Interface["start"] = Effect.fn("BackgroundJob.start")(function* (input) {
|
||||
const start: Interface["start"] = Effect.fn("Job.start")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? Identifier.ascending("job")
|
||||
const started_at = yield* Clock.currentTimeMillis
|
||||
const done = yield* Deferred.make<Info>()
|
||||
const promoted = yield* Deferred.make<Info>()
|
||||
const tail = yield* Deferred.make<void>()
|
||||
const backgrounded = yield* Deferred.make<Info>()
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
@@ -225,13 +225,11 @@ export const make = Effect.gen(function* () {
|
||||
metadata: input.metadata,
|
||||
},
|
||||
done,
|
||||
backgrounded,
|
||||
scope,
|
||||
token,
|
||||
pending: 1,
|
||||
next: 1,
|
||||
tail,
|
||||
promoted,
|
||||
onPromote: input.onPromote,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
}
|
||||
return [{ info: snapshot(job), scope, token }, new Map(jobs).set(id, job)] as readonly [
|
||||
StartResult,
|
||||
@@ -239,56 +237,13 @@ export const make = Effect.gen(function* () {
|
||||
]
|
||||
}),
|
||||
)
|
||||
if ("scope" in result)
|
||||
yield* fork(
|
||||
result.scope,
|
||||
id,
|
||||
result.token,
|
||||
0,
|
||||
restore(input.run).pipe(Effect.ensuring(Deferred.succeed(tail, undefined))),
|
||||
)
|
||||
if ("scope" in result) yield* fork(result.scope, id, result.token, restore(input.run))
|
||||
return result.info
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const extend: Interface["extend"] = Effect.fn("BackgroundJob.extend")(function* (input) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const tail = yield* Deferred.make<void>()
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
(jobs): readonly [ExtendResult, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job || job.info.status !== "running") return [{ extended: false }, jobs]
|
||||
return [
|
||||
{ extended: true, previous: job.tail, scope: job.scope, tail, token: job.token, sequence: job.next },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
pending: job.pending + 1,
|
||||
next: job.next + 1,
|
||||
tail,
|
||||
}),
|
||||
]
|
||||
},
|
||||
)
|
||||
if (!result.extended) return false
|
||||
yield* fork(
|
||||
result.scope,
|
||||
input.id,
|
||||
result.token,
|
||||
result.sequence,
|
||||
Deferred.await(result.previous).pipe(
|
||||
Effect.andThen(restore(input.run)),
|
||||
Effect.ensuring(Deferred.succeed(result.tail, undefined)),
|
||||
),
|
||||
)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const wait: Interface["wait"] = Effect.fn("BackgroundJob.wait")(function* (input) {
|
||||
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
||||
if (!job) return { timedOut: false }
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
@@ -299,41 +254,91 @@ export const make = Effect.gen(function* () {
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
})
|
||||
|
||||
const waitForPromotion: Interface["waitForPromotion"] = Effect.fn("BackgroundJob.waitForPromotion")(function* (id) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(id)
|
||||
if (!job || job.info.status !== "running") return yield* Effect.never
|
||||
if (job.info.metadata?.background === true) return snapshot(job)
|
||||
return yield* Deferred.await(job.promoted)
|
||||
const removeBlock = Effect.fn("Job.removeBlock")(function* (input: BlockInput) {
|
||||
yield* SynchronizedRef.update(state.jobs, (jobs) => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job || job.info.status !== "running" || job.isBackgrounded) return jobs
|
||||
return new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
blockingSessions: decrementSession(job.blockingSessions, input.sessionID),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const promote: Interface["promote"] = Effect.fn("BackgroundJob.promote")(function* (id) {
|
||||
const result = yield* SynchronizedRef.modifyEffect(
|
||||
const block: Interface["block"] = Effect.fn("Job.block")(function* (input) {
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job) return [{ type: "missing" }, jobs]
|
||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
|
||||
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
|
||||
return [
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
|
||||
}),
|
||||
]
|
||||
})
|
||||
if (result.type === "missing") return undefined
|
||||
if (result.type === "finished") return { type: "finished", info: result.info }
|
||||
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
|
||||
return yield* Effect.raceFirst(
|
||||
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
|
||||
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
|
||||
).pipe(Effect.ensuring(removeBlock(input)))
|
||||
})
|
||||
|
||||
const background: Interface["background"] = Effect.fn("Job.background")(function* (id) {
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
Effect.fnUntraced(function* (jobs) {
|
||||
(jobs): readonly [BackgroundResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
if (!job || job.info.status !== "running") return [{}, jobs] as readonly [PromoteResult, Map<string, Active>]
|
||||
if (job.info.metadata?.background === true)
|
||||
return [{ info: snapshot(job) }, jobs] as readonly [PromoteResult, Map<string, Active>]
|
||||
if (!job || job.info.status !== "running") return [{}, jobs]
|
||||
if (job.isBackgrounded) return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
onPromote: undefined,
|
||||
info: {
|
||||
...job.info,
|
||||
metadata: { ...job.info.metadata, background: true },
|
||||
},
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
}
|
||||
return [
|
||||
{ info: snapshot(next), onPromote: job.onPromote, promoted: job.promoted },
|
||||
new Map(jobs).set(id, next),
|
||||
] as readonly [PromoteResult, Map<string, Active>]
|
||||
}),
|
||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
||||
},
|
||||
)
|
||||
if (result.info && result.promoted) yield* Deferred.succeed(result.promoted, result.info).pipe(Effect.ignore)
|
||||
if (result.onPromote) yield* result.onPromote.pipe(Effect.ignore)
|
||||
if (result.info && result.backgrounded)
|
||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
||||
return result.info
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("BackgroundJob.cancel")(function* (id) {
|
||||
const backgroundAll: Interface["backgroundAll"] = Effect.fn("Job.backgroundAll")(function* (input) {
|
||||
const result = yield* SynchronizedRef.modify(
|
||||
state.jobs,
|
||||
(jobs): readonly [BackgroundResult[], Map<string, Active>] => {
|
||||
const results: BackgroundResult[] = []
|
||||
const next = new Map(jobs)
|
||||
for (const [id, job] of jobs) {
|
||||
if (job.info.status !== "running") continue
|
||||
if (job.isBackgrounded) continue
|
||||
if (input.type !== undefined && job.info.type !== input.type) continue
|
||||
if (!job.blockingSessions.has(input.sessionID)) continue
|
||||
const updated = {
|
||||
...job,
|
||||
isBackgrounded: true,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
}
|
||||
results.push({ info: snapshot(updated), backgrounded: job.backgrounded })
|
||||
next.set(id, updated)
|
||||
}
|
||||
return [results, next]
|
||||
},
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
result,
|
||||
(item) => (item.info && item.backgrounded ? Deferred.succeed(item.backgrounded, item.info) : Effect.void),
|
||||
{ discard: true },
|
||||
)
|
||||
return result.flatMap((item) => (item.info ? [item.info] : []))
|
||||
})
|
||||
|
||||
const cancel: Interface["cancel"] = Effect.fn("Job.cancel")(function* (id) {
|
||||
const completed_at = yield* Clock.currentTimeMillis
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [FinishResult, Map<string, Active>] => {
|
||||
const job = jobs.get(id)
|
||||
@@ -341,8 +346,7 @@ export const make = Effect.gen(function* () {
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
onPromote: undefined,
|
||||
pending: 0,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
info: {
|
||||
...job.info,
|
||||
status: "cancelled" as const,
|
||||
@@ -356,9 +360,11 @@ export const make = Effect.gen(function* () {
|
||||
return result.info
|
||||
})
|
||||
|
||||
return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
|
||||
return Service.of({ list, get, start, wait, block, background, backgroundAll, cancel })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -1,151 +0,0 @@
|
||||
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,
|
||||
],
|
||||
}) {}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as LocationMutation from "./location-mutation"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "./fs-util"
|
||||
@@ -153,3 +154,9 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer.pipe(Layer.orDie),
|
||||
deps: [FSUtil.node, Location.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-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"
|
||||
@@ -0,0 +1,124 @@
|
||||
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 } from "./effect/layer-node"
|
||||
import { Node } from "./effect/app-node"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
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 { MCP } from "./mcp/index"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
import { PluginInternal } from "./plugin/internal"
|
||||
import { Policy } from "./policy"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
import * as SessionRunnerLLM from "./session/runner/llm"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
import { SessionCompaction } from "./session/compaction"
|
||||
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 { McpTool } from "./tool/mcp"
|
||||
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([
|
||||
Project.node,
|
||||
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,
|
||||
Shell.node,
|
||||
SkillV2.node,
|
||||
SystemContextRegistry.node,
|
||||
SystemContextBuiltIns.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
MCP.node,
|
||||
PermissionV2.node,
|
||||
ToolOutputStore.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
Image.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
SessionTodo.node,
|
||||
QuestionV2.node,
|
||||
Generate.node,
|
||||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
McpTool.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionCompaction.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 = LayerNode.hoist(
|
||||
LayerNode.bind(locationServices, Location.node, Location.boundNode(ref)),
|
||||
Node.tags.values.global,
|
||||
)
|
||||
return LayerNode.compile(location.node, replacements).pipe(
|
||||
Layer.fresh,
|
||||
Layer.tap(() =>
|
||||
Effect.logInfo("booting location services", {
|
||||
directory: ref.directory,
|
||||
workspaceID: ref.workspaceID,
|
||||
}),
|
||||
),
|
||||
Layer.provide(LayerNode.compile(location.hoisted, replacements)),
|
||||
)
|
||||
},
|
||||
{ idleTimeToLive: "60 minutes" },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// This is temporary for backwards compatibility
|
||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Info, Ref, response } from "@opencode-ai/schema/location"
|
||||
import { Project } from "./project"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { makeLocationNode, tags } from "./effect/app-node"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
@@ -12,6 +14,8 @@ export interface Interface extends Info {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
|
||||
|
||||
export const node = LayerNode.unbound(Service, tags.values.location)
|
||||
|
||||
export const layer = (ref: Ref) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
@@ -26,3 +30,10 @@ export const layer = (ref: Ref) =>
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const boundNode = (ref: Ref) =>
|
||||
makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(ref),
|
||||
deps: [Project.node],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
export * as MCPClient from "./client"
|
||||
|
||||
import path from "node:path"
|
||||
import { execFile } from "node:child_process"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
const DEFAULT_REQUEST_TIMEOUT = 30_000
|
||||
|
||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport
|
||||
|
||||
// Some servers advertise tool outputSchemas the SDK's strict validator can't resolve; this drops
|
||||
// only that field so a single bad schema doesn't blank out the whole tool list.
|
||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
|
||||
export class NeedsAuthError extends Schema.TaggedErrorClass<NeedsAuthError>()("MCP.NeedsAuthError", {
|
||||
server: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ConnectError extends Schema.TaggedErrorClass<ConnectError>()("MCP.ConnectError", {
|
||||
server: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface ToolDefinition {
|
||||
readonly name: string
|
||||
readonly description: string | undefined
|
||||
readonly inputSchema: unknown
|
||||
}
|
||||
|
||||
export type CallToolContent =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "media"; readonly data: string; readonly mimeType: string }
|
||||
|
||||
export interface CallToolResult {
|
||||
readonly isError: boolean
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<CallToolContent>
|
||||
}
|
||||
|
||||
export interface LogMessage {
|
||||
readonly level: LoggingMessageNotification["params"]["level"]
|
||||
readonly logger?: LoggingMessageNotification["params"]["logger"]
|
||||
readonly data: LoggingMessageNotification["params"]["data"]
|
||||
}
|
||||
|
||||
/** Handle over a connected MCP server that keeps the SDK `Client` out of the rest of core. */
|
||||
export interface Connection {
|
||||
/** Server-supplied usage instructions from the initialize result, if any. */
|
||||
readonly instructions: string | undefined
|
||||
/** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */
|
||||
readonly tools: () => Effect.Effect<ToolDefinition[], Error>
|
||||
/** Invokes a tool on the server. Interruption aborts the in-flight request. */
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
readonly onLog: (callback: (message: LogMessage) => void) => void
|
||||
/** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */
|
||||
readonly onToolsChanged: (callback: () => void) => void
|
||||
}
|
||||
|
||||
/** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */
|
||||
export const connect = Effect.fnUntraced(function* (
|
||||
server: string,
|
||||
config: typeof ConfigMCP.Server.Type,
|
||||
directory: string,
|
||||
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
|
||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
||||
authProvider?: OAuthClientProvider,
|
||||
) {
|
||||
const transport: Transport = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
const [command, ...args] = config.command
|
||||
return new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
cwd: config.cwd ? path.resolve(directory, config.cwd) : directory,
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...(process.env as Record<string, string>),
|
||||
...(command === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...config.environment,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!URL.canParse(config.url)) return yield* new ConnectError({ server, message: `Invalid MCP URL for "${server}"` })
|
||||
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: config.headers ? { headers: config.headers } : undefined,
|
||||
authProvider,
|
||||
})
|
||||
})
|
||||
const client = new Client(
|
||||
{ name: "opencode", version: InstallationVersion },
|
||||
{
|
||||
capabilities: {
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
|
||||
const exit = yield* Effect.tryPromise({
|
||||
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
||||
catch: (error) => error,
|
||||
}).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => client.close())),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT
|
||||
return {
|
||||
instructions: client.getInstructions()?.trim() || undefined,
|
||||
tools: () =>
|
||||
Effect.gen(function* () {
|
||||
if (!client.getServerCapabilities()?.tools) return []
|
||||
const tools = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
async (cursor) => {
|
||||
const params = cursor === undefined ? undefined : { cursor }
|
||||
try {
|
||||
return await client.listTools(params, { timeout: requestTimeout })
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
|
||||
return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
|
||||
timeout: requestTimeout,
|
||||
})
|
||||
}
|
||||
},
|
||||
(result) => result.tools,
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.tapError((error) => Effect.logWarning("failed to list MCP tools", { server, error: error.message })),
|
||||
)
|
||||
return tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
}))
|
||||
}),
|
||||
callTool: (input) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
CallToolResultSchema,
|
||||
// The SDK only sends a progress token when onprogress is present, which enables timeout resets.
|
||||
{ signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} },
|
||||
),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
isError: result.isError === true,
|
||||
structured: result.structuredContent,
|
||||
content: result.content.flatMap((part): CallToolContent[] => {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "image" || part.type === "audio")
|
||||
return [{ type: "media", data: part.data, mimeType: part.mimeType }]
|
||||
if (part.type === "resource_link") return [{ type: "text", text: part.uri }]
|
||||
if (part.type === "resource") {
|
||||
const resource = part.resource
|
||||
if ("text" in resource && typeof resource.text === "string")
|
||||
return [{ type: "text", text: resource.text }]
|
||||
if ("blob" in resource && typeof resource.blob === "string" && typeof resource.mimeType === "string")
|
||||
return [{ type: "media", data: resource.blob, mimeType: resource.mimeType }]
|
||||
return [{ type: "text", text: resource.uri }]
|
||||
}
|
||||
return []
|
||||
}),
|
||||
})),
|
||||
),
|
||||
onClose: (callback) => {
|
||||
client.onclose = callback
|
||||
},
|
||||
onLog: (callback) => {
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => callback(notification.params))
|
||||
},
|
||||
onToolsChanged: (callback) => {
|
||||
if (!client.getServerCapabilities()?.tools?.listChanged) return
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback())
|
||||
},
|
||||
} satisfies Connection
|
||||
}
|
||||
|
||||
yield* cleanupStdioDescendants(transport).pipe(
|
||||
Effect.andThen(Effect.promise(() => transport.close())),
|
||||
Effect.ignore,
|
||||
)
|
||||
const error = Cause.squash(exit.cause)
|
||||
if (error instanceof UnauthorizedError) return yield* new NeedsAuthError({ server })
|
||||
return yield* new ConnectError({ server, message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
|
||||
// SDK close stops the MCP process, but not child processes it spawned.
|
||||
const cleanupStdioDescendants = (transport: Transport) =>
|
||||
Effect.gen(function* () {
|
||||
if (!(transport instanceof StdioClientTransport)) return
|
||||
const pid = transport.pid
|
||||
if (typeof pid !== "number") return
|
||||
yield* Effect.forEach(
|
||||
yield* descendantPids(pid),
|
||||
(pid) =>
|
||||
Effect.try({
|
||||
try: () => process.kill(pid, "SIGTERM"),
|
||||
catch: () => undefined,
|
||||
}).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const descendantPids = Effect.fnUntraced(function* (root: number) {
|
||||
if (process.platform === "win32") return []
|
||||
const result: number[] = []
|
||||
const queue = [root]
|
||||
for (let index = 0; index < queue.length; index++) {
|
||||
const parent = queue[index]
|
||||
if (parent === undefined) return result
|
||||
const children = (yield* childPids(parent)).filter((pid) => !result.includes(pid))
|
||||
result.push(...children)
|
||||
queue.push(...children)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const childPids = (pid: number) =>
|
||||
Effect.promise(
|
||||
() =>
|
||||
new Promise<number[]>((resolve) => {
|
||||
execFile("pgrep", ["-P", String(pid)], { encoding: "utf8" }, (_error, stdout) => {
|
||||
resolve(
|
||||
stdout
|
||||
.split("\n")
|
||||
.map((line) => Number.parseInt(line, 10))
|
||||
.filter((pid) => Number.isInteger(pid)),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
async function paginate<R extends { nextCursor?: string }, T>(
|
||||
list: (cursor: string | undefined) => Promise<R>,
|
||||
items: (result: R) => T[],
|
||||
) {
|
||||
const collected: T[] = []
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
while (true) {
|
||||
const result = await list(cursor)
|
||||
collected.push(...items(result))
|
||||
if (result.nextCursor === undefined) return collected
|
||||
// A repeating cursor never terminates; bail instead of hanging the connection forever.
|
||||
if (seen.has(result.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${result.nextCursor}`)
|
||||
seen.add(result.nextCursor)
|
||||
cursor = result.nextCursor
|
||||
}
|
||||
}
|
||||
|
||||
const isOutputSchemaError = (error: Error) =>
|
||||
/can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
|
||||
error.message,
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
export * as McpGuidance from "./guidance"
|
||||
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { McpTool } from "../tool/mcp"
|
||||
import { MCP } from "./index"
|
||||
import { SystemContext } from "../system-context/index"
|
||||
|
||||
const Summary = Schema.Struct({
|
||||
server: Schema.String,
|
||||
instructions: Schema.String,
|
||||
})
|
||||
type Summary = typeof Summary.Type
|
||||
|
||||
const render = (servers: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"<mcp_instructions>",
|
||||
...servers.flatMap((server) => [
|
||||
` <server name="${server.server}">`,
|
||||
...server.instructions.split("\n").map((line) => ` ${line}`),
|
||||
" </server>",
|
||||
]),
|
||||
"</mcp_instructions>",
|
||||
].join("\n")
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (agent: AgentV2.Selection) => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/McpGuidance") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
|
||||
return Service.of({
|
||||
load: Effect.fn("McpGuidance.load")(function* (selection) {
|
||||
const agent = selection.info
|
||||
if (!agent) return SystemContext.empty
|
||||
const [instructions, tools] = yield* Effect.all([mcp.instructions(), mcp.tools()], {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
// Hide a server only when every tool it contributes is wholly denied for this agent.
|
||||
const visible = instructions
|
||||
.filter((item) => {
|
||||
const owned = tools.filter((tool) => tool.server === item.server)
|
||||
return (
|
||||
owned.length === 0 ||
|
||||
owned.some(
|
||||
(tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny",
|
||||
)
|
||||
)
|
||||
})
|
||||
.map((item) => ({ server: item.server, instructions: item.instructions }))
|
||||
if (visible.length === 0) return SystemContext.empty
|
||||
return SystemContext.make({
|
||||
key: SystemContext.Key.make("core/mcp-guidance"),
|
||||
codec: Schema.toCodecJson(Schema.Array(Summary)),
|
||||
load: Effect.succeed(visible),
|
||||
baseline: render,
|
||||
update: (_previous, current) =>
|
||||
[
|
||||
"The available MCP server instructions have changed. This list supersedes the previous one.",
|
||||
render(current),
|
||||
].join("\n"),
|
||||
removed: () => "MCP server instructions are no longer available.",
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [MCP.node] })
|
||||
@@ -0,0 +1,515 @@
|
||||
export * as MCP from "./index"
|
||||
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { createHash } from "node:crypto"
|
||||
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { Config } from "../config"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { Integration } from "../integration"
|
||||
import { IntegrationConnection } from "../integration/connection"
|
||||
import { Location } from "../location"
|
||||
import { MCPClient } from "./client"
|
||||
import { MCPOAuth } from "./oauth"
|
||||
|
||||
export const ServerName = Schema.String.pipe(Schema.brand("MCP.ServerName"))
|
||||
export type ServerName = typeof ServerName.Type
|
||||
|
||||
// The status union is a public wire contract, so it lives in @opencode-ai/schema and is re-exported here.
|
||||
export const Status = Mcp.Status
|
||||
export type Status = Mcp.Status
|
||||
|
||||
export class ServerInfo extends Schema.Class<ServerInfo>("MCP.ServerInfo")({
|
||||
name: ServerName,
|
||||
status: Status,
|
||||
integrationID: Integration.ID.pipe(Schema.optional),
|
||||
connection: IntegrationConnection.Info.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ServerInstructions extends Schema.Class<ServerInstructions>("MCP.ServerInstructions")({
|
||||
server: ServerName,
|
||||
instructions: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Tool extends Schema.Class<Tool>("MCP.Tool")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
inputSchema: Schema.Unknown.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const ToolResultContent = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("media"), data: Schema.String, mimeType: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ToolResultContent = typeof ToolResultContent.Type
|
||||
|
||||
export class ToolResult extends Schema.Class<ToolResult>("MCP.ToolResult")({
|
||||
server: ServerName,
|
||||
tool: Schema.String,
|
||||
isError: Schema.Boolean,
|
||||
structured: Schema.Unknown.pipe(Schema.optional),
|
||||
content: Schema.Array(ToolResultContent),
|
||||
}) {}
|
||||
|
||||
export class PromptArgument extends Schema.Class<PromptArgument>("MCP.PromptArgument")({
|
||||
name: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
required: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("MCP.Prompt")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
arguments: Schema.Array(PromptArgument).pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class PromptMessage extends Schema.Class<PromptMessage>("MCP.PromptMessage")({
|
||||
role: Schema.String,
|
||||
content: Schema.Unknown,
|
||||
}) {}
|
||||
|
||||
export class PromptResult extends Schema.Class<PromptResult>("MCP.PromptResult")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
messages: Schema.Array(PromptMessage),
|
||||
}) {}
|
||||
|
||||
export class Resource extends Schema.Class<Resource>("MCP.Resource")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uri: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceTemplate extends Schema.Class<ResourceTemplate>("MCP.ResourceTemplate")({
|
||||
server: ServerName,
|
||||
name: Schema.String,
|
||||
uriTemplate: Schema.String,
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ResourceCatalog extends Schema.Class<ResourceCatalog>("MCP.ResourceCatalog")({
|
||||
resources: Schema.Array(Resource),
|
||||
templates: Schema.Array(ResourceTemplate),
|
||||
}) {}
|
||||
|
||||
export const ResourceContentPart = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
uri: Schema.String,
|
||||
text: Schema.String,
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("blob"),
|
||||
uri: Schema.String,
|
||||
blob: Schema.String,
|
||||
mimeType: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export type ResourceContentPart = typeof ResourceContentPart.Type
|
||||
|
||||
export class ResourceContent extends Schema.Class<ResourceContent>("MCP.ResourceContent")({
|
||||
server: ServerName,
|
||||
uri: Schema.String,
|
||||
contents: Schema.Array(ResourceContentPart),
|
||||
}) {}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP.NotFoundError", {
|
||||
server: ServerName,
|
||||
}) {}
|
||||
|
||||
export class ToolCallError extends Schema.TaggedErrorClass<ToolCallError>()("MCP.ToolCallError", {
|
||||
server: ServerName,
|
||||
tool: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
type ServerEntry = {
|
||||
readonly config: typeof ConfigMCP.Server.Type
|
||||
status: Status
|
||||
readonly startup: Deferred.Deferred<void>
|
||||
scope?: Scope.Closeable
|
||||
client?: MCPClient.Connection
|
||||
tools?: ReadonlyArray<Tool>
|
||||
// Set when a remote server is registered as an OAuth integration; the credential lives in the global store.
|
||||
integrationID?: Integration.ID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly tools: () => Effect.Effect<Tool[]>
|
||||
readonly callTool: (input: {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
readonly prompt: (input: {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, string>
|
||||
}) => Effect.Effect<PromptResult | undefined, NotFoundError>
|
||||
readonly resourceCatalog: () => Effect.Effect<ResourceCatalog>
|
||||
readonly readResource: (input: {
|
||||
readonly server: ServerName | string
|
||||
readonly uri: string
|
||||
}) => Effect.Effect<ResourceContent | undefined, NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/MCP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const events = yield* EventV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const root = yield* Scope.make()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
yield* Effect.addFinalizer((exit) => Scope.close(root, exit))
|
||||
|
||||
const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
|
||||
// Global MCP timeout defaults, later config files overriding earlier ones.
|
||||
const timeout = Object.assign(
|
||||
{},
|
||||
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
|
||||
)
|
||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||
const runtime = new Map<ServerName, ServerEntry>()
|
||||
for (const entry of documents) {
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
runtime.set(ServerName.make(name), {
|
||||
config: { ...server, timeout: { ...timeout, ...server.timeout } },
|
||||
status: { status: "disconnected" },
|
||||
startup: Deferred.makeUnsafe<void>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Register every remote server as an OAuth integration so credentials live in the global store
|
||||
// rather than in committed config. Servers that connect anonymously simply never use the method.
|
||||
const registrations: Array<{
|
||||
readonly name: ServerName
|
||||
readonly remote: typeof ConfigMCP.Remote.Type
|
||||
readonly integrationID: Integration.ID
|
||||
readonly methodID: Integration.MethodID
|
||||
}> = []
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.type !== "remote" || entry.config.oauth === false) continue
|
||||
const remote = entry.config
|
||||
// Key identity on name + url, not url alone: two configs for the same url under different names are
|
||||
// distinct logical servers that may hold different accounts, so they must not share a credential row.
|
||||
const suffix =
|
||||
"mcp_" +
|
||||
createHash("sha1")
|
||||
.update(name + "\u0000" + remote.url)
|
||||
.digest("hex")
|
||||
.slice(0, 16)
|
||||
entry.integrationID = Integration.ID.make(suffix)
|
||||
registrations.push({
|
||||
name,
|
||||
remote,
|
||||
integrationID: entry.integrationID,
|
||||
methodID: Integration.MethodID.make(suffix),
|
||||
})
|
||||
}
|
||||
if (registrations.length > 0)
|
||||
yield* integration.transform((draft) => {
|
||||
for (const reg of registrations) {
|
||||
draft.update(reg.integrationID, (ref) => {
|
||||
ref.name = reg.name
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: reg.integrationID,
|
||||
method: { id: reg.methodID, type: "oauth", label: reg.name },
|
||||
authorize: () => MCPOAuth.authorize({ name: reg.name, config: reg.remote, methodID: reg.methodID }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const requireServer = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const name = ServerName.make(server)
|
||||
const entry = runtime.get(name)
|
||||
if (!entry) return yield* new NotFoundError({ server: name })
|
||||
return { name, entry }
|
||||
})
|
||||
|
||||
const info = (name: ServerName, entry: ServerEntry, connection: IntegrationConnection.Info | undefined) =>
|
||||
new ServerInfo({
|
||||
name,
|
||||
status: entry.status,
|
||||
integrationID: entry.integrationID,
|
||||
connection,
|
||||
})
|
||||
|
||||
// Builds the connect-time auth provider for a remote OAuth-integration server. The SDK presents and
|
||||
// refreshes stored tokens, persisting refreshes back to the same credential row. The provider never
|
||||
// opens a browser, so an auth-gated connect ends in UnauthorizedError -> needs_auth rather than a redirect.
|
||||
const connectProvider = Effect.fnUntraced(function* (entry: ServerEntry) {
|
||||
if (entry.config.type !== "remote" || !entry.integrationID) return undefined
|
||||
const remote = entry.config
|
||||
const oauth = remote.oauth || undefined
|
||||
const base = {
|
||||
redirectUrl: oauth?.redirect_uri ?? "http://127.0.0.1/callback",
|
||||
scope: oauth?.scope,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
|
||||
onRedirect: () => {},
|
||||
}
|
||||
const stored = yield* credentials.list(entry.integrationID)
|
||||
const found = stored.find((credential) => credential.value.type === "oauth")
|
||||
if (!found || found.value.type !== "oauth")
|
||||
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
|
||||
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
|
||||
// a raw HTTP error, hiding the auth requirement behind a generic failed status. Anonymous servers are
|
||||
// unaffected: tokens() returns undefined, so no auth header is sent and the SDK never calls auth().
|
||||
return MCPOAuth.provider({ ...base, store: MCPOAuth.memoryStore() })
|
||||
const credentialID = found.id
|
||||
const methodID = found.value.methodID
|
||||
let current: Credential.OAuth | undefined = found.value
|
||||
return MCPOAuth.provider({
|
||||
...base,
|
||||
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth. Uses the raw
|
||||
// credential service (no integration event) to avoid re-triggering the reconnect subscriber mid-connect.
|
||||
invalidate: async (scope) => {
|
||||
if (scope === "verifier" || scope === "discovery") return
|
||||
current = undefined
|
||||
await Effect.runPromise(credentials.remove(credentialID))
|
||||
},
|
||||
store: {
|
||||
tokens: async () => (current ? MCPOAuth.toTokens(current) : undefined),
|
||||
saveTokens: async (tokens) => {
|
||||
current = MCPOAuth.toCredential({
|
||||
methodID,
|
||||
serverUrl: remote.url,
|
||||
tokens,
|
||||
client: current ? MCPOAuth.clientFromCredential(current) : undefined,
|
||||
})
|
||||
await Effect.runPromise(credentials.update(credentialID, { value: current }))
|
||||
},
|
||||
clientInformation: async () => (current ? MCPOAuth.clientFromCredential(current) : undefined),
|
||||
saveClientInformation: async () => {},
|
||||
codeVerifier: async () => undefined,
|
||||
saveCodeVerifier: async () => {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
||||
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
||||
|
||||
const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) =>
|
||||
connection.tools().pipe(
|
||||
Effect.map((defs) => {
|
||||
entry.tools = defs.map((def) => toTool(name, def))
|
||||
}),
|
||||
)
|
||||
|
||||
const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => {
|
||||
connection.onClose(() => {
|
||||
// A reconnect closes the previous scope, but the SDK may fire this onclose after the new
|
||||
// connection is already assigned; ignore the stale close so it can't null out the live client.
|
||||
if (entry.client !== connection) return
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onToolsChanged(() => {
|
||||
fork(
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
Effect.andThen(events.publish(McpEvent.ToolsChanged, { server: name })),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const serverLog = (server: ServerName, message: MCPClient.LogMessage) => {
|
||||
const fields = { server, logger: message.logger, level: message.level, data: message.data }
|
||||
switch (message.level) {
|
||||
case "debug":
|
||||
return Effect.logDebug("MCP server log", fields)
|
||||
case "info":
|
||||
case "notice":
|
||||
return Effect.logInfo("MCP server log", fields)
|
||||
case "warning":
|
||||
return Effect.logWarning("MCP server log", fields)
|
||||
case "error":
|
||||
case "critical":
|
||||
case "alert":
|
||||
case "emergency":
|
||||
return Effect.logError("MCP server log", fields)
|
||||
}
|
||||
}
|
||||
|
||||
const startServer = (name: ServerName, entry: ServerEntry) =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(result)) {
|
||||
entry.client = result.value.connection
|
||||
entry.tools = result.value.defs.map((def) => toTool(name, def))
|
||||
entry.status = { status: "connected" }
|
||||
watch(name, entry, result.value.connection)
|
||||
yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length })
|
||||
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
|
||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
return
|
||||
}
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
entry.scope = undefined
|
||||
const error = Cause.squash(result.cause)
|
||||
entry.status =
|
||||
error instanceof MCPClient.NeedsAuthError
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(entry.startup, undefined)))
|
||||
|
||||
// Disabled servers settle their startup immediately so queries never block on them.
|
||||
for (const [name, entry] of runtime) {
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
Deferred.doneUnsafe(entry.startup, Exit.void)
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry))
|
||||
}
|
||||
|
||||
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
|
||||
// OAuth login takes effect without a restart. Only fires for the integrations we registered.
|
||||
const owned = new Set(registrations.map((reg) => reg.integrationID))
|
||||
const reconnect = (integrationID: Integration.ID) =>
|
||||
Effect.gen(function* () {
|
||||
const match = Array.from(runtime).find(([, entry]) => entry.integrationID === integrationID)
|
||||
if (!match) return
|
||||
const [name, entry] = match
|
||||
if (entry.config.disabled) return
|
||||
if (entry.scope) {
|
||||
yield* Scope.close(entry.scope, Exit.void)
|
||||
entry.scope = undefined
|
||||
entry.client = undefined
|
||||
entry.tools = undefined
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
})
|
||||
fork(
|
||||
events.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
|
||||
const whenAllReady = Effect.forEach(runtime.values(), (entry) => Deferred.await(entry.startup), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
const gate = Effect.fnUntraced(function* (server: ServerName | string) {
|
||||
const target = yield* requireServer(server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
servers: Effect.fn("MCP.servers")(function* () {
|
||||
const entries = Array.from(runtime).toSorted(([a], [b]) => a.localeCompare(b))
|
||||
return yield* Effect.forEach(entries, ([name, entry]) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = entry.integrationID
|
||||
? yield* integration.connection.active(entry.integrationID)
|
||||
: undefined
|
||||
return info(name, entry, connection)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
tools: Effect.fn("MCP.tools")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(runtime.values())
|
||||
.flatMap((entry) => entry.tools ?? [])
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name))
|
||||
}),
|
||||
callTool: Effect.fn("MCP.callTool")(function* (input) {
|
||||
const target = yield* requireServer(input.server)
|
||||
yield* Deferred.await(target.entry.startup)
|
||||
if (!target.entry.client)
|
||||
return yield* new ToolCallError({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
),
|
||||
)
|
||||
return new ToolResult({
|
||||
server: target.name,
|
||||
tool: input.name,
|
||||
isError: result.isError,
|
||||
structured: result.structured,
|
||||
content: result.content,
|
||||
})
|
||||
}),
|
||||
instructions: Effect.fn("MCP.instructions")(function* () {
|
||||
yield* whenAllReady
|
||||
return Array.from(runtime)
|
||||
.flatMap(([server, entry]) => {
|
||||
const instructions = entry.client?.instructions
|
||||
if (!instructions) return []
|
||||
return [new ServerInstructions({ server, instructions })]
|
||||
})
|
||||
.toSorted((a, b) => a.server.localeCompare(b.server))
|
||||
}),
|
||||
prompts: Effect.fn("MCP.prompts")(function* () {
|
||||
yield* whenAllReady
|
||||
return []
|
||||
}),
|
||||
prompt: Effect.fn("MCP.prompt")(function* (input) {
|
||||
yield* gate(input.server)
|
||||
return undefined
|
||||
}),
|
||||
resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () {
|
||||
yield* whenAllReady
|
||||
return new ResourceCatalog({ resources: [], templates: [] })
|
||||
}),
|
||||
readResource: Effect.fn("MCP.readResource")(function* (input) {
|
||||
yield* gate(input.server)
|
||||
return undefined
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
export * as MCPOAuth from "./oauth"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "../config/mcp"
|
||||
import { OauthCallbackPage } from "../oauth/page"
|
||||
import type { Integration } from "../integration"
|
||||
|
||||
/** Persists the OAuth artifacts for one MCP server session: DCR client info, PKCE verifier, and tokens. */
|
||||
export interface Store {
|
||||
readonly tokens: () => Promise<OAuthTokens | undefined>
|
||||
readonly saveTokens: (tokens: OAuthTokens) => Promise<void>
|
||||
readonly clientInformation: () => Promise<OAuthClientInformationMixed | undefined>
|
||||
readonly saveClientInformation: (info: OAuthClientInformationMixed) => Promise<void>
|
||||
readonly codeVerifier: () => Promise<string | undefined>
|
||||
readonly saveCodeVerifier: (verifier: string) => Promise<void>
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
/** Loopback URL the authorization server redirects back to after the user approves. */
|
||||
readonly redirectUrl: string
|
||||
/** Space-delimited OAuth scopes to request when the server requires specific ones. */
|
||||
readonly scope?: string
|
||||
/** CSRF state embedded in the authorization request; required by the spec and enforced by some servers.
|
||||
* The caller is responsible for validating the value echoed back to the redirect. */
|
||||
readonly state?: string
|
||||
/** Statically pre-registered client credentials from config; when set, the SDK skips dynamic registration. */
|
||||
readonly client?: { readonly id: string; readonly secret?: string }
|
||||
/** Invoked by the SDK to drop credentials it has determined are invalid (e.g. a rejected refresh token). */
|
||||
readonly invalidate?: (scope: "all" | "client" | "tokens" | "verifier" | "discovery") => void | Promise<void>
|
||||
/** Receives the authorization URL so the caller can open a browser and capture the eventual code. */
|
||||
readonly onRedirect: (url: URL) => void | Promise<void>
|
||||
readonly store: Store
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the MCP SDK's OAuthClientProvider. The SDK drives dynamic client registration, PKCE, and
|
||||
* token refresh through these callbacks; we only persist whatever it hands back via `store`.
|
||||
*/
|
||||
export const provider = (options: Options): OAuthClientProvider => {
|
||||
const state = options.state
|
||||
const client = options.client
|
||||
return {
|
||||
redirectUrl: options.redirectUrl,
|
||||
clientMetadata: {
|
||||
redirect_uris: [options.redirectUrl],
|
||||
client_name: "opencode",
|
||||
client_uri: "https://opencode.ai",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: client?.secret ? "client_secret_post" : "none",
|
||||
...(options.scope ? { scope: options.scope } : {}),
|
||||
},
|
||||
// Only advertise state when the caller supplied one (the interactive flow); the connect-time
|
||||
// provider has no redirect to validate, so it omits it.
|
||||
...(state !== undefined ? { state: () => state } : {}),
|
||||
// Static client config short-circuits dynamic registration; otherwise the SDK registers and we persist.
|
||||
clientInformation: () =>
|
||||
client ? { client_id: client.id, client_secret: client.secret } : options.store.clientInformation(),
|
||||
saveClientInformation: (info) => options.store.saveClientInformation(info),
|
||||
tokens: () => options.store.tokens(),
|
||||
saveTokens: (tokens) => options.store.saveTokens(tokens),
|
||||
redirectToAuthorization: (url) => options.onRedirect(url),
|
||||
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
|
||||
saveCodeVerifier: (verifier) => options.store.saveCodeVerifier(verifier),
|
||||
// The SDK only reads the verifier back after saving one earlier in the same flow; a miss means
|
||||
// the flow was resumed without its session state, which the SDK surfaces as an auth failure.
|
||||
codeVerifier: async () => {
|
||||
const verifier = await options.store.codeVerifier()
|
||||
if (!verifier) throw new Error("Missing PKCE code verifier for MCP OAuth flow")
|
||||
return verifier
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A Store that keeps OAuth artifacts in memory for the duration of one interactive login attempt. */
|
||||
export const memoryStore = (): Store => {
|
||||
let tokens: OAuthTokens | undefined
|
||||
let client: OAuthClientInformationMixed | undefined
|
||||
let verifier: string | undefined
|
||||
return {
|
||||
tokens: async () => tokens,
|
||||
saveTokens: async (value) => {
|
||||
tokens = value
|
||||
},
|
||||
clientInformation: async () => client,
|
||||
saveClientInformation: async (value) => {
|
||||
client = value
|
||||
},
|
||||
codeVerifier: async () => verifier,
|
||||
saveCodeVerifier: async (value) => {
|
||||
verifier = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the dynamically-registered client info we stash in a credential's metadata, for token refresh. */
|
||||
export const clientFromCredential = (credential: Credential.OAuth) =>
|
||||
credential.metadata?.client as OAuthClientInformationMixed | undefined
|
||||
|
||||
/** Folds SDK tokens (plus DCR client info and the server URL) into a storable credential. */
|
||||
export const toCredential = (input: {
|
||||
readonly methodID: Integration.MethodID
|
||||
readonly serverUrl: string
|
||||
readonly tokens: OAuthTokens
|
||||
readonly client: OAuthClientInformationMixed | undefined
|
||||
}) =>
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: input.methodID,
|
||||
access: input.tokens.access_token,
|
||||
refresh: input.tokens.refresh_token ?? "",
|
||||
// 0 marks an unknown/non-expiring token; toTokens then omits expires_in so the SDK won't force a refresh.
|
||||
expires: input.tokens.expires_in ? Date.now() + input.tokens.expires_in * 1000 : 0,
|
||||
metadata: {
|
||||
serverUrl: input.serverUrl,
|
||||
tokenType: input.tokens.token_type,
|
||||
...(input.tokens.scope ? { scope: input.tokens.scope } : {}),
|
||||
...(input.client ? { client: input.client } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
/** Reconstructs SDK tokens from a stored credential so the connect-time provider can present them. */
|
||||
export const toTokens = (credential: Credential.OAuth): OAuthTokens => {
|
||||
const metadata = credential.metadata ?? {}
|
||||
return {
|
||||
access_token: credential.access,
|
||||
token_type: typeof metadata.tokenType === "string" ? metadata.tokenType : "Bearer",
|
||||
...(credential.refresh ? { refresh_token: credential.refresh } : {}),
|
||||
...(credential.expires ? { expires_in: Math.max(0, Math.floor((credential.expires - Date.now()) / 1000)) } : {}),
|
||||
...(typeof metadata.scope === "string" ? { scope: metadata.scope } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the interactive OAuth login for one remote MCP server. Stands up a loopback callback server,
|
||||
* lets the SDK drive DCR + PKCE to produce an authorization URL, and returns an attempt whose callback
|
||||
* exchanges the redirect code for a storable credential. Scoped: the callback server closes with the scope.
|
||||
*/
|
||||
export const authorize = (input: {
|
||||
readonly name: string
|
||||
readonly config: typeof ConfigMCP.Remote.Type
|
||||
readonly methodID: Integration.MethodID
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const oauth = input.config.oauth || undefined
|
||||
const store = memoryStore()
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== redirectPath) {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response.writeHead(400, { "Content-Type": "text/html" }).end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
if (error) return fail(error)
|
||||
// Reject a redirect whose state does not match what we issued: this is the CSRF defense the
|
||||
// state parameter exists for, so an attacker can't inject their own authorization code.
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code")
|
||||
Effect.runFork(Deferred.succeed(code, value))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
|
||||
// Bind the port the redirect will actually arrive on: an explicit callback_port wins, else the port
|
||||
// pinned by redirect_uri, else an ephemeral port. Binding ephemerally while redirect_uri names a fixed
|
||||
// port would send the browser somewhere nothing is listening, hanging the attempt until it expires.
|
||||
const redirectPort = oauth?.redirect_uri ? Number(new URL(oauth.redirect_uri).port) || undefined : undefined
|
||||
const port = yield* Effect.callback<number, Error>((resume) => {
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(oauth?.callback_port ?? redirectPort ?? 0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
resume(
|
||||
address && typeof address === "object"
|
||||
? Effect.succeed(address.port)
|
||||
: Effect.fail(new Error("Could not determine MCP OAuth callback port")),
|
||||
)
|
||||
})
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
|
||||
let authorizationUrl: URL | undefined
|
||||
const oauthProvider = provider({
|
||||
redirectUrl: oauth?.redirect_uri ?? `http://127.0.0.1:${port}${redirectPath}`,
|
||||
scope: oauth?.scope,
|
||||
state,
|
||||
client: oauth?.client_id ? { id: oauth.client_id, secret: oauth.client_secret } : undefined,
|
||||
onRedirect: (url) => {
|
||||
authorizationUrl = url
|
||||
},
|
||||
store,
|
||||
})
|
||||
|
||||
const finalize = Effect.gen(function* () {
|
||||
const tokens = yield* Effect.promise(() => store.tokens())
|
||||
if (!tokens) return yield* Effect.fail(new Error(`MCP server "${input.name}" did not return OAuth tokens`))
|
||||
const client = yield* Effect.promise(() => store.clientInformation())
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
||||
if (result === "AUTHORIZED") {
|
||||
return { url: input.config.url, instructions: `Connected to ${input.name}.`, mode: "auto" as const, callback: finalize }
|
||||
}
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
return {
|
||||
url: authorizationUrl.toString(),
|
||||
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
|
||||
mode: "auto" as const,
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, authorizationCode: value, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
),
|
||||
}
|
||||
})
|
||||
@@ -9,8 +9,8 @@ import { Hash } from "./util/hash"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { InstallationChannel, InstallationVersion } from "./installation/version"
|
||||
import { EventV2 } from "./event"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { httpClient } from "./effect/layer-node-platform"
|
||||
import { makeGlobalNode } from "./effect/app-node"
|
||||
import { httpClient } from "./effect/app-node-platform"
|
||||
|
||||
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
|
||||
export type CatalogModelStatus = typeof CatalogModelStatus.Type
|
||||
@@ -44,6 +44,21 @@ const Cost = Schema.Struct({
|
||||
),
|
||||
})
|
||||
|
||||
const ReasoningOption = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
values: Schema.Array(Schema.String),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("toggle"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("budget_tokens"),
|
||||
min: Schema.optional(Schema.Finite),
|
||||
max: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
])
|
||||
|
||||
export const Model = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
@@ -51,6 +66,7 @@ export const Model = Schema.Struct({
|
||||
release_date: Schema.String,
|
||||
attachment: Schema.Boolean,
|
||||
reasoning: Schema.Boolean,
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
temperature: Schema.Boolean,
|
||||
tool_call: Schema.Boolean,
|
||||
interleaved: Schema.optional(
|
||||
@@ -244,6 +260,6 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
|
||||
|
||||
export * as ModelsDev from "./models-dev"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user