Compare commits

..

1 Commits

Author SHA1 Message Date
opencode-agent[bot] 9d61eef141 fix: keep read path validation model-visible 2026-06-07 13:09:27 +00:00
340 changed files with 8450 additions and 7428 deletions
+4 -2
View File
@@ -1,3 +1,5 @@
# web + desktop packages
packages/app/ @Hona @Brendonovich
packages/desktop/ @Hona @Brendonovich
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
+23 -1
View File
@@ -65,7 +65,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: bun turbo test --output-logs=errors-only --log-order=grouped --log-prefix=task
run: bun turbo test:ci --log-order=stream --log-prefix=task
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
@@ -74,6 +74,26 @@ jobs:
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
with:
report_paths: packages/*/.artifacts/unit/junit.xml
check_name: "unit results (${{ matrix.settings.name }})"
detailed_summary: true
include_time_in_summary: true
fail_on_failure: false
- name: Upload unit artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
path: packages/*/.artifacts/unit/junit.xml
e2e:
name: e2e (${{ matrix.settings.name }})
strategy:
@@ -131,6 +151,7 @@ jobs:
run: bun --cwd packages/app test:e2e:local
env:
CI: true
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
timeout-minutes: 30
- name: Upload Playwright artifacts
@@ -141,5 +162,6 @@ jobs:
if-no-files-found: ignore
retention-days: 7
path: |
packages/app/e2e/junit-*.xml
packages/app/e2e/test-results
packages/app/e2e/playwright-report
+19 -22
View File
@@ -526,7 +526,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -636,9 +636,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.3.4",
"@opentui/keymap": ">=0.3.4",
"@opentui/solid": ">=0.3.4",
"@opentui/core": ">=0.3.2",
"@opentui/keymap": ">=0.3.2",
"@opentui/solid": ">=0.3.2",
},
"optionalPeers": [
"@opentui/core",
@@ -918,7 +918,6 @@
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"pacote@21.5.0": "patches/pacote@21.5.0.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",
},
"overrides": {
@@ -940,9 +939,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.3.2",
"@opentui/keymap": "0.3.2",
"@opentui/solid": "0.3.2",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1646,7 +1645,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1842,27 +1841,27 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
"@opentui/core": ["@opentui/core@0.3.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.2", "@opentui/core-darwin-x64": "0.3.2", "@opentui/core-linux-arm64": "0.3.2", "@opentui/core-linux-arm64-musl": "0.3.2", "@opentui/core-linux-x64": "0.3.2", "@opentui/core-linux-x64-musl": "0.3.2", "@opentui/core-win32-arm64": "0.3.2", "@opentui/core-win32-x64": "0.3.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-5rCVS/3Obb3iLqg/egLCRArt7hAu3lX/9PWVHqUlnJylCT6b5NYDFljt0r3x8v3VG98LB71UzpnDv7DgmGKATw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rFnGfqqEOGiUTbxglpiDA500KeRqcI1ukemhNfDrEzx3imAArS8mFZSuUG7ib31P5EpX+PXuvg0G9/3YXfgWeQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z+/GxKvB3NzMDSwuyWR7HDStbaNRf5a09lt5W9b4BGmCAFW/mbX0Tuh3kloubcMgiq5vLnVaNzY19hrIgJrjGQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0TRuGNR+2GGk0rQuDaIxkIa/3Ty/XySfeOQLAUX4Jqaifky04As69fYT17yOhHqg5viCJUAGG/SdW8IH6C2osg=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-noDKfwYUjutQUx5rtoyKrBIYaeSCmAmtxOSJdnKecyWEhMygtdHp4ssPtxzsZMuQAliHogCmD7vUG/pGMgcXMQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EZp+Lg9eZwzwNny4l2ACHdC95JbEKYbor1WImnm6IEo1e2Fgl9mltYv2J+i+Ea+dXQbrkK/MRIw7CRusxfqFFA=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/FNGeJYhCHcIE3qBIo+nl8014NZ8u/XXUwQY2RjuWhMnzK9kQUfZ3cW4FP6FkOA/k4jGvByya47193II6djFtw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-/zb5nCZKDgBS1UEQXrzTYUbbTPFMygbiZ/BfNWjEDIbm63gVzQ7pVouYbGP+88CRXIJtwT6LeoVPgp9nmOrDWQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-q8xqMhW1jlJVzos+A5+FXRquH01j1ZHmrNi/9++W1Ebz3LQYn+8Z8j7rcV/meIiDuo9nyRHigQQT+cy9xV4N2g=="],
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
"@opentui/keymap": ["@opentui/keymap@0.3.2", "", { "dependencies": { "@opentui/core": "0.3.2" }, "peerDependencies": { "@opentui/react": "0.3.2", "@opentui/solid": "0.3.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-y+IPBagxPvVrKIISmlfvO7ScvVIXGMmV92x0O+WzQ2vPf6fi5xgJc4YNmgEACfGdnM3ub14MkgEXqMOeUN7ZQA=="],
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
"@opentui/solid": ["@opentui/solid@0.3.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Yff0gSwIY/o0XeMciYeAUkQtea8bWzR0UjjVglmcBe13hWuJZt/GfjbDMdNNQ8zCrLubLEh04an5fYXCd7NMYQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5674,8 +5673,6 @@
"@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=="],
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-1tKRDDKUF+no53SwpTBB+cc81gF/shaaFkUwBmUX7Z8=",
"aarch64-linux": "sha256-jy1c4qRmRWVwwPcmvzUmPH9YNN/N0D/g17GROkgAe2M=",
"aarch64-darwin": "sha256-HwN574uks3OweyGtCFloSjHixdNRzCUEqrMZESPARmw=",
"x86_64-darwin": "sha256-qccXjybWIhbCkxCfnwkpWIZ7riirjGKNUpzHQnjRRzc="
"x86_64-linux": "sha256-yZeq16sWAtsAHZO3pbsr90t3+8PlOueRym2J/Kgpj1U=",
"aarch64-linux": "sha256-m54/gm6R8MyQXBh68K8McAfZ9nLLK08nYQwMvAsTs8o=",
"aarch64-darwin": "sha256-pbtcF4ZCUqKO/SVLxv4wskl+O1LlT2RNsUV5d4s9+WY=",
"x86_64-darwin": "sha256-o3ucdbFNy2y9Hrb594Y9AtpRBUPglhqZLtTyiqMECR8="
}
}
+4 -4
View File
@@ -39,9 +39,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.3.4",
"@opentui/keymap": "0.3.4",
"@opentui/solid": "0.3.4",
"@opentui/core": "0.3.2",
"@opentui/keymap": "0.3.2",
"@opentui/solid": "0.3.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -140,7 +140,7 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
+2 -1
View File
@@ -18,7 +18,8 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:unit": "bun test --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
+7 -1
View File
@@ -7,6 +7,12 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
}
export default defineConfig({
testDir: "./e2e",
outputDir: "./e2e/test-results",
@@ -18,7 +24,7 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
reporter,
webServer: {
command,
url: baseURL,
@@ -407,7 +407,6 @@ export function DialogSelectFile(props: {
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
skipFilter={(item) => item.type === "file"}
groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
@@ -509,16 +509,11 @@ export function useServerManagementController(options: { onSelect?: () => void;
resetEdit()
})
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
await setDefault(null)
}
} catch (err) {
showRequestError(language, err)
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)
}
}
@@ -1,54 +0,0 @@
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
return (
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</Show>
</Popover>
</div>
</Show>
)
}
@@ -658,7 +658,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
key: atKey,
filterKeys: ["display"],
skipFilter: (item) => item.type === "file" && !item.recent,
groupBy: (item) => {
if (item.type === "agent") return "agent"
if (item.recent) return "recent"
-2
View File
@@ -447,8 +447,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
refreshTabsAreOverflowing()
})
if (tab.type !== "session") return null
return (
<>
{i() !== 0 && (
+12 -21
View File
@@ -1,6 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { checksum } from "@opencode-ai/core/util/encode"
import { useParams, useSearchParams } from "@solidjs/router"
import { useParams } from "@solidjs/router"
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
@@ -153,11 +153,9 @@ const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type Scope = { draftID: string } | { dir: string; id?: string }
function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
type Scope = {
dir: string
id?: string
}
type PromptCacheEntry = {
@@ -165,15 +163,11 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function promptTarget(serverScope: ServerScope, scope: Scope) {
if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt")
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy])
}
function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
function createPromptSession(serverScope: ServerScope, scope: Scope) {
const [store, setStore, _, ready] = persisted(
promptTarget(serverScope, scope),
Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
@@ -237,7 +231,6 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false,
init: () => {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>()
@@ -261,8 +254,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const owner = getOwner()
const load = (scope: Scope) => {
const key = scopeKey(scope)
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -272,7 +265,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK.scope, scope),
value: createPromptSession(serverSDK.scope, dir, id),
dispose,
}),
owner,
@@ -283,10 +276,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return entry.value
}
const session = createMemo(() =>
load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }),
)
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const session = createMemo(() => load(params.dir!, params.id))
const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session())
return {
ready: () => session().ready,
+5 -64
View File
@@ -2,12 +2,10 @@ import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { Persist, persisted } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
export type SessionTab = {
@@ -17,22 +15,10 @@ export type SessionTab = {
sessionId: string
}
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
}
export type Tab = SessionTab
export type Tab = SessionTab | DraftTab
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory)
@@ -47,7 +33,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
gate: false,
init: () => {
const server = useServer()
const platform = usePlatform()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
@@ -68,10 +53,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const closing = new Set<string>()
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
createEffect(() => {
if (!ready()) return
const servers = new Set(server.list.map(ServerConnection.key))
@@ -102,42 +83,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}),
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
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))
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
const active = `${location.pathname}${location.search}` === draftHref(draftID)
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session }
}),
)
if (active) navigateTab({ type: "session", ...session })
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
@@ -149,12 +98,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
if (draftID) removeDraftPersisted(draftID)
},
removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
@@ -164,12 +110,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({
type: "session",
server: server.key,
dirBase64: params.dir,
sessionId: params.id,
})
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
: undefined
const currentIndex = currentHref
? tabs.findIndex(
-3
View File
@@ -63,7 +63,6 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2365,7 +2364,6 @@ export default function Layout(props: ParentProps) {
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
}
@@ -2519,7 +2517,6 @@ export default function Layout(props: ParentProps) {
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<HelpButton />
<ToastRegion v2={newDesign()} />
</div>
</Show>
+60 -64
View File
@@ -1745,85 +1745,81 @@ export default function Page() {
<div
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none": true,
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active() && !ui.reviewSnap,
"transition-[width]": !isV2NewSessionPage(),
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
style={{
width: sessionPanelWidth(),
}}
>
<div
class="flex-1 min-h-0 overflow-hidden"
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-deep": isV2NewSessionPage(),
"bg-background-stronger": !isV2NewSessionPage(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
"rounded-[10px]": settings.general.newLayoutDesigns(),
}}
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8",
header: "px-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady()}>
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={historyLoader.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
const root = scroller
if (root) scheduleScrollState(root)
}}
historyShift={historyLoader.shift()}
userMessages={historyLoader.userMessages()}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
/>
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
<ResizeHandle
-18
View File
@@ -166,24 +166,6 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull()
})
test("draft target isolates storage per draft and namespaces keys", () => {
const a = Persist.draft("draft-a", "prompt")
const b = Persist.draft("draft-b", "prompt")
expect(a.key).toBe("draft:prompt")
expect(a.storage).not.toBe(b.storage)
expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage)
})
test("removes draft storage when removing persisted target", () => {
const target = Persist.draft("draft-a", "prompt")
storage.setItem(`${target.storage}:${target.key}`, '{"value":1}')
removePersisted(target)
expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull()
})
test("server workspace target preserves local storage and isolates remote storage", () => {
const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt")
const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt")
-15
View File
@@ -341,12 +341,6 @@ function workspaceStorage(dir: string) {
return `opencode.workspace.${head}.${sum}.dat`
}
function draftStorage(draftID: string) {
const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(draftID) ?? "0"
return `opencode.draft.${head}.${sum}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
@@ -456,12 +450,6 @@ function localStorageDirect(): SyncStorage {
}
}
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
}
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
@@ -474,9 +462,6 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
},
serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget {
if (scope === ServerScope.local) return Persist.global(key, legacy)
return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }
+11 -11
View File
@@ -71,17 +71,6 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
@@ -117,6 +106,17 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
+5 -1
View File
@@ -76,7 +76,11 @@ export function WslServerSettings(props: {
}))
const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key))
if (!api) return
request.mutate(async () => {
await api.removeServer(key)
await props.controller.handleRemove(key)
})
}
return (
+1 -1
View File
@@ -65,7 +65,7 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "200ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 1400, d: "200ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "220ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "230ms" },
@@ -501,7 +501,7 @@ export default function PrivacyPolicy() {
otherwise use the Services or send us any Personal Data. If we learn we have collected Personal Data
from a child under 18 years of age, we will delete that information as quickly as possible. If you
believe that a child under 18 years of age may have provided Personal Data to us, please contact us at{" "}
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="california-resident-rights">California Resident Rights</h2>
@@ -520,7 +520,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a California resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access</h3>
@@ -605,7 +605,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Colorado resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -676,7 +676,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Connecticut resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -745,7 +745,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Delaware resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -818,7 +818,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Iowa resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -864,7 +864,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Montana resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -937,7 +937,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Nebraska resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1007,7 +1007,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Hampshire resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1078,7 +1078,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a New Jersey resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1151,7 +1151,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are an Oregon resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1225,7 +1225,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Texas resident, the portion that is more protective of Personal Data shall control to the extent
of such conflict. If you have any questions about this section or whether any of the following rights
apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1293,7 +1293,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Utah resident, the portion that is more protective of Personal Data shall control to the extent of
such conflict. If you have any questions about this section or whether any of the following rights apply
to you, please contact us at help@anoma.ly.
to you, please contact us at contact@anoma.ly.
</p>
<h3>Access and Portability</h3>
@@ -1339,7 +1339,7 @@ export default function PrivacyPolicy() {
If there are any conflicts between this section and any other provision of this Privacy Policy and you
are a Virginia resident, the portion that is more protective of Personal Data shall control to the
extent of such conflict. If you have any questions about this section or whether any of the following
rights apply to you, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
rights apply to you, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h3>Access and Portability</h3>
@@ -1418,7 +1418,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1430,7 +1430,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1457,7 +1457,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Call us at: +1 415 794-0209</li>
</ul>
@@ -1474,8 +1474,8 @@ export default function PrivacyPolicy() {
<p>
Under California Civil Code Sections 1798.83-1798.84, California residents are entitled to contact us to
prevent disclosure of Personal Data to third parties for such third parties' direct marketing purposes;
in order to submit such a request, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>
.
in order to submit such a request, please contact us at{" "}
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<p>
@@ -1500,7 +1500,7 @@ export default function PrivacyPolicy() {
</p>
<ul>
<li>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</li>
<li>Phone: +1 415 794-0209</li>
<li>Address: 2443 Fillmore St #380-6343, San Francisco, CA 94115, United States</li>
@@ -30,7 +30,7 @@ export default function TermsOfService() {
</p>
<p>
Email: <a href="mailto:help@anoma.ly">help@anoma.ly</a>
Email: <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>
</p>
<p>
@@ -114,7 +114,7 @@ export default function TermsOfService() {
attempt to register for or otherwise use the Services or send us any personal information. If we learn
we have collected personal information from a child under 13 years of age, we will delete that
information as quickly as possible. If you believe that a child under 13 years of age may have provided
us personal information, please contact us at <a href="mailto:help@anoma.ly">help@anoma.ly</a>.
us personal information, please contact us at <a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="what-are-the-basics">What are the basics of using OpenCode?</h2>
@@ -315,7 +315,7 @@ export default function TermsOfService() {
specified time of the trial. You must stop using a Paid Service before the end of the trial period in
order to avoid being charged for that Paid Service. If you cancel prior to the end of the trial period
and are inadvertently charged for a Paid Service, please contact us at{" "}
<a href="mailto:help@anoma.ly">help@anoma.ly</a>.
<a href="mailto:contact@anoma.ly">contact@anoma.ly</a>.
</p>
<h2 id="what-if-i-want-to-stop">What if I want to stop using the Services?</h2>
@@ -226,7 +226,7 @@ export async function POST(input: APIEvent) {
expand: ["discounts", "payments"],
})
const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string
const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string
const couponID = (invoice.discounts[0] as Stripe.Discount).coupon?.id as string
if (!paymentID) {
// payment id can be undefined when using coupon
if (!couponID) throw new Error("Payment ID not found")
@@ -143,7 +143,7 @@ export function BillingSection() {
<h2>{i18n.t("workspace.billing.title")}</h2>
<p>
{i18n.t("workspace.billing.subtitle.beforeLink")}{" "}
<a href="mailto:help@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
<a href="mailto:contact@anoma.ly">{i18n.t("workspace.billing.contactUs")}</a>{" "}
{i18n.t("workspace.billing.subtitle.afterLink")}
</p>
</div>
+2 -1
View File
@@ -9,7 +9,8 @@
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"test": "bun test --only-failures",
"test": "bun test",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"bin": {
@@ -1,63 +0,0 @@
export * as ConfigReferencePlugin from "./reference"
import path from "path"
import { Effect } from "effect"
import { Config } from "../../config"
import { ConfigReference } from "../reference"
import { Global } from "../../global"
import { Location } from "../../location"
import { PluginV2 } from "../../plugin"
import { Reference } from "../../reference"
import { AbsolutePath } from "../../schema"
export const Plugin = {
id: PluginV2.ID.make("core/config-reference"),
effect: Effect.gen(function* () {
const config = yield* Config.Service
const global = yield* Global.Service
const location = yield* Location.Service
const references = yield* Reference.Service
const update = yield* references.transform()
const entries = new Map<string, Reference.Source>()
for (const doc of (yield* config.entries()).filter(
(entry): entry is Config.Document => entry.type === "document",
)) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
entries.set(
name,
local(entry)
? new Reference.LocalSource({
type: "local",
path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)),
})
: new Reference.GitSource({
type: "git",
repository: typeof entry === "string" ? entry : entry.repository,
branch: typeof entry === "string" ? undefined : entry.branch,
}),
)
}
}
yield* update((editor) => {
for (const [name, source] of entries) editor.add(name, source)
})
}),
}
function validAlias(name: string) {
return name.length > 0 && !/[\/\s`,]/.test(name)
}
function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local {
return typeof entry === "string"
? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")
: "path" in entry
}
function localPath(directory: string, home: string, value: string) {
if (value.startsWith("~/")) return path.join(home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(directory, value)
}
+30
View File
@@ -16,3 +16,33 @@ export type Entry = typeof Entry.Type
export const Info = Schema.Record(Schema.String, Entry)
export type Info = typeof Info.Type
export type NormalizedEntry =
| { readonly kind: "local"; readonly path: string }
| { readonly kind: "git"; readonly repository: string; readonly branch?: string }
| { readonly kind: "invalid"; readonly message: string }
export type NormalizedInfo = Record<string, NormalizedEntry>
export function validateAlias(name: string) {
if (name.length === 0) return "Reference alias must not be empty"
if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick"
}
export function normalizeEntry(entry: Entry): NormalizedEntry {
if (typeof entry === "string") {
if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry }
return { kind: "git", repository: entry }
}
if ("path" in entry) return { kind: "local", path: entry.path }
return { kind: "git", repository: entry.repository, branch: entry.branch }
}
export function normalize(info: Info): NormalizedInfo {
return Object.fromEntries(
Object.entries(info).map(([name, entry]) => {
const message = validateAlias(name)
return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)]
}),
)
}
+73
View File
@@ -0,0 +1,73 @@
import { Cause, Effect, Logger, References } from "effect"
import * as Log from "../util/log"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
}
export const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
extra[`logSpan.${key}`] = `${now - start}ms`
}
if (opts.cause.reasons.length > 0) {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
case "Warn":
return log.warn(msg, extra)
case "Error":
case "Fatal":
return log.error(msg, extra)
default:
return log.info(msg, extra)
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
+107
View File
@@ -0,0 +1,107 @@
import { Effect, Layer, Logger } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { ensureProcessMetadata } from "../util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, x) => {
const [key, ...value] = x.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
const processMetadata = ensureProcessMetadata("main")
const attributes: Record<string, string> = (() => {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
})()
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...attributes,
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
// @effect/opentelemetry creates a NodeTracerProvider but never calls
// register(), so the global @opentelemetry/api context manager stays
// as the no-op default. Non-Effect code (like the AI SDK) that calls
// tracer.startActiveSpan() relies on context.active() to find the
// parent span - without a real context manager every span starts a
// new trace. Registering AsyncLocalStorageContextManager fixes this.
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
const mgr = new AsyncLocalStorageContextManager()
mgr.enable()
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
headers,
}),
),
}))
}
export const layer = !base
? EffectLogger.layer
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs())
}),
)
export const Observability = { enabled, layer }
+1 -1
View File
@@ -1,6 +1,6 @@
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
import { memoMap } from "./memo-map"
import { Observability } from "../observability"
import { Observability } from "./observability"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+3 -1
View File
@@ -410,7 +410,9 @@ export const layerWith = (options?: LayerOptions) =>
Effect.catchCauseIf(
(cause) => !Cause.hasInterrupts(cause),
(cause) =>
Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }),
Effect.logError("Event observer failed").pipe(
Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }),
),
),
)
+41 -16
View File
@@ -9,6 +9,7 @@ import { EventV2 } from "./event"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { Location } from "./location"
import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
@@ -16,6 +17,7 @@ import { ToolOutputStore } from "./tool-output-store"
export const ReadInput = Schema.Struct({
path: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
@@ -33,6 +35,13 @@ export class BinaryFileError extends Error {
}
}
export class PathValidationError extends Error {
constructor(message: string) {
super(message)
this.name = "PathValidationError"
}
}
const BINARY_EXTENSIONS = new Set([
".zip",
".tar",
@@ -132,6 +141,7 @@ export class ReadPath extends Schema.Class<ReadPath>("FileSystem.ReadPath")({
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@@ -155,6 +165,7 @@ export class RootTarget extends Schema.Class<RootTarget>("FileSystem.RootTarget"
real: Schema.String,
root: Schema.String,
resource: Schema.String,
reference: Schema.NonEmptyString.pipe(Schema.optional),
type: Schema.Literals(["file", "directory"]),
}) {}
@@ -235,6 +246,7 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
const ignored = ignore()
@@ -246,26 +258,37 @@ export const layer = Layer.effect(
.readFileString(path.join(location.project.directory, ".ignore"))
.pipe(Effect.catch(() => Effect.succeed("")))
if (ignorefile) ignored.add(ignorefile)
const resolve = Effect.fnUntraced(function* (input?: string) {
const select = Effect.fnUntraced(function* (reference?: string) {
if (!reference) return { directory: location.directory, root }
const resolved = yield* references.get(reference)
if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`))
if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message))
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (reference)
return yield* Effect.die(new PathValidationError("Absolute paths cannot use a project reference"))
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
return yield* Effect.die(new PathValidationError("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return yield* Effect.die(new PathValidationError("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const selected = { directory: location.directory, root }
const absolute = path.resolve(location.directory, input ?? ".")
if (!FSUtil.contains(location.directory, absolute))
return yield* Effect.die(new Error("Path escapes the location"))
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))
return yield* Effect.die(new PathValidationError("Path escapes the location"))
const real = yield* fs.realPath(absolute).pipe(Effect.orDie)
if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location"))
if (!FSUtil.contains(selected.root, real))
return yield* Effect.die(new PathValidationError("Path escapes the location"))
return { absolute, real, ...selected }
})
const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) {
@@ -321,24 +344,24 @@ export const layer = Layer.effect(
})
const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) {
const target = yield* resolve(input.path)
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new ReadPath({
type,
resource: relative,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
})
})
const resolveFile = Effect.fnUntraced(function* (input: ReadInput) {
const target = yield* resolve(input.path)
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return {
real: target.real,
resource: relative,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
}
})
const content = (target: { readonly real: string }, bytes: Uint8Array) =>
@@ -496,24 +519,25 @@ export const layer = Layer.effect(
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path)
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory"))
const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "."
return new ListTarget({
...directory,
resource: relative,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
})
})
const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) {
const target = yield* resolve(input.path)
const target = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(target.real).pipe(Effect.orDie)
const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined
if (!type) return yield* Effect.die(new Error("Path is not a file or directory"))
const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "."
return new RootTarget({
...target,
resource: relative,
resource: input.reference === undefined ? relative : `${input.reference}:${relative}`,
reference: input.reference,
type,
})
})
@@ -629,4 +653,5 @@ export const layer = Layer.effect(
export const locationLayer = layer.pipe(
Layer.provide(Ripgrep.defaultLayer),
Layer.provideMerge(ProjectReference.locationLayer),
)
+6 -5
View File
@@ -10,8 +10,11 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
import { CrossSpawnSpawner } from "../cross-spawn-spawner"
import { Global } from "../global"
import { NonNegativeInt } from "../schema"
import * as Log from "../util/log"
import { sanitizedProcessEnv } from "../util/opencode-process"
import { which } from "../util/which"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
@@ -143,9 +146,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ri
export const use = serviceUse(Service)
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
@@ -306,7 +307,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
yield* Effect.logInfo("downloading ripgrep", { url })
log.info("downloading ripgrep", { url })
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
@@ -417,7 +418,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpa
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
yield* Effect.logInfo("tree", input)
log.info("tree", input)
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
+59 -52
View File
@@ -4,11 +4,13 @@ import type { PlatformError } from "effect/PlatformError"
import { FSUtil } from "../fs-util"
import { Glob } from "../util/glob"
import { Global } from "../global"
import * as Log from "../util/log"
import { serviceUse } from "../effect/service-use"
import { makeRuntime } from "../effect/runtime"
import { Fff } from "#fff"
import { Ripgrep } from "./ripgrep"
const log = Log.create({ service: "file.search" })
const root = path.join(Global.Path.cache, "fff")
export type Item = Ripgrep.Item
@@ -131,17 +133,22 @@ function item(hit: Fff.Hit): Item {
}
}
function collectPaths<T>(items: T[], scores: Array<{ total: number }>, toPath: (item: T) => string): string[] {
const rows = items.flatMap((item, index): Array<{ text: string; score: number }> => {
const text = toPath(item)
if (!text) return []
return [{ text, score: scores[index]?.total ?? 0 }]
})
rows.sort(
(a, b) => b.score - a.score || a.text.length - b.text.length || (a.text < b.text ? -1 : a.text > b.text ? 1 : 0),
function collectPaths<T>(
out: { items: T[]; scores: Array<{ total: number }> },
toPath: (item: T) => string,
opts?: { includeZeroScore?: boolean },
): string[] {
return Array.from(
new Set(
out.items.flatMap((item, idx): string[] => {
const score = out.scores[idx]
if (!score || (!opts?.includeZeroScore && score.total <= 0)) return []
const text = toPath(item)
if (!text) return []
return [text]
}),
),
)
return Array.from(new Set(rows.map((item) => item.text)))
}
function searchFff(
@@ -155,7 +162,7 @@ function searchFff(
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.relativePath)),
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
}
}
if (kind === "all") {
@@ -163,14 +170,14 @@ function searchFff(
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.item.relativePath)),
value: collectPaths(out.value, (entry) => normalize(entry.item.relativePath), { includeZeroScore: !query }),
}
}
const out = pick.fileSearch(query, opts)
if (!out.ok) return out
return {
ok: true,
value: collectPaths(out.value.items, out.value.scores, (entry) => normalize(entry.relativePath)),
value: collectPaths(out.value, (entry) => normalize(entry.relativePath), { includeZeroScore: !query }),
}
}
@@ -218,14 +225,12 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (!scanned.ok || !scanned.value) {
yield* fffSync("destroy picker", () => pick.destroy()).pipe(Effect.ignore)
state.pick.delete(dir)
yield* Effect.logWarning("fff scan not ready", { dir })
log.warn("fff scan not ready", { dir })
return yield* Effect.fail(new Error(scanned.ok ? "fff scan timed out" : scanned.error))
}
const git = yield* fffSync("refresh git status", () => pick.refreshGitStatus())
if (!git.ok) {
yield* Effect.logWarning("fff git refresh failed", { dir, error: git.error })
}
if (!git.ok) log.warn("fff git refresh failed", { dir, error: git.error })
})
// Create (or return) the picker for a directory. Creation is synchronous
@@ -236,6 +241,14 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
// remove before process exit, so use ripgrep instead of native FFF there.
if (process.env.OPENCODE_TEST_HOME) return undefined
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => {
log.warn("fff availability check failed", { error })
return Effect.succeed(false)
}),
)
if (!available) return undefined
const dir = FSUtil.resolve(cwd)
const existing = state.pick.get(dir)
if (existing) return existing
@@ -243,11 +256,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
const pending = state.wait.get(dir)
if (pending) return yield* Deferred.await(pending)
const available = yield* fffSync("check availability", () => Fff.available()).pipe(
Effect.catch((error) => Effect.logWarning("fff availability check failed", { error }).pipe(Effect.as(false))),
)
if (!available) return undefined
const gate = yield* Deferred.make<Picker, Error>()
state.wait.set(dir, gate)
return yield* Effect.gen(function* () {
@@ -258,6 +266,10 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
basePath: dir,
frecencyDbPath: path.join(root, `${id}.frecency.mdb`),
historyDbPath: path.join(root, `${id}.history.mdb`),
// fff uses a bit different log version, also with spans so keep
// them in the same folder for debuggability
logFilePath: path.join(Global.Path.log, "fff.log"),
logLevel: Log.getLevel().toLowerCase() as Lowercase<Log.Level>,
aiMode: true,
// only the first toolcall picker can accumulate resources to index
// home directory, if the user specifically opened opencode at the
@@ -269,7 +281,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
}),
)
if (!made.ok) {
yield* Effect.logWarning("fff init failed", { dir, error: made.error })
log.warn("fff init failed", { dir, error: made.error })
const err = new Error(made.error)
yield* Deferred.fail(gate, err)
return yield* Effect.fail(err)
@@ -341,26 +353,26 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
const query = input.query.trim()
const kind = input.kind ?? "file"
const entry = yield* acquire(input.cwd).pipe(Effect.catch(() => Effect.succeed<Picker | undefined>(undefined)))
if (!entry) return undefined
const pick = yield* picker(input.cwd)
if (!pick) return undefined
const dir = FSUtil.resolve(input.cwd)
const limit = input.limit ?? 100
const fffResult = yield* fffSync(`${kind} search`, () =>
searchFff(entry.pick, kind, query, {
searchFff(pick, kind, query, {
pageIndex: 0,
currentFile: input.current, // supports both relative and absolute (relative preferred)
pageSize: limit,
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning(`fff ${kind} search failed`, { dir, query, error }).pipe(
Effect.as<Fff.Result<string[]> | undefined>(undefined),
),
),
Effect.catch((error) => {
log.warn(`fff ${kind} search failed`, { dir, query, error })
return Effect.succeed<Fff.Result<string[]> | undefined>(undefined)
}),
)
if (!fffResult) return undefined
if (!fffResult.ok) {
yield* Effect.logWarning(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
log.warn(`fff ${kind} search failed`, { dir, query, error: fffResult.error })
return undefined
}
@@ -391,15 +403,14 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
timeBudgetMs: 1_500,
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error }).pipe(
Effect.as<Fff.Result<Fff.Grep> | undefined>(undefined),
),
),
Effect.catch((error) => {
log.warn("fff grep failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Grep> | undefined>(undefined)
}),
)
if (!fffGrep) return yield* rip(input)
if (!fffGrep.ok) {
yield* Effect.logWarning("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
log.warn("fff grep failed", { dir, pattern: input.pattern, error: fffGrep.error })
return yield* rip(input)
}
@@ -431,11 +442,10 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
pageSize: limit,
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error }).pipe(
Effect.as<Fff.Result<Fff.Search> | undefined>(undefined),
),
),
Effect.catch((error) => {
log.warn("fff glob failed", { dir, pattern: input.pattern, error })
return Effect.succeed<Fff.Result<Fff.Search> | undefined>(undefined)
}),
)
if (fffGlob?.ok) {
@@ -453,7 +463,7 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
truncated: fffGlob.value.totalMatched > rows.length,
}
} else if (fffGlob) {
yield* Effect.logWarning("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
log.warn("fff glob failed", { dir, pattern: input.pattern, error: fffGlob.error })
// fall through to the fallback
}
}
@@ -500,16 +510,13 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
if (!entry) return
const out = yield* fffSync("track query", () => entry.pick.trackQuery(row.text, file)).pipe(
Effect.catch((error) =>
Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error }).pipe(
Effect.as<Fff.Result<boolean> | undefined>(undefined),
),
),
Effect.catch((error) => {
log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error })
return Effect.succeed<Fff.Result<boolean> | undefined>(undefined)
}),
)
if (!out) return
if (!out.ok) {
yield* Effect.logWarning("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
}
if (!out.ok) log.warn("fff track query failed", { dir: row.dir, query: row.text, file, error: out.error })
})
return Service.of({ files, tree, search, file, glob, open, warm, release })
+10 -10
View File
@@ -12,11 +12,13 @@ import { FSUtil } from "../fs-util"
import { Git } from "../git"
import { Location } from "../location"
import { lazy } from "../util/lazy"
import * as Log from "../util/log"
import { Ignore } from "./ignore"
import { Protected } from "./protected"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
@@ -36,7 +38,8 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch {
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
@@ -68,17 +71,14 @@ export const layer = Layer.effect(
const backend = getBackend()
const location = yield* Location.Service
if (!backend) {
yield* Effect.logError("watcher backend not supported", {
directory: location.directory,
platform: process.platform,
})
log.error("watcher backend not supported", { directory: location.directory, platform: process.platform })
return Service.of({})
}
const w = watcher()
if (!w) return Service.of({})
yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend })
log.info("watcher backend", { directory: location.directory, platform: process.platform, backend })
const events = yield* EventV2.Service
const fs = yield* FSUtil.Service
const git = yield* Git.Service
@@ -103,8 +103,9 @@ export const layer = Layer.effect(
Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))),
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) })
pending.then((subscription) => subscription.unsubscribe()).catch(() => {})
return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) })
return Effect.void
}),
)
}
@@ -132,9 +133,8 @@ export const layer = Layer.effect(
return Service.of({})
}).pipe(
Effect.catchCause((cause) => {
return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe(
Effect.as(Service.of({})),
)
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(Service.of({}))
}),
),
)
+1
View File
@@ -46,6 +46,7 @@ export const Flag = {
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("OPENCODE_EXPERIMENTAL_SESSION_SWITCHER"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+2 -2
View File
@@ -22,7 +22,7 @@ import { Watcher } from "./filesystem/watcher"
import { LocationMutation } from "./location-mutation"
import { LocationSearch } from "./location-search"
import { FileMutation } from "./file-mutation"
import { Reference } from "./reference"
import { ProjectReference } from "./project-reference"
import { RepositoryCache } from "./repository-cache"
import { Pty } from "./pty"
import { SkillV2 } from "./skill"
@@ -52,7 +52,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
location,
Policy.locationLayer,
Config.locationLayer,
Reference.locationLayer,
ProjectReference.locationLayer,
PluginV2.locationLayer,
Catalog.locationLayer,
CommandV2.locationLayer,
+3 -2
View File
@@ -9,7 +9,8 @@ import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
/**
* Location-scoped raw search substrate. Search authority is selected only by
* FileSystem, preserving Location-relative paths. Model formatting, leaf-tool permissions, and HTTP transport stay
* FileSystem, preserving Location-relative paths and named read
* references. Model formatting, leaf-tool permissions, and HTTP transport stay
* outside this service so future GlobTool, GrepTool, and HTTP consumers can
* share the same bounded filesystem behavior.
*
@@ -105,7 +106,7 @@ export const layer = Layer.effect(
return {
path: RelativePath.make(relative),
canonical,
resource: relative,
resource: root.reference === undefined ? relative : `${root.reference}:${relative}`,
mtime: info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
+4 -2
View File
@@ -54,7 +54,7 @@ export const Model = Schema.Struct({
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
}),
]),
),
@@ -227,7 +227,9 @@ export const layer = Layer.effect(
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.tapCause((cause) =>
Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
)
})
-21
View File
@@ -1,21 +0,0 @@
export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability"
import { Logging } from "./observability/logging"
import { Otlp } from "./observability/otlp"
export const layer = Layer.unwrap(
Effect.gen(function* () {
const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe(
Layer.provide(NodeFileSystem.layer),
Layer.provide(OtlpSerialization.layerJson),
Layer.provide(FetchHttpClient.layer),
Layer.orDie,
Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())),
)
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}),
)
@@ -1,71 +0,0 @@
import { Formatter, Logger, type LogLevel } from "effect"
import path from "path"
import { Global } from "../global"
import { runID } from "./shared"
function formatter(id: string = runID) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
["timestamp", output.timestamp],
["level", output.level],
["run", id],
...messages.flatMap((value) => (plain(value) ? flatten(value) : [["message", value] as const])),
...(output.cause === undefined ? [] : [["cause", output.cause] as const]),
...flatten(output.spans),
...flatten(output.annotations),
]
.map(([key, value]) => `${key}=${format(value)}`)
.join(" ")
})
}
function flatten(
input: Record<string, unknown>,
prefix = "",
seen = new WeakSet<object>(),
): Array<readonly [string, unknown]> {
if (seen.has(input)) return [[prefix, "[Circular]"]]
seen.add(input)
const entries = Object.entries(input)
if (entries.length === 0 && prefix) return [[prefix, input]]
return entries.flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
return plain(value) ? flatten(value, path, seen) : [[path, value] as const]
})
}
function plain(input: unknown): input is Record<string, unknown> {
if (input === null || typeof input !== "object" || Array.isArray(input)) return false
const prototype = Object.getPrototypeOf(input)
return prototype === Object.prototype || prototype === null
}
function format(input: unknown) {
const value = typeof input === "string" ? input : Formatter.format(input)
return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value)
}
export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Logger.toFile(formatter(id), file, { flag: "a" })
}
const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n"))
export function minimumLogLevel() {
const value = process.env.OPENCODE_LOG_LEVEL?.toUpperCase()
const levels = {
DEBUG: "Debug",
INFO: "Info",
WARN: "Warn",
ERROR: "Error",
} as const satisfies Record<string, LogLevel.LogLevel>
return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO
}
export function loggers() {
return process.env.OPENCODE_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()]
}
export * as Logging from "./logging"
-79
View File
@@ -1,79 +0,0 @@
import { Layer } from "effect"
import { OtlpLogger } from "effect/unstable/observability"
import { Flag } from "../flag/flag"
import { InstallationChannel, InstallationVersion } from "../installation/version"
import { runID } from "./shared"
const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
(acc, entry) => {
const [key, ...value] = entry.split("=")
acc[key] = value.join("=")
return acc
},
{} as Record<string, string>,
)
: undefined
function resourceAttributes() {
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
if (!value) return {}
try {
return Object.fromEntries(
value.split(",").map((entry) => {
const index = entry.indexOf("=")
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
}),
)
} catch {
return {}
}
}
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
...resourceAttributes(),
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.run": runID,
"service.instance.id": runID,
},
}
}
export function loggers() {
if (!endpoint) return []
return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })]
}
export async function tracingLayer() {
if (!endpoint) return Layer.empty
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
const OTLP = await import("@opentelemetry/exporter-trace-otlp-http")
const SdkBase = await import("@opentelemetry/sdk-trace-base")
const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks")
const { context } = await import("@opentelemetry/api")
// The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans.
const manager = new AsyncLocalStorageContextManager()
manager.enable()
context.setGlobalContextManager(manager)
return NodeSdk.layer(() => ({
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${endpoint}/v1/traces`,
headers,
}),
),
}))
}
export * as Otlp from "./otlp"
@@ -1 +0,0 @@
export const runID = crypto.randomUUID().slice(0, 8)
-7
View File
@@ -9,7 +9,6 @@ import { Config } from "../config"
import { ConfigAgentPlugin } from "../config/plugin/agent"
import { ConfigCommandPlugin } from "../config/plugin/command"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { ConfigReferencePlugin } from "../config/plugin/reference"
import { EventV2 } from "../event"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
@@ -26,7 +25,6 @@ import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { SkillV2 } from "../skill"
import { Reference } from "../reference"
type Plugin = {
id: PluginV2.ID
@@ -44,7 +42,6 @@ type Plugin = {
| Config.Service
| ModelsDev.Service
| SkillV2.Service
| Reference.Service
>
}
@@ -70,7 +67,6 @@ export const layer = Layer.effect(
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const skill = yield* SkillV2.Service
const references = yield* Reference.Service
const done = yield* Deferred.make<void>()
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
@@ -89,7 +85,6 @@ export const layer = Layer.effect(
Effect.provideService(FSUtil.Service, fs),
Effect.provideService(Global.Service, global),
Effect.provideService(SkillV2.Service, skill),
Effect.provideService(Reference.Service, references),
Effect.provideService(PluginV2.Service, plugin),
),
})
@@ -109,7 +104,6 @@ export const layer = Layer.effect(
yield* add(ConfigAgentPlugin.Plugin)
yield* add(ConfigCommandPlugin.Plugin)
yield* add(ConfigSkillPlugin.Plugin)
yield* add(ConfigReferencePlugin.Plugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
@@ -130,5 +124,4 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(AgentV2.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
Layer.provideMerge(Reference.locationLayer),
)
+241
View File
@@ -0,0 +1,241 @@
export * as ProjectReference from "./project-reference"
import path from "path"
import { Context, Effect, Layer } from "effect"
import { Config } from "./config"
import { ConfigReference } from "./config/reference"
import { FSUtil } from "./fs-util"
import { Flag } from "./flag/flag"
import { Global } from "./global"
import { Location } from "./location"
import { Repository } from "./repository"
import { RepositoryCache } from "./repository-cache"
export type Resolved =
| { readonly name: string; readonly kind: "local"; readonly path: string }
| {
readonly name: string
readonly kind: "git"
readonly repository: string
readonly reference: Repository.RemoteReference
readonly path: string
readonly branch?: string
}
| { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string }
type Valid = Exclude<Resolved, { kind: "invalid" }>
export type Mention =
| {
readonly name: string
readonly kind: "reference"
readonly reference: Valid
readonly target?: string
readonly path: string
}
| { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string }
| {
readonly name: string
readonly kind: "missing"
readonly target: string
readonly path: string
readonly message: string
}
export interface Interface {
readonly list: () => Effect.Effect<Resolved[]>
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
type Materializer = {
readonly name: string
readonly repository: string
readonly path: string
readonly run: Effect.Effect<void, RepositoryCache.Error>
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (!Flag.OPENCODE_EXPERIMENTAL_REFERENCES) return Service.of(inert)
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const cache = yield* RepositoryCache.Service
const references = resolveAll({
references: ConfigReference.normalize(
Object.assign(
{},
...(yield* config.entries())
.filter((entry): entry is Config.Document => entry.type === "document")
.map((document) => document.info.references ?? {}),
),
),
directory: location.project.directory,
home: global.home,
repos: global.repos,
})
const materializers = yield* Effect.forEach(
uniqueGitReferences(references),
Effect.fnUntraced(function* (reference) {
return {
name: reference.name,
repository: reference.repository,
path: reference.path,
run: yield* Effect.cached(
cache
.ensure({ reference: reference.reference, branch: reference.branch, refresh: true })
.pipe(Effect.asVoid),
),
}
}),
)
yield* Effect.forEach(
materializers,
(materializer) =>
materializer.run.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize project reference").pipe(
Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }),
),
),
),
{ concurrency: 4, discard: true },
).pipe(Effect.forkScoped)
const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) {
const normalized = normalizePath(target)
if (!normalized)
return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true })
yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void
})
return Service.of({
list: Effect.fn("ProjectReference.list")(function* () {
return references
}),
get: Effect.fn("ProjectReference.get")(function* (name: string) {
return references.find((reference) => reference.name === name)
}),
ensurePath,
containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) {
const normalized = normalizePath(target)
return normalized
? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized))
: false
}),
resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) {
const [name, ...rest] = value.split("/")
const target = rest.length ? rest.join("/") : undefined
const reference = references.find((reference) => reference.name === name)
if (!reference) return
if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message }
if (reference.kind === "git") yield* ensurePath(reference.path)
if (!target) return { name, kind: "reference", reference, path: reference.path }
const resolved = path.resolve(reference.path, target)
if (!FSUtil.contains(reference.path, resolved))
return { name, kind: "invalid", target, message: "Reference target escapes its root" }
if (!(yield* fs.existsSafe(resolved)))
return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" }
return { name, kind: "reference", reference, target, path: resolved }
}),
})
}),
)
export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer))
const inert: Interface = {
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
}
export function resolveAll(input: {
references: ConfigReference.NormalizedInfo
directory: string
home: string
repos: string
}) {
const seen = new Map<string, { name: string; branch?: string }>()
return Object.entries(input.references).map(([name, reference]): Resolved => {
const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos })
if (resolved.kind !== "git") return resolved
const existing = seen.get(resolved.path)
if (!existing) {
seen.set(resolved.path, { name, branch: resolved.branch })
return resolved
}
if (existing.branch === resolved.branch) return resolved
return {
name,
kind: "invalid",
repository: resolved.repository,
message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`,
}
})
}
export function resolve(input: {
name: string
reference: ConfigReference.NormalizedEntry
directory: string
home: string
repos: string
}): Resolved {
if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message }
if (input.reference.kind === "local") {
return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) }
}
const reference = Repository.parse(input.reference.repository)
if (!reference || !Repository.isRemote(reference)) {
return {
name: input.name,
kind: "invalid",
repository: input.reference.repository,
message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand",
}
}
return {
name: input.name,
kind: "git",
repository: input.reference.repository,
reference,
path: Repository.cachePath(input.repos, reference),
branch: input.reference.branch,
}
}
function localPath(directory: string, home: string, value: string) {
if (value.startsWith("~/")) return path.join(home, value.slice(2))
return path.isAbsolute(value) ? value : path.resolve(directory, value)
}
function uniqueGitReferences(references: Resolved[]) {
const seen = new Set<string>()
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
if (reference.kind !== "git" || seen.has(reference.path)) return false
seen.add(reference.path)
return true
})
}
function normalizePath(target?: string) {
if (!target) return
return process.platform === "win32" ? FSUtil.normalizePath(target) : target
}
function contains(parent: string, child: string) {
return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child)
}
+7 -4
View File
@@ -7,7 +7,9 @@ import { Location } from "./location"
import { NonNegativeInt, PositiveInt } from "./schema"
import { PtyID } from "./pty/schema"
import { lazy } from "./util/lazy"
import * as Log from "./util/log"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
@@ -156,7 +158,7 @@ export const layer = Layer.effect(
const session = sessions.get(id)
if (!session) return false
sessions.delete(id)
yield* Effect.logInfo("removing session", { id })
log.info("removing session", { id })
teardown(session)
yield* events.publish(Event.Deleted, { id: session.info.id })
return true
@@ -177,7 +179,7 @@ export const layer = Layer.effect(
const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) {
const id = PtyID.ascending()
yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(input.command, input.args, {
@@ -229,7 +231,7 @@ export const layer = Layer.effect(
if (session.info.status === "exited") return
runFork(
Effect.gen(function* () {
yield* Effect.logInfo("session exited", { id, exitCode })
log.info("session exited", { id, exitCode })
session.info.status = "exited"
yield* events.publish(Event.Exited, { id, exitCode })
yield* removeSession(id)
@@ -261,7 +263,7 @@ export const layer = Layer.effect(
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close())))
yield* Effect.logInfo("client connected to session", { id, directory: location.directory })
log.info("client connected to session", { id, directory: location.directory })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
@@ -297,6 +299,7 @@ export const layer = Layer.effect(
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
-114
View File
@@ -1,114 +0,0 @@
export * as Reference from "./reference"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { castDraft } from "immer"
import { Global } from "./global"
import { EventV2 } from "./event"
import { Repository } from "./repository"
import { RepositoryCache } from "./repository-cache"
import { AbsolutePath } from "./schema"
import { State } from "./state"
export class Info extends Schema.Class<Info>("Reference.Info")({
name: Schema.String,
path: AbsolutePath,
source: Schema.suspend(() => Source),
}) {}
export class LocalSource extends Schema.Class<LocalSource>("Reference.LocalSource")({
type: Schema.Literal("local"),
path: AbsolutePath,
}) {}
export class GitSource extends Schema.Class<GitSource>("Reference.GitSource")({
type: Schema.Literal("git"),
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
}) {}
export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type"))
export type Source = typeof Source.Type
export const Event = {
Updated: EventV2.define({ type: "reference.updated", schema: {} }),
}
type Data = {
sources: Map<string, Source>
}
type Editor = {
add(name: string, source: Source): void
remove(name: string): void
list(): readonly [string, Source][]
}
export interface Interface {
readonly transform: State.Interface<Data, Editor>["transform"]
readonly list: () => Effect.Effect<Info[]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Reference") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const global = yield* Global.Service
const events = yield* EventV2.Service
const cache = yield* RepositoryCache.Service
const scope = yield* Scope.Scope
const materialized = new Map<string, Info>()
const state = State.create<Data, Editor>({
initial: () => ({ sources: new Map() }),
editor: (draft) => ({
add: (name, source) => draft.sources.set(name, castDraft(source)),
remove: (name) => draft.sources.delete(name),
list: () => Array.from(draft.sources.entries()) as [string, Source][],
}),
finalize: (editor) =>
Effect.gen(function* () {
materialized.clear()
const seen = new Map<string, string | undefined>()
for (const [name, source] of editor.list()) {
if (source.type === "local") {
materialized.set(name, new Info({ name, path: source.path, source }))
continue
}
const repository = Repository.parse(source.repository)
if (!repository || !Repository.isRemote(repository)) continue
if (source.branch) {
try {
Repository.validateBranch(source.branch)
} catch {
continue
}
}
const target = Repository.cachePath(global.repos, repository)
if (seen.has(target) && seen.get(target) !== source.branch) continue
seen.set(target, source.branch)
materialized.set(name, new Info({ name, path: AbsolutePath.make(target), source }))
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to materialize reference", {
name,
repository: source.repository,
cause,
}),
),
Effect.forkIn(scope),
)
}
yield* events.publish(Event.Updated, {})
}),
})
return Service.of({
transform: state.transform,
list: Effect.fn("Reference.list")(function* () {
return Array.from(materialized.values())
}),
})
}),
)
export const locationLayer = layer
+1
View File
@@ -349,5 +349,6 @@ const toMessage = (input: Admitted) =>
text: input.prompt.text,
files: input.prompt.files,
agents: input.prompt.agents,
references: input.prompt.references,
time: { created: input.timeCreated },
})
+1 -1
View File
@@ -5,4 +5,4 @@ export const logFailure = (
message: "Failed to drain Session" | "Failed to wake Session",
sessionID: SessionSchema.ID,
cause: Cause.Cause<unknown>,
) => Effect.logError(message, cause).pipe(Effect.annotateLogs({ sessionID }))
) => Effect.logError(message, cause).pipe(Effect.annotateLogs("sessionID", sessionID))
@@ -132,6 +132,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
text: event.data.prompt.text,
files: event.data.prompt.files,
agents: event.data.prompt.agents,
references: event.data.prompt.references,
time: { created: event.data.timestamp },
}),
)
+1
View File
@@ -37,6 +37,7 @@ export class User extends Schema.Class<User>("Session.Message.User")({
text: Prompt.fields.text,
files: Prompt.fields.files,
agents: Prompt.fields.agents,
references: Prompt.fields.references,
type: Schema.Literal("user"),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
+15 -1
View File
@@ -29,18 +29,32 @@ export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.Agent
source: Source.pipe(Schema.optional),
}) {}
export class ReferenceAttachment extends Schema.Class<ReferenceAttachment>("Prompt.ReferenceAttachment")({
name: Schema.String,
kind: Schema.Literals(["local", "git", "invalid"]),
uri: Schema.String.pipe(Schema.optional),
repository: Schema.String.pipe(Schema.optional),
branch: Schema.String.pipe(Schema.optional),
target: Schema.String.pipe(Schema.optional),
targetUri: Schema.String.pipe(Schema.optional),
problem: Schema.String.pipe(Schema.optional),
source: Source.pipe(Schema.optional),
}) {}
export class Prompt extends Schema.Class<Prompt>("Prompt")({
text: Schema.String,
files: Schema.Array(FileAttachment).pipe(Schema.optional),
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
references: Schema.Array(ReferenceAttachment).pipe(Schema.optional),
}) {
static readonly equivalence = Schema.toEquivalence(Prompt)
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents">) {
static fromUserMessage(input: Pick<Prompt, "text" | "files" | "agents" | "references">) {
return new Prompt({
text: input.text,
...(input.files === undefined ? {} : { files: input.files }),
...(input.agents === undefined ? {} : { agents: input.agents }),
...(input.references === undefined ? {} : { references: input.references }),
})
}
}
@@ -105,6 +105,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),
...(message.references?.length ? { references: message.references } : {}),
},
}),
]
+11 -4
View File
@@ -6,6 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { AbsolutePath } from "../schema"
import * as Log from "../util/log"
const skillConcurrency = 4
const fileConcurrency = 8
@@ -70,6 +71,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const log = Log.create({ service: "skill-discovery" })
const http = (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
@@ -85,7 +87,7 @@ export const layer = Layer.effect(
http.execute,
Effect.flatMap((response) => response.arrayBuffer),
Effect.flatMap((body) => fs.writeWithDirs(destination, new Uint8Array(body))),
Effect.catch((error) => Effect.logError("failed to download skill file", { url, error })),
Effect.catch((error) => Effect.sync(() => log.error("failed to download skill file", { url, error }))),
)
})
@@ -98,9 +100,10 @@ export const layer = Layer.effect(
HttpClientRequest.acceptJson,
http.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
Effect.catch((error) =>
Effect.logError("failed to fetch skill index", { url: index, error }).pipe(Effect.as(undefined)),
),
Effect.catch((error) => {
log.error("failed to fetch skill index", { url: index, error })
return Effect.succeed(undefined)
}),
)
if (!data) return []
@@ -108,14 +111,17 @@ export const layer = Layer.effect(
return yield* Effect.forEach(
data.skills.flatMap((skill) => {
if (!isSafeSegment(skill.name)) {
log.warn("skill entry has unsafe name", { url: index, skill: skill.name })
return []
}
if (!skill.files.includes("SKILL.md") && !skill.files.includes(`${skill.name}.md`)) {
log.warn("skill entry missing Markdown definition", { url: index, skill: skill.name })
return []
}
const root = path.resolve(sourceRoot, skill.name)
if (!FSUtil.contains(sourceRoot, root) || root === sourceRoot) {
log.warn("skill entry escapes cache root", { url: index, skill: skill.name })
return []
}
@@ -138,6 +144,7 @@ export const layer = Layer.effect(
}
})
if (files.some((file) => file === undefined)) {
log.warn("skill entry has unsafe file", { url: index, skill: skill.name })
return []
}
return [{ skill, root, files: files as { url: string; destination: string }[] }]
+4 -3
View File
@@ -2,7 +2,8 @@
* Model-facing V2 exact-edit leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval.
* external_directory approval before edit approval. Named project references
* are read-oriented and deliberately are not accepted by mutation tools.
*/
export * as EditTool from "./edit"
@@ -20,7 +21,7 @@ export const name = "edit"
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
"File path to edit. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
}),
oldString: Schema.String.annotate({ description: "Exact text to replace" }),
newString: Schema.String.annotate({ description: "Replacement text, which must differ from oldString" }),
@@ -99,7 +100,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.withPermission(
Tool.make({
description:
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
"Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Input,
output: Output,
toModelOutput: ({ input, output }) => [
+8 -2
View File
@@ -15,6 +15,9 @@ export const Input = Schema.Struct({
path: LocationSearch.FilesInput.fields.path.annotate({
description: "Relative directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.FilesInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
limit: LocationSearch.FilesInput.fields.limit.annotate({
description: `Maximum results to return (default: ${LocationSearch.DEFAULT_RESULT_LIMIT})`,
}),
@@ -38,6 +41,8 @@ export const toModelOutput = (output: ModelOutput) => {
/**
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and traversal.
*
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -50,19 +55,20 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Find files by glob pattern within the active Location. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
"Find files by glob pattern within the active Location or a named project reference. Returns concise relative file resources. Use a relative path to narrow the search and limit to bound the result count.",
input: Input,
output: LocationSearch.FilesResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
execute: (input, context) =>
Effect.gen(function* () {
const root = yield* filesystem.resolveRoot({ path: input.path })
const root = yield* filesystem.resolveRoot({ path: input.path, reference: input.reference })
yield* permission.assert({
action: name,
resources: [input.pattern],
save: ["*"],
metadata: {
root: root.resource,
reference: input.reference,
path: input.path,
limit: input.limit,
},
+7 -1
View File
@@ -18,6 +18,9 @@ export const Input = Schema.Struct({
path: LocationSearch.GrepInput.fields.path.annotate({
description: "Relative file or directory to search. Defaults to the active Location.",
}),
reference: LocationSearch.GrepInput.fields.reference.annotate({
description: "Named project reference to search instead of the active Location",
}),
include: LocationSearch.GrepInput.fields.include.annotate({
description: 'File glob to include in the search (for example, "*.js" or "*.{ts,tsx}")',
}),
@@ -53,6 +56,8 @@ export const toModelOutput = (output: Output) => {
/**
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
* LocationSearch resolves the current root and owns containment and ripgrep execution.
*
* TODO: Revisit root-specific search permission resources if named-reference policy needs independent allow/deny rules.
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
@@ -65,7 +70,7 @@ export const layer = Layer.effectDiscard(
.register({
[name]: Tool.make({
description:
"Search file contents by regular expression within the active Location or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
input: Input,
output: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
@@ -78,6 +83,7 @@ export const layer = Layer.effectDiscard(
save: ["*"],
metadata: {
root: root.resource,
reference: input.reference,
path: input.path,
include: input.include,
limit: input.limit,
+12 -5
View File
@@ -68,14 +68,21 @@ export const layer = Layer.effectDiscard(
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
return content
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof FileSystem.PathValidationError
? Effect.fail(new ToolFailure({ message: defect.message }))
: Effect.die(defect),
),
Effect.mapError((error) => {
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
error instanceof ToolFailure
? error.message
: `Unable to read ${input.path}`
: error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof Image.DecodeError ||
error instanceof Image.SizeError
? error.message
: `Unable to read ${input.path}`
return new ToolFailure({ message })
}),
)
+4 -3
View File
@@ -2,7 +2,8 @@
* Model-facing V2 file-write leaf. Relative paths resolve within the active
* Location. Absolute paths inside that Location are accepted, while explicit
* absolute external paths retain mutation capability through a separate
* external_directory approval before edit approval.
* external_directory approval before edit approval. Named project references
* are read-oriented and deliberately are not accepted by mutation tools.
*/
export * as WriteTool from "./write"
@@ -20,7 +21,7 @@ export const name = "write"
export const Input = Schema.Struct({
path: Schema.String.annotate({
description:
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval. Named project references are read-oriented and are not accepted.",
}),
content: Schema.String.annotate({ description: "Content to write to the file" }),
})
@@ -54,7 +55,7 @@ export const layer = Layer.effectDiscard(
[name]: Tool.withPermission(
Tool.make({
description:
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval. Named project references are read-oriented and are not accepted.",
input: Input,
output: Output,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
+197
View File
@@ -0,0 +1,197 @@
export * as Log from "./log"
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import { Schema } from "effect"
import { Glob } from "./glob"
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const keep = 10
const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID"
let level: Level = "INFO"
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level]
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
dev?: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
export function getLevel(): Level {
return level
}
let write = (msg: any) => {
process.stderr.write(msg)
return msg.length
}
export async function init(options: Options) {
if (options.level) level = options.level
void cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const runID = process.env.OPENCODE_RUN_ID
const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID
if (shouldTruncate) await fs.truncate(logpath).catch(() => {})
if (options.dev && runID) process.env[initializedRunID] = runID
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`
if (value instanceof Error) return prefix + formatError(value)
if (typeof value === "object") return prefix + JSON.stringify(value)
return prefix + value
})
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
}
@@ -0,0 +1,24 @@
export const OPENCODE_RUN_ID = "OPENCODE_RUN_ID"
export const OPENCODE_PROCESS_ROLE = "OPENCODE_PROCESS_ROLE"
export function ensureRunID() {
return (process.env[OPENCODE_RUN_ID] ??= crypto.randomUUID())
}
export function ensureProcessRole(fallback: "main" | "worker") {
return (process.env[OPENCODE_PROCESS_ROLE] ??= fallback)
}
export function ensureProcessMetadata(fallback: "main" | "worker") {
return {
runID: ensureRunID(),
processRole: ensureProcessRole(fallback),
}
}
export function sanitizedProcessEnv(overrides?: Record<string, string>) {
const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
return overrides ? Object.assign(env, overrides) : env
}
+1 -1
View File
@@ -18,7 +18,7 @@ export const Model = Schema.Struct({
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
}),
]),
),
@@ -1,11 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Logger } from "effect"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { fileLogger } from "../../src/observability/logging"
import { resource } from "../../src/observability/otlp"
import { resource } from "@opencode-ai/core/effect/observability"
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
const opencodeClient = process.env.OPENCODE_CLIENT
@@ -48,62 +42,5 @@ describe("resource", () => {
"service.namespace": "anomalyco",
})
expect(resource().attributes["service.instance.id"]).not.toBe("override")
expect(resource().attributes["opencode.run"]).toMatch(/^[0-9a-f]{8}$/)
})
})
test("file logger appends concurrent runs with a run on every line", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
await using _ = {
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
const file = path.join(dir, "opencode.log")
const write = (runID: string) =>
Effect.forEach(
Array.from({ length: 50 }, (_, index) => index),
(index) => Effect.logInfo(`entry-${index}`),
).pipe(
Effect.provide(Logger.layer([fileLogger(file, runID)]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
Effect.scoped,
)
await Effect.runPromise(Effect.all([write("run-a"), write("run-b")], { concurrency: "unbounded" }))
const lines = (await Bun.file(file).text()).trim().split("\n")
expect(lines).toHaveLength(100)
expect(lines.filter((line) => line.includes("run=run-a"))).toHaveLength(50)
expect(lines.filter((line) => line.includes("run=run-b"))).toHaveLength(50)
expect(lines.every((line) => line.startsWith("timestamp=") && line.includes(" level=INFO "))).toBe(true)
expect(lines.every((line) => !line.includes(" fiber="))).toBe(true)
expect(lines.every((line) => !line.startsWith("{"))).toBe(true)
})
test("file logger flattens nested objects", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-log-test-"))
await using _ = {
async [Symbol.asyncDispose]() {
await fs.rm(dir, { recursive: true, force: true })
},
}
const file = path.join(dir, "opencode.log")
await Effect.logInfo("request complete", {
request: { method: "GET", timing: { duration: 42 } },
tags: ["api", "test"],
}).pipe(
Effect.annotateLogs({ session: { id: "session-1" } }),
Effect.provide(Logger.layer([fileLogger(file, "run-a")]).pipe(Layer.provide(NodeFileSystem.layer), Layer.orDie)),
Effect.scoped,
Effect.runPromise,
)
const line = (await Bun.file(file).text()).trim()
expect(line).toContain('message="request complete"')
expect(line).toContain("request.method=GET")
expect(line).toContain("request.timing.duration=42")
expect(line).toContain('tags="[\\\"api\\\",\\\"test\\\"]"')
expect(line).toContain("session.id=session-1")
expect(line).not.toContain("request={")
})
@@ -19,8 +19,6 @@ const tmpdir = (init?: (dir: string) => Effect.Effect<void>) =>
).pipe(Effect.tap((dir) => init?.(dir) ?? Effect.void))
const write = (file: string, data: string) => Effect.promise(() => Bun.write(file, data))
const waitForFileIndex = (search: Search.Interface, cwd: string) =>
search.glob({ cwd, pattern: "**/*", limit: 1 }).pipe(Effect.ignore)
describe("file.search", () => {
it.live("uses fff for Bun-backed grep", () =>
@@ -45,7 +43,6 @@ describe("file.search", () => {
yield* write(path.join(dir, "README.md"), "hello\n")
const search = yield* Search.Service
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "rdme", limit: 10 })
expect(results).toContain("README.md")
@@ -60,7 +57,6 @@ describe("file.search", () => {
yield* write(path.join(dir, "src", "main.ts"), "export const main = true\n")
const search = yield* Search.Service
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "", limit: 10, kind: "all" })
expect(results).toContain("README.md")
@@ -69,21 +65,6 @@ describe("file.search", () => {
}),
)
it.live("stabilizes equal score file candidates by path length", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
const dir = yield* tmpdir()
yield* write(path.join(dir, "src", "longer-name.ts"), "export const longer = true\n")
yield* write(path.join(dir, "a.ts"), "export const shorter = true\n")
const search = yield* Search.Service
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "", limit: 10 })
expect(results?.slice(0, 2)).toEqual(["a.ts", "src/longer-name.ts"])
}),
)
it.live("keeps paging grep results without an explicit limit", () =>
Effect.gen(function* () {
expect(Fff.available()).toBe(true)
@@ -161,7 +142,6 @@ describe("file.search", () => {
yield* write(path.join(dir, "alpha-target-two.ts"), "export const two = 2\n")
const search = yield* Search.Service
yield* waitForFileIndex(search, dir)
const results = yield* search.file({ cwd: dir, query: "alpha target two", limit: 10 })
expect(results).toContain("alpha-target-two.ts")
+144 -5
View File
@@ -7,14 +7,25 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = ProjectReference.Service.of({
list: () => Effect.succeed([]),
get: () => Effect.succeed(undefined),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
function provide(
directory: string,
references = inertReferences,
filesystem = FSUtil.defaultLayer,
data = Global.Path.data,
) {
@@ -25,6 +36,7 @@ function provide(
filesystem,
Ripgrep.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, references),
Global.layerWith({ data }),
),
),
@@ -55,9 +67,14 @@ describe("FileSystem", () => {
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(
yield* service
.resolveReadPath({ path: unrelated })
.pipe(Effect.catchDefect((defect) => Effect.succeed(defect))),
).toBeInstanceOf(FileSystem.PathValidationError)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, FSUtil.defaultLayer, data))
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
}),
)
@@ -246,7 +263,7 @@ describe("FileSystem", () => {
}
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
}).pipe(provide(directory, filesystem))
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
@@ -332,7 +349,7 @@ describe("FileSystem", () => {
next: 3,
})
expect(realPaths.filter((target) => target !== directory)).toEqual([path.join(directory, "alpha.txt")])
}).pipe(provide(directory, filesystem))
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
@@ -368,7 +385,7 @@ describe("FileSystem", () => {
expect((yield* service.listPage({ limit: 32 })).entries).toHaveLength(32)
expect(maximum).toBe(16)
}).pipe(provide(directory, filesystem))
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
@@ -390,8 +407,9 @@ describe("FileSystem", () => {
),
)
test("rejects page limits over 2000", () => {
test("rejects empty list aliases and page limits over 2000", () => {
const decode = Schema.decodeUnknownSync(FileSystem.ListPageInput)
expect(() => decode({ reference: "" })).toThrow()
expect(() => decode({ limit: 2_001 })).toThrow()
})
@@ -448,4 +466,125 @@ describe("FileSystem", () => {
),
)
it.live("reads and lists paths relative to a local project reference", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(docs)
await fs.writeFile(path.join(docs, "README.md"), "docs")
})
const service = yield* FileSystem.Service
expect(yield* service.read({ reference: "docs", path: RelativePath.make("README.md") })).toMatchObject({
type: "text",
content: "docs",
})
expect(yield* service.list({ reference: "docs" })).toMatchObject([{ path: "README.md", type: "file" }])
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
it.live("materializes Git references before filesystem access", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
const ensured: string[] = []
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(docs)
await fs.writeFile(path.join(docs, "README.md"), "docs")
})
expect(
yield* (yield* FileSystem.Service).read({ reference: "sdk", path: RelativePath.make("README.md") }),
).toMatchObject({ content: "docs" })
expect(ensured).toEqual([docs])
}).pipe(
provide(
directory,
references(
{
sdk: {
name: "sdk",
kind: "git",
repository: "owner/repo",
reference: Repository.parseRemote("owner/repo"),
path: docs,
},
},
(target) => Effect.sync(() => ensured.push(target ?? "")),
),
),
)
}),
)
it.live("rejects unknown, invalid, and escaping project reference paths", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(docs))
const service = yield* FileSystem.Service
expect(Exit.isFailure(yield* service.list({ reference: "unknown" }).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* service.list({ reference: "invalid" }).pipe(Effect.exit))).toBe(true)
expect(
Exit.isFailure(
yield* service.read({ reference: "docs", path: RelativePath.make("../outside") }).pipe(Effect.exit),
),
).toBe(true)
}).pipe(
provide(
directory,
references({
docs: { name: "docs", kind: "local", path: docs },
invalid: { name: "invalid", kind: "invalid", message: "invalid reference" },
}),
),
)
}),
)
it.live("rejects aliases when project references are disabled", () =>
withTmp((directory) =>
Effect.gen(function* () {
expect(Exit.isFailure(yield* (yield* FileSystem.Service).list({ reference: "docs" }).pipe(Effect.exit))).toBe(
true,
)
}).pipe(provide(directory)),
),
)
it.live("rejects symlink escapes from project references", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
const outside = path.join(directory, "outside.txt")
return Effect.gen(function* () {
if (process.platform === "win32") return
yield* Effect.promise(async () => {
await fs.mkdir(docs)
await fs.writeFile(outside, "outside")
await fs.symlink(outside, path.join(docs, "link.txt"))
})
expect(
Exit.isFailure(
yield* (yield* FileSystem.Service)
.read({ reference: "docs", path: RelativePath.make("link.txt") })
.pipe(Effect.exit),
),
).toBe(true)
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
})
function references(
entries: Record<string, ProjectReference.Resolved>,
ensurePath: ProjectReference.Interface["ensurePath"] = () => Effect.void,
) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath,
containsManagedPath: () => Effect.succeed(false),
})
}
+2 -2
View File
@@ -18,7 +18,7 @@ import { Global } from "../src/global"
import { ModelsDev } from "../src/models-dev"
import { Npm } from "../src/npm"
import { Project } from "../src/project"
import { Reference } from "../src/reference"
import { ProjectReference } from "../src/project-reference"
import { LocationSearch } from "../src/location-search"
import { ToolRegistry } from "../src/tool/registry"
import { ApplicationTools } from "../src/tool/application-tools"
@@ -71,7 +71,7 @@ describe("LocationServiceMap", () => {
const update = (directory: string) =>
Effect.gen(function* () {
yield* PluginBoot.Service.use((boot) => boot.wait())
yield* Reference.Service
yield* ProjectReference.Service
yield* LocationSearch.Service
const catalog = yield* Catalog.Service
const transform = yield* catalog.transform()
+1 -1
View File
@@ -171,7 +171,7 @@ describe("LocationMutation", () => {
),
)
test("ignores unknown mutation input fields", () => {
test("keeps project references outside the mutation input API", () => {
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md",
+22 -4
View File
@@ -8,6 +8,7 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
import { LocationSearch } from "@opencode-ai/core/location-search"
import { AppProcess } from "@opencode-ai/core/process"
import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
@@ -15,12 +16,15 @@ import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
function provide(directory: string, data = Global.Path.data) {
const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
Global.layerWith({ data }),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
@@ -52,7 +56,7 @@ describe("LocationSearch", () => {
const search = yield* LocationSearch.Service
const result = yield* search.grep({ pattern: "FAIL", path: output })
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
}).pipe(provide(directory, data))
}).pipe(provide(directory, inertReferences, data))
}),
)
@@ -79,7 +83,7 @@ describe("LocationSearch", () => {
),
)
it.live("searches files under a relative subdirectory", () =>
it.live("searches files under a relative subdirectory and named local reference", () =>
withTmp((directory) => {
const docs = path.join(directory, "docs")
return Effect.gen(function* () {
@@ -94,7 +98,11 @@ describe("LocationSearch", () => {
expect(
(yield* search.files({ pattern: "*.ts", path: RelativePath.make("src") })).items.map((item) => item.path),
).toEqual([RelativePath.make("src/active.ts")])
}).pipe(provide(directory))
const guide = yield* Effect.promise(() => fs.realpath(path.join(docs, "guide.md")))
expect((yield* search.files({ pattern: "*.md", reference: "docs" })).items).toMatchObject([
{ path: RelativePath.make("guide.md"), resource: "docs:guide.md", canonical: guide },
])
}).pipe(provide(directory, references({ docs: { name: "docs", kind: "local", path: docs } })))
}),
)
@@ -256,3 +264,13 @@ describe("LocationSearch", () => {
expect(() => decode({ pattern: "*", limit: LocationSearch.MAX_RESULT_LIMIT + 1 })).toThrow()
})
})
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}
@@ -0,0 +1,299 @@
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Deferred, Effect, Layer, Schema } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigReference } from "@opencode-ai/core/config/reference"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
describe("ProjectReference", () => {
it.live("uses the broad experimental flag unless references are explicitly configured", () =>
withEnv(
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: undefined },
Effect.sync(() => {
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(true)
}),
).pipe(
Effect.flatMap(() =>
withEnv(
{ OPENCODE_EXPERIMENTAL: "true", OPENCODE_EXPERIMENTAL_REFERENCES: "false" },
Effect.sync(() => {
expect(Flag.OPENCODE_EXPERIMENTAL_REFERENCES).toBe(false)
}),
),
),
),
)
it.live("normalizes aliases and resolves relative local paths from the project root", () =>
withTmp((tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
const nested = path.join(project, "packages", "app")
yield* Effect.promise(() => fs.mkdir(nested, { recursive: true }))
const references = ProjectReference.resolveAll({
references: ConfigReference.normalize({
docs: { path: "./docs" },
home: "~/notes",
sdk: { repository: "owner/repo", branch: "main" },
shorthand: "owner/other",
invalid: "not-a-repo",
"bad/name": "owner/repo",
}),
directory: project,
home: path.join(tmp.path, "home"),
repos: path.join(tmp.path, "repos"),
})
expect(references).toMatchObject([
{ name: "docs", kind: "local", path: path.join(project, "docs") },
{ name: "home", kind: "local", path: path.join(tmp.path, "home", "notes") },
{ name: "sdk", kind: "git", branch: "main" },
{ name: "shorthand", kind: "git" },
{ name: "invalid", kind: "invalid", repository: "not-a-repo" },
{ name: "bad/name", kind: "invalid" },
])
}),
),
)
it.live("marks same-cache references with different branches invalid", () =>
Effect.sync(() => {
const references = ProjectReference.resolveAll({
references: ConfigReference.normalize({
main: { repository: "owner/repo", branch: "main" },
dev: { repository: "github.com/owner/repo", branch: "dev" },
alsoMain: { repository: "https://github.com/owner/repo", branch: "main" },
}),
directory: "/project",
home: "/home",
repos: "/repos",
})
expect(references.map((reference) => reference.kind)).toEqual(["git", "invalid", "git"])
expect(references[1]?.kind === "invalid" ? references[1].message : "").toContain("conflicts with @main")
}),
)
it.live("merges config aliases and exposes mention and managed-path operations", () =>
withoutReferences(
withTmp((tmp) => {
const calls: RepositoryCache.EnsureInput[] = []
const project = path.join(tmp.path, "project")
const nested = path.join(project, "packages", "app")
const docs = path.join(project, "docs")
const repos = path.join(tmp.path, "repos")
return Effect.gen(function* () {
yield* Effect.promise(async () => {
await fs.mkdir(nested, { recursive: true })
await fs.mkdir(docs)
await fs.writeFile(path.join(docs, "README.md"), "docs")
})
yield* withReferences(
Effect.gen(function* () {
const references = yield* ProjectReference.Service
const git = path.join(repos, "github.com", "owner", "repo")
expect(yield* references.list()).toMatchObject([
{ name: "docs", kind: "local", path: docs },
{ name: "sdk", kind: "git", path: git },
])
expect(yield* references.resolveMention("docs/README.md")).toMatchObject({
name: "docs",
kind: "reference",
target: "README.md",
path: path.join(docs, "README.md"),
})
expect(yield* references.resolveMention("docs/missing.md")).toMatchObject({
name: "docs",
kind: "missing",
})
expect(yield* references.resolveMention("docs/../outside.md")).toMatchObject({
name: "docs",
kind: "invalid",
})
expect(yield* references.resolveMention("unknown")).toBeUndefined()
expect(yield* references.resolveMention("sdk")).toMatchObject({
name: "sdk",
kind: "reference",
path: git,
})
expect(yield* references.containsManagedPath(path.join(git, "README.md"))).toBe(true)
expect(yield* references.containsManagedPath(path.join(docs, "README.md"))).toBe(false)
yield* references.ensurePath()
expect(calls).toHaveLength(1)
}).pipe(
Effect.provide(
testLayer({
directory: nested,
project,
repos,
documents: [
document({ docs: { path: "./old-docs" }, sdk: "owner/old" }),
document({ docs: { path: "./docs" }, sdk: { repository: "owner/repo", branch: "main" } }),
],
ensure: (input) => Effect.sync(() => result(repos, calls, input)),
}),
),
),
)
})
}),
),
)
it.live("is inert while the runtime flag is disabled", () =>
withoutReferences(
withTmp((tmp) => {
const calls: RepositoryCache.EnsureInput[] = []
return Effect.gen(function* () {
const references = yield* ProjectReference.Service
expect(yield* references.list()).toEqual([])
expect(yield* references.get("sdk")).toBeUndefined()
expect(yield* references.resolveMention("sdk")).toBeUndefined()
expect(
yield* references.containsManagedPath(path.join(tmp.path, "repos", "github.com", "owner", "repo")),
).toBe(false)
yield* references.ensurePath()
expect(calls).toEqual([])
}).pipe(
Effect.provide(
testLayer({
directory: tmp.path,
project: tmp.path,
repos: path.join(tmp.path, "repos"),
documents: [document({ sdk: "owner/repo" })],
ensure: (input) => Effect.sync(() => result(path.join(tmp.path, "repos"), calls, input)),
}),
),
)
}),
),
)
it.live("starts Git materialization in the background without blocking the location layer", () =>
withTmp((tmp) =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
yield* withReferences(
Effect.gen(function* () {
expect(yield* (yield* ProjectReference.Service).list()).toHaveLength(1)
yield* Deferred.await(started).pipe(
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.die(new Error("refresh did not start")),
}),
)
}).pipe(
Effect.provide(
testLayer({
directory: tmp.path,
project: tmp.path,
repos: path.join(tmp.path, "repos"),
documents: [document({ sdk: "owner/repo" })],
ensure: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
}),
),
),
)
}),
),
)
})
function document(references: ConfigReference.Info) {
return new Config.Document({ type: "document", info: Schema.decodeUnknownSync(Config.Info)({ references }) })
}
function result(
repos: string,
calls: RepositoryCache.EnsureInput[],
input: RepositoryCache.EnsureInput,
): RepositoryCache.Result {
calls.push(input)
return {
repository: input.reference.label,
host: input.reference.host,
remote: input.reference.remote,
localPath: Repository.cachePath(repos, input.reference),
status: "cached",
branch: input.branch,
}
}
function testLayer(input: {
directory: string
project: string
repos: string
documents: Config.Document[]
ensure: RepositoryCache.Interface["ensure"]
}) {
return ProjectReference.layer.pipe(
Layer.provide(
Layer.mergeAll(
FSUtil.defaultLayer,
Global.layerWith({ home: path.join(input.directory, "home"), repos: input.repos }),
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(input.directory) },
{ projectDirectory: AbsolutePath.make(input.project) },
),
),
),
Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(input.documents) })),
Layer.succeed(RepositoryCache.Service, RepositoryCache.Service.of({ ensure: input.ensure })),
),
),
)
}
function withTmp<A, E, R>(body: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
body,
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
}
function withReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
return withEnv({ OPENCODE_EXPERIMENTAL_REFERENCES: "true" }, body)
}
function withoutReferences<A, E, R>(body: Effect.Effect<A, E, R>) {
return withEnv({ OPENCODE_EXPERIMENTAL: undefined, OPENCODE_EXPERIMENTAL_REFERENCES: undefined }, body)
}
function withEnv<A, E, R>(env: Record<string, string | undefined>, body: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
return previous
}),
() => body,
(previous) =>
Effect.sync(() => {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
}),
)
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Exit, Layer, Scope } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { Reference } from "@opencode-ai/core/reference"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
const cache = Layer.mock(RepositoryCache.Service, {
ensure: () => Effect.die("unexpected Git materialization"),
})
describe("Reference", () => {
it.effect("registers normalized sources for the owning scope", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const scope = yield* Scope.make()
const update = yield* references.transform().pipe(Effect.provideService(Scope.Scope, scope))
const path = AbsolutePath.make("/docs")
yield* update((editor) => editor.add("docs", new Reference.LocalSource({ type: "local", path })))
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "docs", path, source: new Reference.LocalSource({ type: "local", path }) }),
])
yield* Scope.close(scope, Exit.void)
expect(yield* references.list()).toEqual([])
}).pipe(
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
)
it.effect("derives Git paths without exposing cache operations", () =>
Effect.gen(function* () {
const references = yield* Reference.Service
const update = yield* references.transform()
const repository = Repository.parseRemote("owner/repo")
const source = new Reference.GitSource({ type: "git", repository: "owner/repo", branch: "main" })
yield* update((editor) => editor.add("sdk", source))
expect(yield* references.list()).toEqual([
new Reference.Info({ name: "sdk", path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), source }),
])
}).pipe(
Effect.scoped,
Effect.provide(Reference.layer),
Effect.provide(cache),
Effect.provide(EventV2.defaultLayer),
Effect.provide(Global.defaultLayer),
),
)
})
@@ -4,7 +4,7 @@ import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { AgentAttachment, FileAttachment } from "@opencode-ai/core/session/prompt"
import { AgentAttachment, FileAttachment, ReferenceAttachment } from "@opencode-ai/core/session/prompt"
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolOutput } from "@opencode-ai/core/tool-output"
@@ -17,6 +17,7 @@ const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.
describe("toLLMMessages", () => {
test("maps every top-level V2 Session message type", () => {
const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" })
const reference = new ReferenceAttachment({ name: "docs", kind: "local", uri: "file:///docs" })
const messages = toLLMMessages(
[
new SessionMessage.AgentSwitched({
@@ -43,6 +44,7 @@ describe("toLLMMessages", () => {
text: "Inspect this image",
files: [file],
agents: [new AgentAttachment({ name: "build" })],
references: [reference],
time: { created },
}),
new SessionMessage.Synthetic({
@@ -82,7 +84,7 @@ describe("toLLMMessages", () => {
{ type: "text", text: "Inspect this image" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,aGVsbG8=", filename: "hello.png" },
],
metadata: { agents: [{ name: "build" }] },
metadata: { agents: [{ name: "build" }], references: [reference] },
}),
)
expect(messages.slice(2).map((message) => message.content)).toEqual([
+38 -3
View File
@@ -43,10 +43,12 @@ const filesystem = Layer.succeed(
Effect.sync(() => {
resolutions.push(input)
const relative = input.path ?? RelativePath.make(".")
const resource = input.reference === undefined ? relative : `${input.reference}:${relative}`
return new FileSystem.RootTarget({
real: `/project/${relative}`,
root: "/project",
resource: relative,
resource,
reference: input.reference,
type: "directory",
})
}),
@@ -120,10 +122,10 @@ describe("GlobTool", () => {
action: "glob",
resources: ["**/*.ts"],
save: ["*"],
metadata: { root: "src", path: "src", limit: 12 },
metadata: { root: "src", reference: undefined, path: "src", limit: 12 },
},
])
expect(resolutions).toEqual([{ path: RelativePath.make("src") }])
expect(resolutions).toEqual([{ path: RelativePath.make("src"), reference: undefined }])
expect(searches).toEqual([{ pattern: "**/*.ts", path: RelativePath.make("src"), limit: 12 }])
}),
)
@@ -167,6 +169,39 @@ describe("GlobTool", () => {
}),
)
it.effect("searches named references with root and reference metadata", () =>
Effect.gen(function* () {
reset()
result = new LocationSearch.FilesResult({
items: [
new LocationSearch.File({
path: RelativePath.make("guide.md"),
canonical: "/project/docs/guide.md",
resource: "docs:guide.md",
mtime: 1,
}),
],
truncated: false,
partial: false,
})
expect(yield* executeTool(yield* ToolRegistry.Service, call({ pattern: "*.md", reference: "docs" }))).toEqual({
type: "text",
value: "docs:guide.md",
})
expect(assertions).toMatchObject([
{
sessionID,
action: "glob",
resources: ["*.md"],
save: ["*"],
metadata: { root: "docs:.", reference: "docs", path: undefined, limit: undefined },
},
])
expect(searches).toEqual([{ pattern: "*.md", reference: "docs" }])
}),
)
it.effect("formats bounded and partial results without discarding structured output", () =>
Effect.sync(() => {
const output = new LocationSearch.FilesResult({
+42 -5
View File
@@ -9,6 +9,7 @@ import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgr
import { LocationSearch } from "@opencode-ai/core/location-search"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
@@ -38,7 +39,8 @@ const filesystem = Layer.succeed(
new FileSystem.RootTarget({
real: `/project/${input.path ?? "."}`,
root: "/project",
resource: input.path ?? ".",
resource: input.reference === undefined ? (input.path ?? ".") : `${input.reference}:${input.path ?? "."}`,
reference: input.reference,
type: "directory",
}),
),
@@ -113,12 +115,23 @@ const reset = () => {
result = new LocationSearch.GrepResult({ items: [], truncated: false, partial: false })
}
function provideLive(directory: string) {
function references(entries: Record<string, ProjectReference.Resolved>) {
return ProjectReference.Service.of({
list: () => Effect.succeed(Object.values(entries)),
get: (name) => Effect.succeed(entries[name]),
resolveMention: () => Effect.succeed(undefined),
ensurePath: () => Effect.void,
containsManagedPath: () => Effect.succeed(false),
})
}
function provideLive(directory: string, projectReferences = references({})) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
@@ -157,13 +170,29 @@ describe("GrepTool", () => {
action: "grep",
resources: ["needle"],
save: ["*"],
metadata: { root: "src", path: RelativePath.make("src"), include: "*.ts", limit: 2 },
metadata: { root: "src", reference: undefined, path: RelativePath.make("src"), include: "*.ts", limit: 2 },
},
])
expect(searches).toEqual([{ pattern: "needle", path: RelativePath.make("src"), include: "*.ts", limit: 2 }])
}),
)
it.effect("delegates named reference grep and exposes the canonical selected root in metadata", () =>
Effect.gen(function* () {
reset()
yield* execute({ pattern: "guide", path: "docs", reference: "manual", include: "*.md" })
expect(assertions[0]).toMatchObject({
resources: ["guide"],
metadata: { root: "manual:docs", reference: "manual", path: RelativePath.make("docs"), include: "*.md" },
})
expect(searches).toEqual([
{ pattern: "guide", path: RelativePath.make("docs"), reference: "manual", include: "*.md" },
])
}),
)
it.effect("does not search when permission is denied", () =>
Effect.gen(function* () {
reset()
@@ -219,7 +248,7 @@ describe("GrepTool", () => {
}),
)
runtimeIt.live("greps active Location files with include globs", () =>
runtimeIt.live("greps active Location and named-reference files with include globs", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -230,15 +259,23 @@ describe("GrepTool", () => {
reset()
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, "src"))
await fs.mkdir(docs)
await fs.writeFile(path.join(tmp.path, "src", "index.ts"), "needle ts\n")
await fs.writeFile(path.join(tmp.path, "src", "notes.txt"), "needle txt\n")
await fs.writeFile(path.join(docs, "guide.md"), "needle docs\n")
})
expect(yield* execute({ pattern: "needle", path: "src", include: "*.ts" })).toEqual({
type: "text",
value: "Found 1 matches\nsrc/index.ts:\n Line 1: needle ts\n",
})
}).pipe(Effect.provide(provideLive(tmp.path)))
expect(yield* execute({ pattern: "needle", reference: "docs", include: "*.md" })).toEqual({
type: "text",
value: "Found 1 matches\ndocs:guide.md:\n Line 1: needle docs\n",
})
}).pipe(
Effect.provide(provideLive(tmp.path, references({ docs: { name: "docs", kind: "local", path: docs } }))),
)
}),
),
)
+32 -1
View File
@@ -35,7 +35,7 @@ const filesystem = Layer.succeed(
? Effect.succeed(
new FileSystem.ReadPath({
type: resolvedType,
resource: input.path,
resource: input.reference === undefined ? input.path : `${input.reference}:${input.path}`,
}),
)
: Effect.die(resolveFailure),
@@ -457,6 +457,20 @@ describe("ReadTool", () => {
}),
)
it.effect("authorizes project references with their canonical identity", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-read", name: "read", input: { path: "README.md", reference: "docs" } },
})
expect(assertions).toMatchObject([{ resources: ["docs:README.md"] }])
}),
)
it.effect("preserves unexpected resolution defects", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
@@ -476,6 +490,23 @@ describe("ReadTool", () => {
}),
)
it.effect("returns path validation failures as model-visible tool output", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
resolveFailure = new FileSystem.PathValidationError("Absolute path is not managed tool output")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-unmanaged", name: "read", input: { path: "/tmp/private.txt" } },
}),
).toEqual({ type: "error", value: "Absolute path is not managed tool output" })
expect(readCalls).toEqual([])
}),
)
it.effect("forwards pagination and returns bounded text pages with continuation", () =>
Effect.gen(function* () {
readResult = new FileSystem.TextPage({
-4
View File
@@ -1,6 +1,5 @@
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "url"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
@@ -101,9 +100,6 @@ describe("SkillTool", () => {
type: "text",
value: SkillTool.toModelOutput(info, [reference]),
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(
`Base directory for this skill: ${pathToFileURL(directory).href}`,
)
expect(
yield* settleTool(registry, {
sessionID,
+4
View File
@@ -4,4 +4,8 @@ type Channel = "dev" | "beta" | "prod"
const raw = import.meta.env.OPENCODE_CHANNEL
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
+3
View File
@@ -15,5 +15,8 @@ declare module "virtual:opencode-server" {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
}
+3 -7
View File
@@ -145,10 +145,8 @@ const main = Effect.gen(function* () {
})
},
{
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
const stopSidecars = async () => {
@@ -329,9 +327,7 @@ const main = Effect.gen(function* () {
password,
})
if (process.platform === "win32") {
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
}
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
yield* Effect.promise(() => health.wait).pipe(
Effect.timeout("30 seconds"),
+2 -3
View File
@@ -2,10 +2,9 @@ import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { app, utilityProcess } from "electron"
import type { Details } from "electron"
import { getLogger } from "./logging"
import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { getStore } from "./store"
import { DEFAULT_SERVER_URL_KEY } from "./store-keys"
export type HealthCheck = { wait: Promise<void> }
@@ -44,7 +43,7 @@ export function setDefaultServerUrl(url: string | null) {
export function preferAppEnv(userDataPath: string) {
const shell = process.platform === "win32" ? null : getUserShell()
Object.assign(process.env, {
...(shell ? loadShellEnv(shell, getLogger()) : null),
...(shell ? loadShellEnv(shell) : null),
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
+3 -4
View File
@@ -1,13 +1,11 @@
import { spawnSync } from "node:child_process"
import { userInfo } from "node:os"
import { basename } from "node:path"
import { getLogger } from "./logging"
const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
type ShellEnvLogger = {
log: (message: string) => void
}
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
@@ -67,7 +65,8 @@ export function isNushell(shell: string) {
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
export function loadShellEnv(shell: string) {
const logger = getLogger()
if (isNushell(shell)) {
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
return null
+2 -1
View File
@@ -54,7 +54,8 @@ async function start(command: StartCommand) {
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Server } = await import("virtual:opencode-server")
const { Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
listener = await Server.listen({
port: command.port,
-4
View File
@@ -1,4 +0,0 @@
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
+3 -3
View File
@@ -1,7 +1,7 @@
import Store from "electron-store"
import electron from "electron"
import { app } from "electron"
import { SETTINGS_STORE } from "./store-keys"
import { SETTINGS_STORE } from "./constants"
const cache = new Map<string, Store>()
@@ -14,7 +14,7 @@ export function getStore(name = SETTINGS_STORE) {
if (cached) return cached
const next = new Store({
name,
cwd: electron.app.getPath("userData"),
cwd: app.getPath("userData"),
fileExtension: "",
accessPropertiesByDotNotation: false,
})
+1 -1
View File
@@ -6,9 +6,9 @@ import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } f
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { PINCH_ZOOM_ENABLED_KEY } from "./constants"
import { exportDebugLogs, write as writeLog } from "./logging"
import { getStore } from "./store"
import { PINCH_ZOOM_ENABLED_KEY } from "./store-keys"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
-43
View File
@@ -2,14 +2,8 @@ import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
import type { WslServersController } from "./servers"
import { requireWslIpcString } from "./policy"
import type { WslServersState } from "../../preload/types"
export function registerWslIpcHandlers(controller: WslServersController) {
if (process.platform !== "win32") {
registerUnavailableWslIpcHandlers()
return
}
const subscriptions = new Map<number, () => void>()
const unsubscribe = (id: number) => {
const off = subscriptions.get(id)
@@ -68,40 +62,3 @@ export function registerWslIpcHandlers(controller: WslServersController) {
controller.startServer(requireWslIpcString("server id", id)),
)
}
function registerUnavailableWslIpcHandlers() {
const unavailable = () => {
throw new Error("WSL is only available on Windows")
}
const state = (): WslServersState => ({
runtime: {
available: false,
version: null,
error: "WSL is only available on Windows",
},
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
})
ipcMain.handle("wsl-servers-subscribe", (event) => {
event.sender.send("wsl-servers-event", { type: "state", state: state() })
})
ipcMain.handle("wsl-servers-unsubscribe", () => undefined)
ipcMain.handle("wsl-servers-get-state", () => state())
ipcMain.handle("wsl-servers-probe-runtime", unavailable)
ipcMain.handle("wsl-servers-refresh-distros", unavailable)
ipcMain.handle("wsl-servers-install-wsl", unavailable)
ipcMain.handle("wsl-servers-install-distro", unavailable)
ipcMain.handle("wsl-servers-probe-distro", unavailable)
ipcMain.handle("wsl-servers-probe-opencode", unavailable)
ipcMain.handle("wsl-servers-install-opencode", unavailable)
ipcMain.handle("wsl-servers-open-terminal", unavailable)
ipcMain.handle("wsl-servers-add", unavailable)
ipcMain.handle("wsl-servers-remove", unavailable)
ipcMain.handle("wsl-servers-start", unavailable)
}
@@ -6,10 +6,6 @@ import {
pollWslHealth,
wslServerIdsToStartOnInitialize,
} from "./startup"
import { createWslServersController, type WslServerConfig } from "./servers"
let persistedServers: WslServerConfig[] = []
let releaseOpencodeResolve: (() => void) | undefined
test("starts every configured WSL server on initialization", () => {
expect(
@@ -95,73 +91,3 @@ test("derives a required Windows restart from the post-install runtime probe", (
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
})
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
persistedServers = []
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => ({
listener: {
stop: () => undefined,
onExit: () => undefined,
},
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}),
testControllerOptions(),
)
await controller.addServer("Debian")
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
releaseOpencodeResolve = undefined
const controller = createWslServersController(
"1.16.2",
async () => new Promise<never>(() => undefined),
testControllerOptions(),
)
await controller.initialize()
await waitFor(() => !!releaseOpencodeResolve)
await controller.removeServer("wsl:Debian")
releaseOpencodeResolve?.()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(controller.getState().servers).toEqual([])
expect(controller.getState().opencodeChecks).toEqual({})
})
async function waitFor(check: () => boolean) {
for (let attempt = 0; attempt < 20; attempt++) {
if (check()) return
await new Promise((resolve) => setTimeout(resolve, 0))
}
throw new Error("Timed out waiting for condition")
}
function testControllerOptions() {
return {
readServers: () => persistedServers,
writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers
},
readCommandVersion: async () => "1.16.2",
resolveOpencode: async () => {
await new Promise<void>((resolve) => {
releaseOpencodeResolve = resolve
})
return "/home/me/.opencode/bin/opencode"
},
}
}
+13 -72
View File
@@ -11,7 +11,7 @@ import type {
WslServersEvent,
WslServersState,
} from "../../preload/types"
import { WSL_SERVERS_KEY } from "../store-keys"
import { WSL_SERVERS_KEY } from "../constants"
import { getStore } from "../store"
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
@@ -43,33 +43,18 @@ type ControllerLogger = {
error: (message: string, meta?: unknown) => void
}
type WslServersControllerOptions = {
logger?: ControllerLogger
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
resolveOpencode?: typeof resolveWslOpencode
readCommandVersion?: typeof readWslCommandVersion
}
export type WslServersController = ReturnType<typeof createWslServersController>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(
appVersion: string,
spawnSidecar: SpawnSidecar,
options?: WslServersControllerOptions,
) {
export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const logger = options?.logger
const readServers = options?.readServers ?? readPersistedServers
const writeServers = options?.writeServers ?? writePersistedServers
const emit = () => {
for (const listener of listeners) listener({ type: "state", state })
@@ -81,7 +66,7 @@ export function createWslServersController(
}
const persistServers = (servers: WslServerConfig[]) => {
writeServers(servers)
getStore().set(WSL_SERVERS_KEY, { servers })
}
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
@@ -104,7 +89,7 @@ export function createWslServersController(
}
const refreshFromStore = () => {
const persisted = readServers()
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
const existing = state.servers.find((item) => item.config.id === config.id)
return {
@@ -128,52 +113,10 @@ export function createWslServersController(
})
}
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
const version = resolved
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
: null
return opencodeCheck(distro, resolved, version, appVersion)
}
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
setOpencodeCheck(distro, await checkOpencode(distro, opts))
}
const hasServer = (id: string, distro: string) => {
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
}
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
void checkOpencode(distro)
.then((check) => {
if (!hasServer(id, distro)) return
setOpencodeCheck(distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro, message })
})
}
const refreshOpencodeChecks = async () => {
await Promise.all(
state.servers.map((item) =>
checkOpencode(item.config.distro)
.then((check) => {
if (!hasServer(item.config.id, item.config.distro)) return
setOpencodeCheck(item.config.distro, check)
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", {
id: item.config.id,
distro: item.config.distro,
message,
})
}),
),
)
const resolved = await resolveWslOpencode(distro, opts)
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
@@ -227,7 +170,10 @@ export function createWslServersController(
setRuntime(id, { kind: "failed", message })
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
})
refreshOpencodeCheckBackground(id, item.config.distro)
void refreshOpencodeCheck(item.config.distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
logger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
})
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
@@ -279,7 +225,6 @@ export function createWslServersController(
async initialize() {
refreshFromStore()
void refreshOpencodeChecks()
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
},
@@ -366,7 +311,7 @@ export function createWslServersController(
id,
distro,
}
persistServers([...readServers(), config])
persistServers([...readPersistedServers(), config])
setState({
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
})
@@ -378,7 +323,7 @@ export function createWslServersController(
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readServers().filter((item) => item.id !== id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({
servers: state.servers.filter((item) => item.config.id !== id),
@@ -426,10 +371,6 @@ function readPersistedServers(): WslServerConfig[] {
return []
}
function writePersistedServers(servers: WslServerConfig[]) {
getStore().set(WSL_SERVERS_KEY, { servers })
}
function normalizePersistedServer(value: unknown): WslServerConfig[] {
if (!value || typeof value !== "object") return []
const record = value as Record<string, unknown>
+2 -1
View File
@@ -6,7 +6,8 @@
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
+2 -1
View File
@@ -27,7 +27,8 @@
"access": "public"
},
"scripts": {
"test": "bun test --timeout 30000 --only-failures",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit",
"build": "bun ./script/build.ts",
"verify:package": "bun ./script/verify-package.ts"
+1 -1
View File
@@ -7,7 +7,7 @@
"private": true,
"scripts": {
"setup:recording-env": "bun run script/setup-recording-env.ts",
"test": "bun test --timeout 30000 --only-failures",
"test": "bun test --timeout 30000",
"typecheck": "tsgo --noEmit"
},
"exports": {
@@ -0,0 +1,136 @@
# Bun shell migration plan
Practical phased replacement of Bun `$` calls.
## Goal
Replace runtime Bun shell template-tag usage in `packages/opencode/src` with a unified `Process` API in `util/process.ts`.
Keep behavior stable while improving safety, testability, and observability.
Current baseline from audit:
- 143 runtime command invocations across 17 files
- 84 are git commands
- Largest hotspots:
- `src/cli/cmd/github.ts` (33)
- `src/worktree/index.ts` (22)
- `src/lsp/server.ts` (21)
- `src/installation/index.ts` (20)
- `src/snapshot/index.ts` (18)
## Decisions
- Extend `src/util/process.ts` (do not create a separate exec module).
- Proceed with phased migration for both git and non-git paths.
- Keep plugin `$` compatibility in 1.x and remove in 2.0.
## Non-goals
- Do not remove plugin `$` compatibility in this effort.
- Do not redesign command semantics beyond what is needed to preserve behavior.
## Constraints
- Keep migration phased, not big-bang.
- Minimize behavioral drift.
- Keep these explicit shell-only exceptions:
- `src/session/prompt.ts` raw command execution
- worktree start scripts in `src/worktree/index.ts`
## Process API proposal (`src/util/process.ts`)
Add higher-level wrappers on top of current spawn support.
Core methods:
- `Process.run(cmd, opts)`
- `Process.text(cmd, opts)`
- `Process.lines(cmd, opts)`
- `Process.status(cmd, opts)`
- `Process.shell(command, opts)` for intentional shell execution
Git helpers:
- `Process.git(args, opts)`
- `Process.gitText(args, opts)`
Shared options:
- `cwd`, `env`, `stdin`, `stdout`, `stderr`, `abort`, `timeout`, `kill`
- `allowFailure` / non-throw mode
- optional redaction + trace metadata
Standard result shape:
- `code`, `stdout`, `stderr`, `duration_ms`, `cmd`
- helpers like `text()` and `arrayBuffer()` where useful
## Phased rollout
### Phase 0: Foundation
- Implement Process wrappers in `src/util/process.ts`.
- Refactor `src/util/git.ts` to use Process only.
- Add tests for exit handling, timeout, abort, and output capture.
### Phase 1: High-impact hotspots
Migrate these first:
- `src/cli/cmd/github.ts`
- `src/worktree/index.ts`
- `src/lsp/server.ts`
- `src/installation/index.ts`
- `src/snapshot/index.ts`
Within each file, migrate git paths first where applicable.
### Phase 2: Remaining git-heavy files
Migrate git-centric call sites to `Process.git*` helpers:
- `../core/src/filesystem.ts`
- `src/project/vcs.ts`
- `../core/src/filesystem/watcher.ts`
- `src/storage/storage.ts`
- `src/cli/cmd/pr.ts`
### Phase 3: Remaining non-git files
Migrate residual non-git usages:
- `src/cli/cmd/tui/util/clipboard.ts`
- `src/util/archive.ts`
- `../core/src/filesystem/ripgrep.ts`
- `src/tool/bash.ts`
- `src/cli/cmd/uninstall.ts`
### Phase 4: Stabilize
- Remove dead wrappers and one-off patterns.
- Keep plugin `$` compatibility isolated and documented as temporary.
- Create linked 2.0 task for plugin `$` removal.
## Validation strategy
- Unit tests for new `Process` methods and options.
- Integration tests on hotspot modules.
- Smoke tests for install, snapshot, worktree, and GitHub flows.
- Regression checks for output parsing behavior.
## Risk mitigation
- File-by-file PRs with small diffs.
- Preserve behavior first, simplify second.
- Keep shell-only exceptions explicit and documented.
- Add consistent error shaping and logging at Process layer.
## Definition of done
- Runtime Bun `$` usage in `packages/opencode/src` is removed except:
- approved shell-only exceptions
- temporary plugin compatibility path (1.x)
- Git paths use `Process.git*` consistently.
- CI and targeted smoke tests pass.
- 2.0 issue exists for plugin `$` removal.
+3 -2
View File
@@ -7,7 +7,8 @@
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000 --only-failures",
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"test:httpapi": "bun run script/httpapi-exercise.ts --mode coverage --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode auth --fail-on-missing --fail-on-skip && bun run script/httpapi-exercise.ts --mode effect --fail-on-missing --fail-on-skip",
"bench:test": "bun run script/bench-test-suite.ts",
"profile:test": "bun run script/profile-test-files.ts",
@@ -79,7 +80,7 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@modelcontextprotocol/sdk": "1.29.0",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
+12 -4
View File
@@ -1,4 +1,5 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type {
Event,
EventMessagePartDelta,
@@ -21,6 +22,8 @@ import {
completedToolUpdate,
} from "./tool"
const log = Log.create({ service: "acp-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type GlobalEventEnvelope = {
@@ -56,8 +59,9 @@ export class Subscription {
start() {
if (this.started) return
this.started = true
this.run().catch(() => {
this.run().catch((error: unknown) => {
if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
@@ -121,7 +125,9 @@ export class Subscription {
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch(() => {})
await this.handle(event.payload).catch((error: unknown) => {
log.error("failed to handle event", { error, type: event.payload?.type })
})
}
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
@@ -208,7 +214,10 @@ export class Subscription {
{ throwOnError: true },
)
.then((response) => response.data)
.catch(() => undefined)
.catch((error: unknown) => {
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return
const part = message.parts.find((item) => item.id === partId)
@@ -321,7 +330,6 @@ export class Subscription {
...pendingToolCall({
toolCallId: part.callID,
toolName: part.tool,
state: part.state,
}),
},
})
+24 -3
View File
@@ -1,4 +1,5 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
@@ -6,6 +7,8 @@ import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
const log = Log.create({ service: "acp-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
@@ -32,7 +35,9 @@ export class Handler {
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch(() => {})
.catch((error: unknown) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID)
@@ -47,6 +52,10 @@ export class Handler {
if (!session) return
if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return
}
@@ -64,7 +73,12 @@ export class Handler {
},
options: permissionOptions,
})
.catch(async () => {
.catch(async (error: unknown) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
@@ -78,7 +92,13 @@ export class Handler {
}
if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
}
await this.reply(permission.id, reply, session.cwd)
@@ -100,6 +120,7 @@ export class Handler {
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return
}
+19 -5
View File
@@ -30,6 +30,7 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPError from "./error"
@@ -46,6 +47,7 @@ import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-service" })
export type Error = ACPError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
@@ -331,7 +333,9 @@ export function make(input: {
"session",
).pipe(
Effect.catch((error) =>
Effect.logError("failed to abort ACP backing session", { error: error, sessionID: current.id }),
Effect.sync(() => {
log.error("failed to abort ACP backing session", { error, sessionID: current.id })
}),
),
)
})
@@ -606,7 +610,10 @@ function makeUsageService(sdk: OpencodeClient) {
) as Record<ProviderV2.ID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch(() => undefined)
.catch((error: unknown) => {
log.error("failed to get providers for usage context limit", { error })
return undefined
})
limits.set(key, next)
return yield* Effect.promise(() => next)
},
@@ -626,7 +633,10 @@ function makeUsageService(sdk: OpencodeClient) {
).pipe(
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
Effect.catch((error) =>
Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)),
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
@@ -652,7 +662,9 @@ function makeUsageService(sdk: OpencodeClient) {
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
},
})
.catch(() => {}),
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
})
@@ -669,7 +681,9 @@ function replayMessages(subscription: ACPEvent.Subscription | undefined, message
if (!subscription) return Effect.void
return Effect.promise(async () => {
for (const message of messages) {
await subscription.replayMessage(message).catch(() => {})
await subscription.replayMessage(message).catch((error: unknown) => {
log.error("failed to replay ACP message", { error, messageID: message.info.id })
})
}
})
}
+4 -8
View File
@@ -118,18 +118,14 @@ export function completedToolContent(toolName: string, state: CompletedToolState
return content
}
export function pendingToolCall(input: {
readonly toolCallId: string
readonly toolName: string
readonly state: { readonly input: ToolInput; readonly title?: string }
}): ToolCall {
export function pendingToolCall(input: { readonly toolCallId: string; readonly toolName: string }): ToolCall {
return {
toolCallId: input.toolCallId,
title: input.state.title || input.toolName,
title: input.toolName,
kind: toToolKind(input.toolName),
status: "pending",
locations: toLocations(input.toolName, input.state.input),
rawInput: input.state.input,
locations: [],
rawInput: {},
}
}
+18 -11
View File
@@ -1,4 +1,5 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
@@ -7,6 +8,8 @@ import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-usage" })
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost &
@@ -154,9 +157,10 @@ export const layer = Layer.effect(
contextLimitLoader.providers(input.directory).pipe(
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
Effect.catch((error) =>
Effect.logError("failed to get providers for usage context limit", { error: error }).pipe(
Effect.as(undefined),
),
Effect.sync(() => {
log.error("failed to get providers for usage context limit", { error })
return undefined
}),
),
),
)
@@ -178,13 +182,14 @@ export const layer = Layer.effect(
readonly sessionID: string
readonly directory: string
}) {
const messages = yield* messageLoader
.messages({ sessionID: input.sessionID, directory: input.directory })
.pipe(
Effect.catch((error) =>
Effect.logError("failed to fetch messages for usage update", { error: error }).pipe(Effect.as(undefined)),
),
)
const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = latestAssistantMessage(messages)
@@ -209,7 +214,9 @@ export const layer = Layer.effect(
cost: { amount: totalSessionCost(messages), currency: "USD" },
},
})
.catch(() => {}),
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
})
+4 -1
View File
@@ -1,3 +1,4 @@
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
@@ -6,6 +7,8 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { ACPProfile } from "@/acp/profile"
const log = Log.create({ service: "acp-command" })
export const AcpCommand = effectCmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
@@ -60,7 +63,7 @@ export const AcpCommand = effectCmd({
return agent.create(conn)
}, stream)
yield* Effect.logInfo("setup connection")
log.info("setup connection")
process.stdin.resume()
yield* Effect.promise(
() =>
+3 -2
View File
@@ -2,6 +2,7 @@ import { LSP } from "@/lsp/lsp"
import { Effect } from "effect"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import * as Log from "@opencode-ai/core/util/log"
import { EOL } from "os"
export const LSPCommand = cmd({
@@ -32,7 +33,7 @@ export const SymbolsCommand = effectCmd({
describe: "search workspace symbols",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
yield* Effect.logInfo("symbols")
using _ = Log.Default.time("symbols")
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}),
@@ -43,7 +44,7 @@ export const DocumentSymbolsCommand = effectCmd({
describe: "get symbols from a document",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
yield* Effect.logInfo("document-symbols")
using _ = Log.Default.time("document-symbols")
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}),

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