mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d03b49a11 | |||
| ac07fe16a0 | |||
| 8a1f4e7727 | |||
| 4511d05c61 | |||
| f63469f04e |
+4
-2
@@ -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
|
||||
|
||||
@@ -94,12 +94,8 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
@@ -526,7 +522,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:",
|
||||
@@ -535,7 +531,7 @@
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -557,6 +553,7 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
@@ -790,34 +787,6 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@solid-primitives/scheduled": "1.5.2",
|
||||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.16.2",
|
||||
@@ -1645,7 +1614,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=="],
|
||||
|
||||
@@ -1807,8 +1776,6 @@
|
||||
|
||||
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
|
||||
|
||||
"@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"],
|
||||
|
||||
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
|
||||
|
||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||
@@ -3369,7 +3336,7 @@
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
@@ -5673,8 +5640,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=="],
|
||||
@@ -5757,8 +5722,6 @@
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/tui/@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
|
||||
@@ -5767,6 +5730,8 @@
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@opentui/solid/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
|
||||
"@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
@@ -5955,8 +5920,6 @@
|
||||
|
||||
"dmg-license/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="],
|
||||
|
||||
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
@@ -6015,12 +5978,10 @@
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"happy-dom/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||
|
||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
@@ -6427,8 +6388,6 @@
|
||||
|
||||
"@jsx-email/cli/vite/rollup": ["rollup@3.30.0", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA=="],
|
||||
|
||||
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-OttgLsPJSYW1b9RGqiYSi7qPdDC3GuD2FE7k5tPe6Iw=",
|
||||
"aarch64-linux": "sha256-VsmW8tTiT3VkTxp5oiYc/maJLSsBMxK6VVUSVshWVT8=",
|
||||
"aarch64-darwin": "sha256-/MaQBX2zoJMtLA8jHt8+uKpIFve/eec3RfcKT3FvXC8=",
|
||||
"x86_64-darwin": "sha256-3ggap9rgR2TvVbYgnXsgkg6Rj1TqNZu32QgTVRdN0f0="
|
||||
"x86_64-linux": "sha256-5DhbOm/gs2mfjmNYdZHkr0ZopgSC2HcGN9/r1noGqhc=",
|
||||
"aarch64-linux": "sha256-0dIKcqKmhrPhRpabnfM20wnqt/AkoCWDMgG9cQZ8P3o=",
|
||||
"aarch64-darwin": "sha256-Sx3G63vORj69u2AOMDfpk6NU7fMgxsbYBh3jnGohNXI=",
|
||||
"x86_64-darwin": "sha256-4g2ydNayqNqWlBeQt90rUI6bY5dMvXP4OmQ6QWaJbuI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./desktop-menu": "./src/desktop-menu.ts",
|
||||
"./updater": "./src/updater.ts",
|
||||
"./wsl/types": "./src/wsl/types.ts",
|
||||
"./vite": "./vite.js",
|
||||
"./index.css": "./src/index.css"
|
||||
|
||||
@@ -70,6 +70,7 @@ function UiI18nBridge(props: ParentProps) {
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
updaterEnabled?: boolean
|
||||
deepLinks?: string[]
|
||||
}
|
||||
api?: {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
const statusLabels = {
|
||||
connected: "mcp.status.connected",
|
||||
@@ -16,7 +19,10 @@ const statusLabels = {
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
@@ -24,7 +30,21 @@ export const DialogSelectMcp: Component = () => {
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
const toggle = useMcpToggle()
|
||||
const toggle = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
}))
|
||||
|
||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||
const totalCount = createMemo(() => items().length)
|
||||
|
||||
@@ -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,7 +0,0 @@
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
|
||||
if (platform === "desktop" && ServerConnection.local(server)) return "native" as const
|
||||
return "server" as const
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
const local = {
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
const remote = {
|
||||
type: "ssh",
|
||||
host: "example.test",
|
||||
http: { url: "http://localhost:4096" },
|
||||
} as const
|
||||
|
||||
describe("directoryPickerKind", () => {
|
||||
test("uses the native picker only for local desktop projects", () => {
|
||||
expect(directoryPickerKind("desktop", local)).toBe("native")
|
||||
expect(directoryPickerKind("desktop", remote)).toBe("server")
|
||||
expect(directoryPickerKind("web", local)).toBe("server")
|
||||
})
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DialogSelectDirectory } from "./dialog-select-directory"
|
||||
import { directoryPickerKind } from "./directory-picker-policy"
|
||||
|
||||
type DirectoryPickerInput = {
|
||||
server: ServerConnection.Any
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
onSelect: (result: string | string[] | null) => void
|
||||
}
|
||||
|
||||
export function useDirectoryPicker() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory {...input} />,
|
||||
() => input.onSelect(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||
import { ACCEPTED_FILE_TYPES, pickAttachmentFiles } from "./prompt-input/files"
|
||||
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
navigatePromptHistory,
|
||||
@@ -73,8 +73,6 @@ import { PromptContextItems } from "./prompt-input/context-items"
|
||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||
import { PromptDragOverlay } from "./prompt-input/drag-overlay"
|
||||
import { promptPlaceholder } from "./prompt-input/placeholder"
|
||||
import { useDirectoryPicker } from "./directory-picker"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { useQueries } from "@tanstack/solid-query"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
@@ -142,7 +140,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const permission = usePermission()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
let editorRef!: HTMLDivElement
|
||||
@@ -471,18 +468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const pick = () => {
|
||||
if (server.isLocal()) {
|
||||
pickAttachmentFiles({
|
||||
picker: platform.openAttachmentPickerDialog,
|
||||
directory: () => sdk.directory,
|
||||
fallback: () => fileInputRef?.click(),
|
||||
onFile: addAttachment,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
})
|
||||
fileInputRef?.click()
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-file").then((module) =>
|
||||
@@ -1108,7 +1094,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const { addAttachment, addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||
editor: () => editorRef,
|
||||
isDialogActive: () => !!dialog.active,
|
||||
setDraggingType: (type) => setStore("draggingType", type),
|
||||
@@ -1399,7 +1385,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
server.projects.touch(worktree)
|
||||
navigate(`/${base64Encode(worktree)}/session`)
|
||||
}
|
||||
const addProject = () => {
|
||||
const addProject = async () => {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
const select = (result: string | string[] | null) => {
|
||||
@@ -1407,10 +1393,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (!directory) return
|
||||
selectProject(directory)
|
||||
}
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
onSelect: select,
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") }))
|
||||
return
|
||||
}
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory onSelect={select} server={conn} />,
|
||||
() => select(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { attachmentMime, pickAttachmentFiles } from "./files"
|
||||
import { attachmentMime } from "./files"
|
||||
import { pasteMode } from "./paste"
|
||||
|
||||
describe("attachmentMime", () => {
|
||||
@@ -24,70 +24,6 @@ describe("attachmentMime", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("pickAttachmentFiles", () => {
|
||||
test("reads the current project directory for every native picker invocation", async () => {
|
||||
const paths: string[] = []
|
||||
const files: File[] = []
|
||||
const file = new File(["hello"], "hello.txt", { type: "text/plain" })
|
||||
let directory = "C:\\Projects\\LoremIpsum"
|
||||
const picker = async (options?: { defaultPath?: string }, onFile?: (file: File) => Promise<unknown>) => {
|
||||
paths.push(options?.defaultPath ?? "")
|
||||
await onFile?.(file)
|
||||
}
|
||||
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
directory = "C:\\Projects\\DolorSit"
|
||||
pickAttachmentFiles({
|
||||
picker,
|
||||
directory: () => directory,
|
||||
fallback: () => undefined,
|
||||
onFile: async (selected) => files.push(selected),
|
||||
onError: () => undefined,
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(files).toEqual([file, file])
|
||||
expect(paths).toEqual(["C:\\Projects\\LoremIpsum", "C:\\Projects\\DolorSit"])
|
||||
})
|
||||
|
||||
test("uses the browser file input when no native picker exists", async () => {
|
||||
let fallback = 0
|
||||
pickAttachmentFiles({
|
||||
directory: () => "/projects/consectetur-adipiscing",
|
||||
fallback: () => {
|
||||
fallback += 1
|
||||
},
|
||||
onFile: async () => undefined,
|
||||
onError: () => undefined,
|
||||
})
|
||||
expect(fallback).toBe(1)
|
||||
})
|
||||
|
||||
test("reports native picker failures without rejecting", async () => {
|
||||
const error = new Error("picker unavailable")
|
||||
const errors: unknown[] = []
|
||||
const handled = Promise.withResolvers<void>()
|
||||
pickAttachmentFiles({
|
||||
picker: async () => Promise.reject(error),
|
||||
directory: () => "C:\\Projects\\LoremIpsum",
|
||||
fallback: () => undefined,
|
||||
onFile: async () => undefined,
|
||||
onError: (cause) => {
|
||||
errors.push(cause)
|
||||
handled.resolve()
|
||||
},
|
||||
})
|
||||
await handled.promise
|
||||
expect(errors).toEqual([error])
|
||||
})
|
||||
})
|
||||
|
||||
describe("pasteMode", () => {
|
||||
test("uses native paste for short single-line text", () => {
|
||||
expect(pasteMode("hello world")).toBe("native")
|
||||
|
||||
@@ -2,38 +2,6 @@ import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-pick
|
||||
|
||||
export { ACCEPTED_FILE_TYPES }
|
||||
|
||||
type AttachmentPicker = (
|
||||
options: {
|
||||
defaultPath?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
},
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
) => Promise<void>
|
||||
|
||||
export function pickAttachmentFiles(input: {
|
||||
picker?: AttachmentPicker
|
||||
directory: () => string
|
||||
fallback: () => void
|
||||
onFile: (file: File) => Promise<unknown>
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
if (!input.picker) {
|
||||
input.fallback()
|
||||
return
|
||||
}
|
||||
void input
|
||||
.picker(
|
||||
{
|
||||
defaultPath: input.directory(),
|
||||
multiple: true,
|
||||
accept: ACCEPTED_FILE_TYPES,
|
||||
},
|
||||
input.onFile,
|
||||
)
|
||||
.catch(input.onError)
|
||||
}
|
||||
|
||||
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||
const IMAGE_EXTS = new Map([
|
||||
["gif", "image/gif"],
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
@@ -7,13 +8,13 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "./updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -90,7 +91,9 @@ export const SettingsGeneral: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
@@ -120,6 +123,58 @@ export const SettingsGeneral: Component = () => {
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
@@ -668,6 +723,19 @@ export const SettingsGeneral: Component = () => {
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
@@ -684,8 +752,10 @@ export const SettingsGeneral: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<Button size="small" variant="secondary" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
@@ -7,13 +8,13 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -92,7 +93,9 @@ export const SettingsGeneralV2: Component = () => {
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
})
|
||||
|
||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||
const dir = createMemo(() => decode64(params.dir))
|
||||
@@ -122,6 +125,58 @@ export const SettingsGeneralV2: Component = () => {
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const check = () => {
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
|
||||
void platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
if (!result.updateAvailable) {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const actions = platform.updateAndRestart
|
||||
? [
|
||||
{
|
||||
label: language.t("toast.update.action.installRestart"),
|
||||
onClick: async () => {
|
||||
await platform.updateAndRestart!()
|
||||
},
|
||||
},
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: language.t("toast.update.action.notYet"),
|
||||
onClick: "dismiss" as const,
|
||||
},
|
||||
]
|
||||
|
||||
showToast({
|
||||
persistent: true,
|
||||
icon: "download",
|
||||
title: language.t("toast.update.title"),
|
||||
description: language.t("toast.update.description", { version: result.version ?? "" }),
|
||||
actions,
|
||||
})
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
})
|
||||
.finally(() => setStore("checking", false))
|
||||
}
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const serverSync = useServerSync()
|
||||
@@ -685,6 +740,19 @@ export const SettingsGeneralV2: Component = () => {
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.updates.row.startup.title")}
|
||||
description={language.t("settings.updates.row.startup.description")}
|
||||
>
|
||||
<div data-action="settings-updates-startup">
|
||||
<Switch
|
||||
checked={settings.updates.startup()}
|
||||
disabled={!platform.checkUpdate}
|
||||
onChange={(checked) => settings.updates.setStartup(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.releaseNotes.title")}
|
||||
description={language.t("settings.general.row.releaseNotes.description")}
|
||||
@@ -701,8 +769,10 @@ export const SettingsGeneralV2: Component = () => {
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={store.checking || !platform.checkUpdate} onClick={check}>
|
||||
{store.checking
|
||||
? language.t("settings.updates.action.checking")
|
||||
: language.t("settings.updates.action.checkNow")}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
@@ -10,12 +11,14 @@ import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { type ServerHealth } from "@/utils/server-health"
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -96,6 +99,37 @@ const useDefaultServerKey = (
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const queryOptions = useQueryOptions()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
if (status?.status === "connected") {
|
||||
await sdk.client.mcp.disconnect({ name })
|
||||
return
|
||||
}
|
||||
if (status?.status === "needs_auth") {
|
||||
await sdk.client.mcp.auth.authenticate({ name })
|
||||
return
|
||||
}
|
||||
await sdk.client.mcp.connect({ name })
|
||||
},
|
||||
onSuccess: () => queryClient.refetchQueries(queryOptions.mcp(pathKey(sync.directory))),
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
type ServerStatusState = {
|
||||
servers: () => ServerStatusItem[]
|
||||
defaultKey: () => ServerConnection.Key | undefined
|
||||
@@ -277,7 +311,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
dialogRun += 1
|
||||
})
|
||||
const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
|
||||
const toggleMcp = useMcpToggle()
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { updaterAction } from "./updater-action"
|
||||
|
||||
describe("updaterAction", () => {
|
||||
test("disables update actions when the platform has no updater", () => {
|
||||
expect(updaterAction(undefined)).toEqual({ label: "settings.updates.action.checkNow" })
|
||||
})
|
||||
|
||||
test("projects updater transitions into one settings action", () => {
|
||||
expect(updaterAction({ status: "idle" })).toEqual({
|
||||
label: "settings.updates.action.checkNow",
|
||||
run: "check",
|
||||
})
|
||||
expect(updaterAction({ status: "checking" })).toEqual({ label: "settings.updates.action.checking" })
|
||||
expect(updaterAction({ status: "downloading", version: "2.0.0" })).toEqual({
|
||||
label: "settings.updates.action.downloading",
|
||||
})
|
||||
expect(updaterAction({ status: "ready", version: "2.0.0" })).toEqual({
|
||||
label: "toast.update.action.installRestart",
|
||||
run: "install",
|
||||
})
|
||||
expect(updaterAction({ status: "installing", version: "2.0.0" })).toEqual({
|
||||
label: "settings.updates.action.installing",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import type { UpdaterState } from "@/updater"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function updaterAction(state: UpdaterState | undefined) {
|
||||
if (!state) return { label: "settings.updates.action.checkNow" as const }
|
||||
switch (state.status) {
|
||||
case "checking":
|
||||
return { label: "settings.updates.action.checking" as const }
|
||||
case "downloading":
|
||||
return { label: "settings.updates.action.downloading" as const }
|
||||
case "ready":
|
||||
return { label: "toast.update.action.installRestart" as const, run: "install" as const }
|
||||
case "installing":
|
||||
return { label: "settings.updates.action.installing" as const }
|
||||
case "disabled":
|
||||
return { label: "settings.updates.action.checkNow" as const }
|
||||
default:
|
||||
return { label: "settings.updates.action.checkNow" as const, run: "check" as const }
|
||||
}
|
||||
}
|
||||
|
||||
export function useUpdaterAction() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const action = createMemo(() => updaterAction(platform.updater?.state()))
|
||||
|
||||
return {
|
||||
action,
|
||||
async run() {
|
||||
const run = action().run
|
||||
if (run === "install") return platform.updater?.install()
|
||||
if (run !== "check") return
|
||||
|
||||
const state = await platform.updater?.check()
|
||||
if (state?.status === "up-to-date") {
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.updates.toast.latest.title"),
|
||||
description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }),
|
||||
})
|
||||
}
|
||||
if (state?.status === "error") {
|
||||
showToast({ title: language.t("common.requestFailed"), description: state.message })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -609,9 +609,6 @@ export const createDirSyncContext = (
|
||||
)
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
toggle: (name: string) => serverSync.mcp.toggle(directory, name),
|
||||
},
|
||||
absolute,
|
||||
get directory() {
|
||||
return current()[0].path.directory
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { toggleMcp } from "./mcp"
|
||||
|
||||
describe("toggleMcp", () => {
|
||||
test("runs the status action before refreshing the owning query", async () => {
|
||||
const calls: string[] = []
|
||||
const input = (status: "connected" | "needs_auth" | "disabled") => ({
|
||||
status,
|
||||
connect: async () => {
|
||||
calls.push("connect")
|
||||
},
|
||||
disconnect: async () => {
|
||||
calls.push("disconnect")
|
||||
},
|
||||
authenticate: async () => {
|
||||
calls.push("authenticate")
|
||||
},
|
||||
refresh: async () => {
|
||||
calls.push("refresh")
|
||||
},
|
||||
})
|
||||
|
||||
await toggleMcp(input("connected"))
|
||||
expect(calls).toEqual(["disconnect", "refresh"])
|
||||
|
||||
calls.length = 0
|
||||
await toggleMcp(input("needs_auth"))
|
||||
expect(calls).toEqual(["authenticate", "refresh"])
|
||||
|
||||
calls.length = 0
|
||||
await toggleMcp(input("disabled"))
|
||||
expect(calls).toEqual(["connect", "refresh"])
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { McpStatus } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export async function toggleMcp(input: {
|
||||
status: McpStatus["status"]
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => Promise<void>
|
||||
authenticate: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
}) {
|
||||
await {
|
||||
connected: input.disconnect,
|
||||
needs_auth: input.authenticate,
|
||||
disabled: input.connect,
|
||||
failed: input.connect,
|
||||
needs_client_registration: input.connect,
|
||||
}[input.status]()
|
||||
await input.refresh()
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function useMcpToggle() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync.mcp.toggle,
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -4,18 +4,12 @@ import type { Accessor } from "solid-js"
|
||||
import type { DesktopMenuAction } from "../desktop-menu"
|
||||
import { ServerConnection } from "./server"
|
||||
import type { WslServersPlatform } from "../wsl/types"
|
||||
import type { UpdaterPlatform } from "../updater"
|
||||
|
||||
type PickerPaths = string | string[] | null
|
||||
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
|
||||
type OpenAttachmentPickerOptions = {
|
||||
title?: string
|
||||
multiple?: boolean
|
||||
accept?: string[]
|
||||
extensions?: string[]
|
||||
defaultPath?: string
|
||||
}
|
||||
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
|
||||
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type UpdateInfo = { updateAvailable: boolean; version?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
@@ -27,7 +21,13 @@ export type FatalRendererErrorLog = {
|
||||
os?: DesktopOS
|
||||
}
|
||||
|
||||
type PlatformBase = {
|
||||
export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: PlatformName
|
||||
|
||||
/** Desktop OS (Tauri only) */
|
||||
os?: DesktopOS
|
||||
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
@@ -49,20 +49,23 @@ type PlatformBase = {
|
||||
/** Send a system notification (optional deep link) */
|
||||
notify(title: string, description?: string, href?: string): Promise<void>
|
||||
|
||||
/** Open a native attachment picker and read selected files sequentially (desktop only) */
|
||||
openAttachmentPickerDialog?(
|
||||
opts: OpenAttachmentPickerOptions,
|
||||
onFile: (file: File) => Promise<unknown>,
|
||||
): Promise<void>
|
||||
/** Open directory picker dialog (native on Tauri, server-backed on web) */
|
||||
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Open a native save file picker dialog (desktop only) */
|
||||
/** Open native file picker dialog (Tauri only) */
|
||||
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
|
||||
|
||||
/** Save file picker dialog (Tauri only) */
|
||||
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
|
||||
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
/** Check for a downloadable desktop update */
|
||||
checkUpdate?(): Promise<UpdateInfo>
|
||||
|
||||
/** Install the downloaded update using the platform restart flow */
|
||||
updateAndRestart?(): Promise<void>
|
||||
|
||||
/** Fetch override */
|
||||
fetch?: typeof fetch
|
||||
@@ -110,16 +113,6 @@ type PlatformBase = {
|
||||
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
(
|
||||
| { platform: "web"; os?: never }
|
||||
| {
|
||||
platform: "desktop"
|
||||
os?: DesktopOS
|
||||
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
}
|
||||
)
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
|
||||
@@ -36,7 +36,6 @@ import { ServerConnection, useServer } from "./server"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -482,28 +481,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
|
||||
todo: {
|
||||
set: setSessionTodo,
|
||||
},
|
||||
mcp: {
|
||||
toggle: async (directory: string, name: string) => {
|
||||
const key = directoryKey(directory)
|
||||
const sdk = sdkFor(key)
|
||||
const status = children.child(key, { bootstrap: false })[0].mcp[name].status
|
||||
await toggleMcp({
|
||||
status,
|
||||
connect: async () => {
|
||||
await sdk.mcp.connect({ name })
|
||||
},
|
||||
disconnect: async () => {
|
||||
await sdk.mcp.disconnect({ name })
|
||||
},
|
||||
authenticate: async () => {
|
||||
await sdk.mcp.auth.authenticate({ name })
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ export interface Settings {
|
||||
showCustomAgents: boolean
|
||||
newLayoutDesigns?: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
mono: string
|
||||
@@ -119,6 +122,9 @@ const defaultSettings: Settings = {
|
||||
showSessionProgressBar: true,
|
||||
showCustomAgents: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
mono: "",
|
||||
@@ -244,6 +250,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
setStartup(value: boolean) {
|
||||
setStore("updates", "startup", value)
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
|
||||
@@ -866,8 +866,6 @@ export const dict = {
|
||||
"settings.updates.row.check.description": "Manually check for updates and install if available",
|
||||
"settings.updates.action.checkNow": "Check now",
|
||||
"settings.updates.action.checking": "Checking...",
|
||||
"settings.updates.action.downloading": "Downloading...",
|
||||
"settings.updates.action.installing": "Installing...",
|
||||
"settings.updates.toast.latest.title": "You're up to date",
|
||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||
"sound.option.none": "None",
|
||||
|
||||
@@ -4,7 +4,6 @@ export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { useWslServers } from "./wsl/context"
|
||||
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./updater"
|
||||
export {
|
||||
type WslDistroProbe,
|
||||
type WslInstalledDistro,
|
||||
|
||||
@@ -225,6 +225,8 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
const formattedError = () => formatError(props.error, language.t)
|
||||
let recordedFatalError: Promise<void> | undefined
|
||||
const [store, setStore] = createStore({
|
||||
checking: false,
|
||||
version: undefined as string | undefined,
|
||||
actionError: undefined as string | undefined,
|
||||
})
|
||||
|
||||
@@ -245,25 +247,32 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
const state = await platform.updater?.check()
|
||||
setStore("actionError", state?.status === "error" ? state.message : undefined)
|
||||
if (!platform.checkUpdate) return
|
||||
setStore("checking", true)
|
||||
await platform
|
||||
.checkUpdate()
|
||||
.then((result) => {
|
||||
setStore("actionError", undefined)
|
||||
if (result.updateAvailable && result.version) setStore("version", result.version)
|
||||
})
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
.finally(() => {
|
||||
setStore("checking", false)
|
||||
})
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
await platform.updater
|
||||
?.install()
|
||||
if (!platform.updateAndRestart) return
|
||||
await platform
|
||||
.updateAndRestart()
|
||||
.then(() => setStore("actionError", undefined))
|
||||
.catch((err) => {
|
||||
setStore("actionError", formatError(err, language.t))
|
||||
})
|
||||
}
|
||||
|
||||
const updateVersion = () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
}
|
||||
|
||||
async function exportDebugLogs() {
|
||||
const exportLogs = platform.exportDebugLogs
|
||||
if (!exportLogs) return
|
||||
@@ -318,27 +327,20 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<Show when={platform.updater}>
|
||||
<Show when={platform.checkUpdate}>
|
||||
<Show
|
||||
when={updateVersion()}
|
||||
when={store.version}
|
||||
fallback={
|
||||
<Button
|
||||
size="large"
|
||||
variant="ghost"
|
||||
onClick={checkForUpdates}
|
||||
disabled={["checking", "downloading", "installing"].includes(platform.updater?.state().status ?? "")}
|
||||
>
|
||||
{platform.updater?.state().status === "checking"
|
||||
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
|
||||
{store.checking
|
||||
? language.t("error.page.action.checking")
|
||||
: language.t("error.page.action.checkUpdates")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{(version) => (
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
{language.t("error.page.action.updateTo", { version: version() })}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="large" onClick={installUpdate}>
|
||||
{language.t("error.page.action.updateTo", { version: store.version ?? "" })}
|
||||
</Button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
@@ -19,7 +18,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { DateTime } from "luxon"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
@@ -126,7 +125,6 @@ function HomeDesign() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
@@ -316,19 +314,26 @@ function HomeDesign() {
|
||||
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
|
||||
}
|
||||
|
||||
function chooseProject(conn: ServerConnection.Any) {
|
||||
async function chooseProject(conn: ServerConnection.Any) {
|
||||
function resolve(result: string | string[] | null) {
|
||||
addProjects(conn, homeProjectDirectories(result))
|
||||
}
|
||||
|
||||
const server = global.createServerCtx(conn)
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
@@ -385,7 +390,7 @@ function HomeDesign() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView class="mt-3 min-h-0 flex-1">
|
||||
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="pt-3 flex flex-col gap-6">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
@@ -427,7 +432,7 @@ function HomeDesign() {
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1096,7 +1101,6 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||
function LegacyHome() {
|
||||
const sync = useServerSync()
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const dialog = useDialog()
|
||||
const navigate = useNavigate()
|
||||
const global = useGlobal()
|
||||
@@ -1124,7 +1128,7 @@ function LegacyHome() {
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
async function chooseProject() {
|
||||
const s = server.current
|
||||
if (!s) return
|
||||
|
||||
@@ -1138,12 +1142,18 @@ function LegacyHome() {
|
||||
}
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: s,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
dialog.show(
|
||||
() => <DialogSelectDirectory multiple={true} onSelect={resolve} server={s} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -64,7 +65,6 @@ import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
} from "./layout/sidebar-workspace"
|
||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||
import { SidebarContent } from "./layout/sidebar-shell"
|
||||
import { runUpdateAndRestart } from "./layout/update"
|
||||
|
||||
export default function Layout(props: ParentProps) {
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -118,7 +119,6 @@ export default function Layout(props: ParentProps) {
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
@@ -168,15 +168,28 @@ export default function Layout(props: ParentProps) {
|
||||
peeked: false,
|
||||
})
|
||||
|
||||
const [update, setUpdate] = createStore({
|
||||
installing: false,
|
||||
})
|
||||
const updateQuery = useQuery(() => ({
|
||||
queryKey: ["desktop", "update"] as const,
|
||||
enabled: () =>
|
||||
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
|
||||
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
|
||||
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
|
||||
}))
|
||||
const updateVersion = () => {
|
||||
const state = platform.updater?.state()
|
||||
if (state?.status !== "ready") return
|
||||
return state.version
|
||||
if (!settings.ready()) return
|
||||
if (!settings.updates.startup()) return
|
||||
if (!updateQuery.data?.updateAvailable) return
|
||||
return updateQuery.data.version ?? ""
|
||||
}
|
||||
const installUpdate = () => {
|
||||
runUpdateAndRestart(platform.updateAndRestart, (installing) => setUpdate("installing", installing))
|
||||
}
|
||||
const installUpdate = () => void platform.updater?.install()
|
||||
const titlebarUpdate: TitlebarUpdate = {
|
||||
version: updateVersion,
|
||||
installing: () => platform.updater?.state().status === "installing",
|
||||
installing: () => update.installing,
|
||||
install: installUpdate,
|
||||
}
|
||||
|
||||
@@ -1459,7 +1472,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
function chooseProject() {
|
||||
async function chooseProject() {
|
||||
const conn = server.current
|
||||
if (!conn) return
|
||||
function resolve(result: string | string[] | null) {
|
||||
@@ -1473,12 +1486,22 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
}
|
||||
|
||||
pickDirectory({
|
||||
server: conn,
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
onSelect: resolve,
|
||||
})
|
||||
if (platform.openDirectoryPickerDialog && server.isLocal()) {
|
||||
const result = await platform.openDirectoryPickerDialog?.({
|
||||
title: language.t("command.project.open"),
|
||||
multiple: true,
|
||||
})
|
||||
resolve(result)
|
||||
} else {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-directory").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} server={conn} />,
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { runUpdateAndRestart } from "./update"
|
||||
|
||||
describe("runUpdateAndRestart", () => {
|
||||
test("clears the installing state when restart resolves without exiting", async () => {
|
||||
const states: boolean[] = []
|
||||
await new Promise<void>((resolve) => {
|
||||
runUpdateAndRestart(
|
||||
async () => {},
|
||||
(installing) => {
|
||||
states.push(installing)
|
||||
if (states.length === 2) resolve()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
expect(states).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export function runUpdateAndRestart(
|
||||
updateAndRestart: (() => Promise<void>) | undefined,
|
||||
setInstalling: (installing: boolean) => void,
|
||||
) {
|
||||
if (!updateAndRestart) return
|
||||
setInstalling(true)
|
||||
void updateAndRestart()
|
||||
.catch(() => undefined)
|
||||
.finally(() => setInstalling(false))
|
||||
}
|
||||
@@ -547,7 +547,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.agent.cycle.description"),
|
||||
keybind: "mod+.",
|
||||
slash: "agent",
|
||||
disabled: desktopV2() && !settings.general.showCustomAgents(),
|
||||
onSelect: () => local.agent.move(1),
|
||||
}),
|
||||
agentCommand({
|
||||
@@ -555,7 +554,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.agent.cycle.reverse"),
|
||||
description: language.t("command.agent.cycle.reverse.description"),
|
||||
keybind: "shift+mod+.",
|
||||
disabled: desktopV2() && !settings.general.showCustomAgents(),
|
||||
onSelect: () => local.agent.move(-1),
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
|
||||
export type UpdaterState =
|
||||
| { status: "disabled" }
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "downloading"; version: string; percent?: number }
|
||||
| { status: "ready"; version: string }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "installing"; version: string }
|
||||
| { status: "error"; message: string }
|
||||
|
||||
export type UpdaterPlatform = {
|
||||
state: Accessor<UpdaterState>
|
||||
check(): Promise<UpdaterState>
|
||||
install(): Promise<void>
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
@@ -20,12 +20,8 @@
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import pkg from "../package.json"
|
||||
import { modelsData } from "./generate"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -17,9 +13,7 @@ await rm("dist", { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -49,12 +43,6 @@ const targets = singleFlag
|
||||
})
|
||||
: allTargets
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
|
||||
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
|
||||
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
|
||||
|
||||
for (const item of targets) {
|
||||
const target = [
|
||||
binary,
|
||||
@@ -68,9 +56,8 @@ for (const item of targets) {
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts", parserWorker],
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@@ -92,11 +79,6 @@ for (const item of targets) {
|
||||
OPENCODE_MODELS_DEV: modelsData,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
OTUI_TREE_SITTER_WORKER_PATH:
|
||||
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
|
||||
path.relative(dir, parserWorker).replaceAll("\\", "/") +
|
||||
'"',
|
||||
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Effect } from "effect"
|
||||
import { Daemon } from "../../services/daemon"
|
||||
|
||||
export default Runtime.handler(Commands, () =>
|
||||
Effect.gen(function* () {
|
||||
const daemon = yield* Daemon.Service
|
||||
const transport = yield* daemon.transport()
|
||||
const { runTui } = yield* Effect.promise(() => import("../../tui"))
|
||||
yield* runTui(transport)
|
||||
}),
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
|
||||
import { Context, Layer, Option } from "effect"
|
||||
import * as Effect from "effect/Effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
@@ -35,7 +34,6 @@ function bind(hostname: string, port: number, password: string) {
|
||||
return Layer.build(
|
||||
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
|
||||
Layer.provide(PermissionSaved.defaultLayer),
|
||||
),
|
||||
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
|
||||
}
|
||||
|
||||
@@ -60,19 +60,19 @@ export function run(commands: Spec.Any, handlers: ReadonlyArray<LazyHandler>, op
|
||||
}
|
||||
|
||||
function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): ProvidedCommand {
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
const spec = handler
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
const spec: Command.Command.Any = Object.keys(node.commands).length
|
||||
? (node.spec as Command.Command<string, unknown>).pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
)
|
||||
: node.spec
|
||||
if (!Object.keys(node.commands).length) return spec as ProvidedCommand
|
||||
const handler = handlers.find((handler) => handler.spec === node.spec)
|
||||
if (!handler) return spec as ProvidedCommand
|
||||
return spec.pipe(
|
||||
Command.withSubcommands(Object.values(node.commands).map((child) => provide(child, handlers))),
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
}),
|
||||
),
|
||||
) as ProvidedCommand
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Runtime } from "./framework/runtime"
|
||||
import { Daemon } from "./services/daemon"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
debug: {
|
||||
agents: () => import("./commands/handlers/debug/agents"),
|
||||
},
|
||||
|
||||
@@ -5,12 +5,10 @@ import { ServerAuth } from "@opencode-ai/server/auth"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schedule, Schema, Scope } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import path from "path"
|
||||
|
||||
export interface Interface {
|
||||
readonly client: () => Effect.Effect<ReturnType<typeof createOpencodeClient>, unknown>
|
||||
readonly transport: () => Effect.Effect<{ url: string; headers: RequestInit["headers"] }, unknown>
|
||||
readonly start: () => Effect.Effect<string, Error>
|
||||
readonly status: () => Effect.Effect<string | undefined>
|
||||
readonly stop: () => Effect.Effect<void, unknown>
|
||||
@@ -110,21 +108,16 @@ export const layer = Layer.effect(
|
||||
const start = Effect.fn("cli.daemon.start")(function* () {
|
||||
const existing = yield* healthy().pipe(Effect.option)
|
||||
const found = Option.getOrUndefined(existing)
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
if (found?.version === InstallationVersion && compiled) return found.url
|
||||
if (found?.version === InstallationVersion) return found.url
|
||||
if (found) yield* stopProcess(found).pipe(Effect.ignore)
|
||||
|
||||
const entrypoint = compiled ? undefined : process.argv[1]
|
||||
if (!compiled && entrypoint === undefined)
|
||||
return yield* Effect.fail(new Error("Failed to resolve CLI entrypoint"))
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
spawn(process.execPath, [...(entrypoint ? [entrypoint] : []), "serve", "--register"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}).unref()
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
yield* Effect.sync(() => {
|
||||
const compiled = path.basename(process.execPath).replace(/\.exe$/, "") !== "bun"
|
||||
Bun.spawn([process.execPath, ...(compiled ? [] : [Bun.main]), "serve", "--register"], {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}).unref()
|
||||
})
|
||||
|
||||
return yield* compatible().pipe(
|
||||
@@ -134,13 +127,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const transport = Effect.fn("cli.daemon.transport")(function* () {
|
||||
return { url: yield* start(), headers: ServerAuth.headers({ password: yield* password() }) }
|
||||
})
|
||||
|
||||
const client = Effect.fn("cli.daemon.client")(function* () {
|
||||
const connection = yield* transport()
|
||||
return createOpencodeClient({ baseUrl: connection.url, headers: connection.headers })
|
||||
return yield* createClient(yield* start())
|
||||
})
|
||||
|
||||
const status = Effect.fn("cli.daemon.status")(function* () {
|
||||
@@ -185,7 +173,7 @@ export const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({ client, transport, start, status, stop, password, register })
|
||||
return Service.of({ client, start, status, stop, password, register })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { TuiConfig } from "@opencode-ai/tui/config"
|
||||
import { Effect } from "effect"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
|
||||
const config = TuiConfig.resolve({}, { terminalSuspend: false })
|
||||
return run({
|
||||
...transport,
|
||||
args: {},
|
||||
config,
|
||||
fetch: gracefulFetch,
|
||||
pluginHost: {
|
||||
async start() {},
|
||||
async dispose() {},
|
||||
},
|
||||
}).pipe(Effect.provide(Global.defaultLayer))
|
||||
}
|
||||
|
||||
const legacyDefaults: Record<string, unknown> = {
|
||||
"/config/providers": { providers: [], default: {} },
|
||||
"/provider": { all: [], default: {}, connected: [] },
|
||||
"/agent": [],
|
||||
"/config": {},
|
||||
}
|
||||
|
||||
const gracefulFetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const response = await fetch(input, init)
|
||||
if (response.status !== 404) return response
|
||||
const fallback = legacyDefaults[new URL(input instanceof Request ? input.url : input).pathname]
|
||||
if (fallback === undefined) return response
|
||||
return Response.json(fallback)
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
@@ -2,8 +2,6 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "@opentui/solid",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -63,7 +63,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
@@ -113,7 +113,10 @@ export const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
update: state.update,
|
||||
update: Effect.fn("AgentV2.update")(function* (update) {
|
||||
const transform = yield* state.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
|
||||
@@ -203,7 +203,7 @@ export const layer = Layer.effect(
|
||||
event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID,
|
||||
),
|
||||
Stream.runForEach((event) =>
|
||||
state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -27,7 +27,6 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@@ -55,7 +54,6 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
update: state.update,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
return state.get().commands.get(name)
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
@@ -125,6 +124,5 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -237,10 +237,6 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Ripgrep.Service
|
||||
// and does not await the scan; the native background scan starts as soon as
|
||||
// the picker exists. The `wait` gate dedupes concurrent creation.
|
||||
const acquire = Effect.fn("Search.acquire")(function* (cwd: string) {
|
||||
// The opencode test runtime owns an isolated XDG tree that Windows must
|
||||
// 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 })
|
||||
@@ -266,9 +262,9 @@ 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"),
|
||||
// fff 0.9.3 keeps its process-global log file open until exit, so
|
||||
// disable it in tests to let Windows remove the isolated XDG tree.
|
||||
logFilePath: process.env.NODE_ENV === "test" ? undefined : 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
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
export * as Image from "./image"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FileSystem } from "./filesystem"
|
||||
|
||||
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
|
||||
resource: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Image could not be decoded: ${this.resource}`
|
||||
}
|
||||
}
|
||||
|
||||
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeError", {
|
||||
resource: Schema.String,
|
||||
width: Schema.Number,
|
||||
height: Schema.Number,
|
||||
bytes: Schema.Number,
|
||||
maxWidth: Schema.Number,
|
||||
maxHeight: Schema.Number,
|
||||
maxBytes: Schema.Number,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes`
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.BinaryContent,
|
||||
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const loadAdapter = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("./image/photon"),
|
||||
catch: () => new ResizerUnavailableError(),
|
||||
}).pipe(Effect.flatMap((adapter) => adapter.make)),
|
||||
)
|
||||
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
),
|
||||
)
|
||||
const normalize = yield* loadAdapter
|
||||
return yield* normalize(resource, content, {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? 2_000,
|
||||
maxHeight: image.max_height ?? 2_000,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
|
||||
})
|
||||
})
|
||||
return Service.of({ normalize })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))
|
||||
@@ -1,94 +0,0 @@
|
||||
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
|
||||
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError } from "../image"
|
||||
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
|
||||
const loadPhoton = yield* Effect.cached(
|
||||
Effect.tryPromise({
|
||||
try: () => import("@silvia-odwyer/photon-node"),
|
||||
catch: () => new ResizerUnavailableError(),
|
||||
}),
|
||||
)
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: string,
|
||||
content: FileSystem.BinaryContent,
|
||||
limits: {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
},
|
||||
) {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
|
||||
catch: () => new DecodeError({ resource }),
|
||||
})
|
||||
try {
|
||||
const width = decoded.get_width()
|
||||
const height = decoded.get_height()
|
||||
const bytes = Buffer.byteLength(content.content, "utf-8")
|
||||
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content
|
||||
if (!limits.autoResize)
|
||||
return yield* new SizeError({
|
||||
resource,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
maxWidth: limits.maxWidth,
|
||||
maxHeight: limits.maxHeight,
|
||||
maxBytes: limits.maxBase64Bytes,
|
||||
})
|
||||
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
|
||||
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||
const previous = acc.at(-1) ?? {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
const next =
|
||||
acc.length === 0
|
||||
? previous
|
||||
: {
|
||||
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||
}
|
||||
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
|
||||
}, [])
|
||||
for (const size of sizes) {
|
||||
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||
try {
|
||||
const encoders: Array<readonly [mime: string, encode: () => Uint8Array]> = [
|
||||
["image/png", () => resized.get_bytes()],
|
||||
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
|
||||
]
|
||||
for (const [mime, encode] of encoders) {
|
||||
const candidate = Buffer.from(encode()).toString("base64")
|
||||
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
|
||||
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
|
||||
}
|
||||
} finally {
|
||||
resized.free()
|
||||
}
|
||||
}
|
||||
return yield* new SizeError({
|
||||
resource,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
maxWidth: limits.maxWidth,
|
||||
maxHeight: limits.maxHeight,
|
||||
maxBytes: limits.maxBase64Bytes,
|
||||
})
|
||||
} finally {
|
||||
decoded.free()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard(
|
||||
return files.filter((file): file is File => file !== undefined)
|
||||
})
|
||||
|
||||
yield* registry.register({
|
||||
yield* registry.contribute({
|
||||
key,
|
||||
load: observe().pipe(
|
||||
Effect.map((files) =>
|
||||
|
||||
@@ -28,7 +28,6 @@ import { Pty } from "./pty"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { SkillGuidance } from "./skill/guidance"
|
||||
import { BuiltInTools } from "./tool/builtins"
|
||||
import { Image } from "./image"
|
||||
import { ToolRegistry } from "./tool/registry"
|
||||
import { ApplicationTools } from "./tool/application-tools"
|
||||
import { ToolOutputStore } from "./tool-output-store"
|
||||
@@ -72,7 +71,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Layer.provide(base),
|
||||
)
|
||||
const services = Layer.mergeAll(base, resources, permissionsAndTools)
|
||||
const image = Image.layer.pipe(Layer.provide(services))
|
||||
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
|
||||
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
|
||||
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
|
||||
@@ -85,7 +83,6 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Layer.provide(resources),
|
||||
Layer.provide(todos),
|
||||
Layer.provide(questions),
|
||||
Layer.provide(image),
|
||||
)
|
||||
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
@@ -93,18 +90,9 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Layer.provide(model),
|
||||
Layer.provide(skillGuidance),
|
||||
)
|
||||
return Layer.mergeAll(
|
||||
services,
|
||||
image,
|
||||
mutation,
|
||||
searches,
|
||||
resources,
|
||||
todos,
|
||||
questions,
|
||||
model,
|
||||
runner,
|
||||
builtInTools,
|
||||
).pipe(Layer.fresh)
|
||||
return Layer.mergeAll(services, mutation, searches, resources, todos, questions, model, runner, builtInTools).pipe(
|
||||
Layer.fresh,
|
||||
)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
dependencies: [
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Tool } from "./tool"
|
||||
|
||||
export interface Interface {
|
||||
readonly sessions: Session.Interface
|
||||
readonly tools: Tool.Interface
|
||||
readonly tools: Tool.Service
|
||||
}
|
||||
|
||||
/** Intentional public native API for Effect applications embedding OpenCode. */
|
||||
@@ -88,7 +88,7 @@ export const layer = Layer.effect(
|
||||
const tools = yield* ApplicationTools.Service
|
||||
const validation = yield* SessionModelValidation
|
||||
return Service.of({
|
||||
tools: { register: tools.register },
|
||||
tools: { attach: tools.attach },
|
||||
sessions: {
|
||||
create: (input) =>
|
||||
sessions.create({
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
export * as Tool from "./tool"
|
||||
|
||||
import { Effect, Scope } from "effect"
|
||||
import type { AnyTool, RegistrationError } from "../tool/tool"
|
||||
import type { NativeTool } from "../tool/native"
|
||||
|
||||
export { Failure, RegistrationError, make } from "../tool/tool"
|
||||
export type { AnyTool, Content, Context, Definition } from "../tool/tool"
|
||||
export { Failure, make } from "../tool/native"
|
||||
export type { Any, Content, Context, Executable } from "../tool/native"
|
||||
|
||||
export interface Interface {
|
||||
export interface Service {
|
||||
/**
|
||||
* Register same-process tools on this OpenCode instance for the current Scope.
|
||||
* Attach same-process tools to this OpenCode instance for the current Scope.
|
||||
* Location tools with the same name take precedence where they are installed.
|
||||
* Closing the Scope removes the tools immediately, so calls that have not
|
||||
* started settling may fail because the tool is no longer available.
|
||||
*/
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { logFailure } from "./session/logging"
|
||||
import { MessageDecodeError } from "./session/error"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionInput } from "./session/input"
|
||||
@@ -178,7 +177,10 @@ export const layer = Layer.effect(
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: logFailure("Failed to wake Session", admitted.sessionID, cause),
|
||||
: Effect.logError("Failed to wake Session").pipe(
|
||||
Effect.annotateLogs("sessionID", admitted.sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
@@ -425,12 +427,20 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const DefaultDatabase = Database.defaultLayer
|
||||
const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase))
|
||||
const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase))
|
||||
const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
DefaultDatabase,
|
||||
DefaultEvents,
|
||||
DefaultProjector,
|
||||
DefaultStore,
|
||||
SessionExecution.noopLayer,
|
||||
ProjectV2.defaultLayer,
|
||||
),
|
||||
),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SessionRunner } from "../runner"
|
||||
import { SessionSchema } from "../schema"
|
||||
import { SessionStore } from "../store"
|
||||
import { SessionExecution } from "../execution"
|
||||
import { logFailure } from "../logging"
|
||||
|
||||
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
|
||||
export const layer = Layer.effect(
|
||||
@@ -21,7 +20,11 @@ export const layer = Layer.effect(
|
||||
Effect.provide(locations.get(session.location)),
|
||||
)
|
||||
}),
|
||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
})
|
||||
|
||||
return SessionExecution.Service.of({
|
||||
@@ -31,5 +34,3 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer))
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Cause, Effect } from "effect"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
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", sessionID))
|
||||
@@ -2,7 +2,6 @@ export * as SessionRunCoordinator from "./run-coordinator"
|
||||
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
|
||||
import { SessionRunner } from "./runner"
|
||||
import { logFailure } from "./logging"
|
||||
import { SessionSchema } from "./schema"
|
||||
|
||||
export type Mode = "run" | "wake"
|
||||
@@ -276,7 +275,11 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((runner) =>
|
||||
make<SessionSchema.ID, void, SessionRunner.RunError>({
|
||||
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
|
||||
onFailure: (sessionID, cause) => logFailure("Failed to drain Session", sessionID, cause),
|
||||
onFailure: (sessionID, cause) =>
|
||||
Effect.logError("Failed to drain Session").pipe(
|
||||
Effect.annotateLogs("sessionID", sessionID),
|
||||
Effect.annotateLogs("cause", cause),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.map(Service.of),
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error"
|
||||
import { SessionRunnerModel } from "./model"
|
||||
import type { SystemContext } from "../../system-context/index"
|
||||
import type { SessionContextEpoch } from "../context-epoch"
|
||||
import type { ToolOutputStore } from "../../tool-output-store"
|
||||
|
||||
export class StepLimitExceededError extends Schema.TaggedErrorClass<StepLimitExceededError>()(
|
||||
"SessionRunner.StepLimitExceededError",
|
||||
@@ -25,7 +24,6 @@ export type RunError =
|
||||
| StepLimitExceededError
|
||||
| SystemContext.InitializationBlocked
|
||||
| SessionContextEpoch.AgentReplacementBlocked
|
||||
| ToolOutputStore.Error
|
||||
|
||||
/** Runs one local continuation from already-recorded Session history. */
|
||||
export interface Interface {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { SystemContext } from "../../system-context/index"
|
||||
import { SystemContextRegistry } from "../../system-context/registry"
|
||||
import { SkillGuidance } from "../../skill/guidance"
|
||||
import { ToolRegistry } from "../../tool/registry"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { SessionContextEpoch } from "../context-epoch"
|
||||
import { SessionCompaction } from "../compaction"
|
||||
import { SessionEvent } from "../event"
|
||||
@@ -64,7 +63,7 @@ import { toLLMMessages } from "./to-llm-message"
|
||||
* - [x] Authorize and execute recorded local calls through a core-owned registry hook.
|
||||
* - [x] Persist typed success, failure, and provider-executed tool outcomes.
|
||||
* - [x] Start each recorded local call eagerly and await all settlements before continuation.
|
||||
* - [ ] Add scoped runtime context, progress updates, attachment normalization,
|
||||
* - [ ] Add scoped runtime context, progress updates, output truncation, attachment normalization,
|
||||
* plugins, and cancellation settlement.
|
||||
* - [x] Reload projected history and start the next explicit provider turn after local tool results.
|
||||
* - [x] Continue for durable user steering accepted during an active provider turn.
|
||||
@@ -132,7 +131,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
|
||||
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, never>) =>
|
||||
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
|
||||
|
||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
||||
@@ -186,7 +185,7 @@ export const layer = Layer.effect(
|
||||
session.location,
|
||||
agent.id,
|
||||
).pipe(retryAgentMismatch(promotion))
|
||||
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>()
|
||||
const toolFibers = yield* FiberSet.make<void, never>()
|
||||
let needsContinuation = false
|
||||
if (promotion) {
|
||||
const cutoff = yield* SessionInput.latestSeq(db, session.id)
|
||||
@@ -212,7 +211,6 @@ export const layer = Layer.effect(
|
||||
const model = yield* models.resolve(session)
|
||||
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
|
||||
const context = entries.map((entry) => entry.message)
|
||||
const toolMaterialization = yield* tools.materialize(agent.info?.permissions)
|
||||
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -221,7 +219,7 @@ export const layer = Layer.effect(
|
||||
.filter((part): part is string => part !== undefined && part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(context, model),
|
||||
tools: toolMaterialization.definitions,
|
||||
tools: yield* tools.definitions(agent.info?.permissions),
|
||||
})
|
||||
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
|
||||
return yield* Effect.die(rebuildPreparedTurn())
|
||||
@@ -253,16 +251,16 @@ export const layer = Layer.effect(
|
||||
yield* publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
needsContinuation = true
|
||||
const assistantMessageID = yield* publisher.assistantMessageID(event.id)
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(
|
||||
toolMaterialization.settle({
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
assistantMessageID,
|
||||
call: event,
|
||||
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
|
||||
return Effect.succeed({
|
||||
result: { type: "error" as const, value: String(Cause.squash(cause)) },
|
||||
output: undefined,
|
||||
outputPaths: [],
|
||||
})
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap((settlement) =>
|
||||
publish(
|
||||
LLMEvent.toolResult({
|
||||
@@ -320,17 +318,12 @@ export const layer = Layer.effect(
|
||||
yield* FiberSet.clear(toolFibers)
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
}
|
||||
if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) {
|
||||
const failure = Cause.squash(settled.cause)
|
||||
const message = failure instanceof Error ? failure.message : String(failure)
|
||||
yield* withPublication(publisher.failUnsettledTools(`Tool execution failed: ${message}`))
|
||||
}
|
||||
if (publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
|
||||
if (stream._tag === "Success" && !publisher.hasProviderError())
|
||||
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
|
||||
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
|
||||
if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
|
||||
const attempt = stream._tag === "Failure" ? stream : settled
|
||||
if (attempt._tag === "Failure") return yield* Effect.failCause(attempt.cause)
|
||||
return !publisher.hasProviderError() && needsContinuation
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -218,11 +218,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
}
|
||||
})
|
||||
|
||||
const assistantMessageIDForTool = (callID: string) => {
|
||||
const tool = tools.get(callID)
|
||||
return tool ? Effect.succeed(tool.assistantMessageID) : Effect.die(`Unknown tool call: ${callID}`)
|
||||
}
|
||||
|
||||
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
|
||||
event: LLMEvent,
|
||||
outputPaths: ReadonlyArray<string> = [],
|
||||
@@ -413,6 +408,5 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
|
||||
hasAssistantStarted: () => assistantMessageID !== undefined,
|
||||
hasProviderError: () => providerFailed,
|
||||
startAssistant,
|
||||
assistantMessageID: assistantMessageIDForTool,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,5 +58,3 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
+19
-36
@@ -4,7 +4,7 @@ import { Effect, Scope, Semaphore } from "effect"
|
||||
import type { Draft, Objectish } from "immer"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to an editor during rebuild.
|
||||
* A replayable contribution applied to an editor during rebuild.
|
||||
*
|
||||
* Transforms are intentionally synchronous and mutation-shaped: domain editors
|
||||
* hide the draft representation while preserving concise plugin/config code.
|
||||
@@ -39,17 +39,15 @@ export interface Interface<State extends Objectish, Editor> {
|
||||
* registration order. Closing the owning Scope removes the slot and rebuilds.
|
||||
*/
|
||||
readonly transform: () => Effect.Effect<(transform: Transform<Editor>) => Effect.Effect<void>, never, Scope.Scope>
|
||||
/** Registers and applies a replayable transform in the current Scope. */
|
||||
readonly update: (update: Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
/**
|
||||
* Mutates the current materialized state directly, once.
|
||||
* Mutates the current materialized state directly.
|
||||
*
|
||||
* This is not replayable transform state: a later rebuild starts again
|
||||
* This is not replayable contribution state: a later rebuild starts again
|
||||
* from `initial()` plus active transforms, so direct edits must be reserved
|
||||
* for current-state adjustments that are intentionally outside the transform
|
||||
* fold.
|
||||
*/
|
||||
readonly mutate: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
readonly update: (update: (editor: Editor) => Effect.Effect<void>, reason?: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export function create<State extends Objectish, Editor>(options: Options<State, Editor>): Interface<State, Editor> {
|
||||
@@ -63,50 +61,35 @@ export function create<State extends Objectish, Editor>(options: Options<State,
|
||||
state = next
|
||||
})
|
||||
|
||||
const rebuild = Effect.fnUntraced(function* () {
|
||||
const rebuild = Effect.fn("State.rebuild")(function* () {
|
||||
const next = options.initial()
|
||||
const api = options.editor(next as Draft<State>)
|
||||
for (const transform of transforms)
|
||||
yield* Effect.sync(() => transform.update(api)).pipe(Effect.withSpan("State.rebuild.update", {}))
|
||||
yield* commit(next)
|
||||
})
|
||||
}, semaphore.withPermit)
|
||||
|
||||
const result: Interface<State, Editor> = {
|
||||
return {
|
||||
get: () => state,
|
||||
transform: Effect.fn("State.transform")(function* () {
|
||||
const transform = { update: (_editor: Editor) => {} }
|
||||
transforms = [...transforms, transform]
|
||||
const scope = yield* Scope.Scope
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { update: (_editor: Editor) => {} }
|
||||
transforms = [...transforms, transform]
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
),
|
||||
)
|
||||
return (update: Transform<Editor>) =>
|
||||
Effect.uninterruptible(
|
||||
semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transform.update = update
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
),
|
||||
)
|
||||
}),
|
||||
yield* Scope.addFinalizer(
|
||||
scope,
|
||||
Effect.sync(() => {
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
}).pipe(Effect.andThen(rebuild())),
|
||||
)
|
||||
return Effect.fnUntraced(function* (update: Transform<Editor>) {
|
||||
transform.update = update
|
||||
yield* rebuild()
|
||||
})
|
||||
}),
|
||||
update: Effect.fn("State.update")(function* (update) {
|
||||
const transform = yield* result.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
mutate: Effect.fn("State.mutate")(function* (update, reason) {
|
||||
update: Effect.fn("State.update")(function* (update, reason) {
|
||||
const api = options.editor(state as Draft<State>)
|
||||
yield* update(api)
|
||||
if (options.finalize) yield* options.finalize(api, reason)
|
||||
}, semaphore.withPermit),
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const builtIns = Layer.effectDiscard(
|
||||
}),
|
||||
])
|
||||
|
||||
yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
yield* registry.contribute({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ export * as SystemContextRegistry from "./registry"
|
||||
import { Context, Effect, Layer, Ref, Scope } from "effect"
|
||||
import { SystemContext } from "./index"
|
||||
|
||||
export interface Entry {
|
||||
export interface Contribution {
|
||||
readonly key: SystemContext.Key
|
||||
readonly load: Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (entry: Entry) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly contribute: (contribution: Contribution) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
@@ -18,27 +18,27 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const entries = yield* Ref.make<ReadonlyArray<Entry>>([])
|
||||
const contributions = yield* Ref.make<ReadonlyArray<Contribution>>([])
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("SystemContextRegistry.register")(function* (entry) {
|
||||
contribute: Effect.fn("SystemContextRegistry.contribute")(function* (contribution) {
|
||||
yield* Effect.acquireRelease(
|
||||
Ref.modify(entries, (current) => {
|
||||
if (current.some((item) => item.key === entry.key)) return [false, current]
|
||||
return [true, [...current, entry]]
|
||||
Ref.modify(contributions, (current) => {
|
||||
if (current.some((item) => item.key === contribution.key)) return [false, current]
|
||||
return [true, [...current, contribution]]
|
||||
}).pipe(
|
||||
Effect.flatMap((added) =>
|
||||
added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`),
|
||||
added ? Effect.void : Effect.die(`Duplicate system context contribution key: ${contribution.key}`),
|
||||
),
|
||||
Effect.as(entry),
|
||||
Effect.as(contribution),
|
||||
),
|
||||
(entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)),
|
||||
(entry) => Ref.update(contributions, (current) => current.filter((item) => item !== entry)),
|
||||
)
|
||||
}),
|
||||
load: Effect.fn("SystemContextRegistry.load")(function* () {
|
||||
const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
const current = (yield* Ref.get(contributions)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
||||
return SystemContext.combine(
|
||||
yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }),
|
||||
yield* Effect.forEach(current, (contribution) => contribution.load, { concurrency: "unbounded" }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ToolOutputStore from "./tool-output-store"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
|
||||
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Global } from "./global"
|
||||
@@ -15,6 +15,23 @@ export const RETENTION = Duration.days(7)
|
||||
|
||||
export const MANAGED_DIRECTORY = "tool-output"
|
||||
|
||||
export interface WriteInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly toolCallID: string
|
||||
readonly content: string
|
||||
readonly mime?: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export interface TruncateInput extends WriteInput {
|
||||
readonly maxLines?: number
|
||||
readonly maxBytes?: number
|
||||
}
|
||||
|
||||
export type TruncateResult =
|
||||
| { readonly content: string; readonly truncated: false }
|
||||
| { readonly content: string; readonly truncated: true; readonly outputPath: string }
|
||||
|
||||
export interface BoundInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly toolCallID: string
|
||||
@@ -26,16 +43,11 @@ export interface BoundResult {
|
||||
readonly outputPaths: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export class StorageError extends Schema.TaggedErrorClass<StorageError>()("ToolOutputStore.StorageError", {
|
||||
operation: Schema.Literals(["encode", "write"]),
|
||||
cause: Schema.Defect,
|
||||
}) {}
|
||||
|
||||
export type Error = StorageError
|
||||
|
||||
export interface Interface {
|
||||
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
|
||||
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult, Error>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<string>
|
||||
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
|
||||
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
|
||||
readonly cleanup: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -97,12 +109,6 @@ const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes
|
||||
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
|
||||
}
|
||||
|
||||
const lineCount = (text: string) => {
|
||||
let count = 1
|
||||
for (const char of text) if (char === "\n") count++
|
||||
return count
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -110,6 +116,7 @@ export const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const config = yield* Effect.serviceOption(Config.Service)
|
||||
const directory = path.join(global.data, MANAGED_DIRECTORY)
|
||||
|
||||
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
|
||||
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
|
||||
const entries = yield* config.value.entries().pipe(Effect.catch(() => Effect.succeed([] as Config.Entry[])))
|
||||
@@ -120,50 +127,68 @@ export const layer = Layer.effect(
|
||||
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
|
||||
})
|
||||
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (content: string) {
|
||||
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
|
||||
const file = path.join(directory, `tool_${Identifier.ascending()}`)
|
||||
yield* fs.ensureDir(directory).pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
yield* fs
|
||||
.writeFileString(file, content, { flag: "wx" })
|
||||
.pipe(Effect.mapError((cause) => new StorageError({ operation: "write", cause })))
|
||||
yield* fs.ensureDir(directory).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
|
||||
return file
|
||||
})
|
||||
|
||||
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
|
||||
const outputLimits = yield* limits()
|
||||
const media = input.output.content.filter((item) => item.type === "file")
|
||||
const text = input.output.content.filter((item) => item.type === "text")
|
||||
const contextual =
|
||||
input.output.content.length === 0
|
||||
? yield* Effect.try({
|
||||
try: () => JSON.stringify(input.output.structured, null, 2) ?? String(input.output.structured),
|
||||
catch: (cause) => new StorageError({ operation: "encode", cause }),
|
||||
})
|
||||
: text.map((item) => item.text).join("")
|
||||
if (
|
||||
lineCount(contextual) <= outputLimits.maxLines &&
|
||||
Buffer.byteLength(contextual, "utf-8") <= outputLimits.maxBytes
|
||||
)
|
||||
return {
|
||||
output: input.output,
|
||||
outputPaths: [],
|
||||
}
|
||||
|
||||
const outputPath = yield* write(contextual)
|
||||
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
|
||||
const configured = yield* limits()
|
||||
const maxLines = input.maxLines ?? configured.maxLines
|
||||
const maxBytes = input.maxBytes ?? configured.maxBytes
|
||||
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
|
||||
return { content: input.content, truncated: false } as const
|
||||
}
|
||||
const outputPath = yield* write(input)
|
||||
const marker = `... output truncated; full content saved to ${outputPath} ...`
|
||||
return {
|
||||
content: boundedPreview(input.content, marker, maxLines, maxBytes),
|
||||
truncated: true,
|
||||
outputPath,
|
||||
} as const
|
||||
})
|
||||
|
||||
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
|
||||
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
|
||||
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
|
||||
Effect.catch(() => Effect.succeed(String(input.output.structured))),
|
||||
)
|
||||
const content = text || input.output.content.length > 0 ? text : structured
|
||||
if (content === undefined) return { output: input.output, outputPaths: [] }
|
||||
|
||||
const truncated = yield* truncate({
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.toolCallID,
|
||||
content,
|
||||
mime: "text/plain",
|
||||
name: `${input.toolCallID}.txt`,
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
|
||||
Effect.andThen(limits()),
|
||||
Effect.map(({ maxLines, maxBytes }) => {
|
||||
const marker = "... output truncated; omitted content could not be retained ..."
|
||||
return {
|
||||
content: boundedPreview(content, marker, maxLines, maxBytes),
|
||||
truncated: true as const,
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
|
||||
|
||||
return {
|
||||
output: {
|
||||
structured: input.output.structured,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: boundedPreview(contextual, marker, outputLimits.maxLines, outputLimits.maxBytes),
|
||||
},
|
||||
...media,
|
||||
{ type: "text" as const, text: truncated.content },
|
||||
...input.output.content.filter((item) => item.type === "file"),
|
||||
],
|
||||
},
|
||||
outputPaths: [outputPath],
|
||||
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -182,7 +207,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ limits, bound, cleanup })
|
||||
return Service.of({ limits, write, truncate, bound, cleanup })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,59 +1,139 @@
|
||||
# Core Tool Architecture
|
||||
|
||||
This folder owns Core's one local tool representation, process and Location registration, effective lookup, and settlement.
|
||||
This folder owns Core-native tool definition, contribution, effective lookup, and execution. Keep those concerns distinct even though `ToolRegistry` brings them together at runtime.
|
||||
|
||||
## Representations
|
||||
## Current Architecture
|
||||
|
||||
- `tool.ts` defines the opaque canonical `Tool.make({ description, input, output, execute, toModelOutput })` value. Application tools and shipped built-ins use the same type.
|
||||
- `application-tools.ts` stores process-scoped application registrations.
|
||||
- `tools.ts` exposes the registration-only `Tools.Service` view used by Location producers.
|
||||
- `registry.ts` stores only canonical tools, overlays Location registrations over application registrations, derives definitions, invokes tools, and applies generic output bounding.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
|
||||
## Construction
|
||||
|
||||
Tool schemas and projection use `input` and `output` terminology. A tool value is opaque: its codecs, executor, definition derivation, and catalog permission declaration are private runtime details.
|
||||
|
||||
Location-scoped built-in layers acquire `PermissionV2.Service` and every other required Location service while the layer is constructed. The executor captures those services. Permission sources are always constructed from the canonical invocation context:
|
||||
|
||||
```ts
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
```txt
|
||||
Public Tool.make NativeTool value ApplicationTools Location built-ins Location ToolRegistry Session runner
|
||||
│ │ │ │ │ │
|
||||
├─ construct ─────────▶ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ ├─ scoped attach ─────▶ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ├─ scoped contributions ──▶ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ├─ shared current entries ───────────────────────▶ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ ├─ effective definitions and settlement ──▶
|
||||
│ │ │ │ │ │
|
||||
```
|
||||
|
||||
Leaves own resolution, permission, and side-effect ordering. Translate only expected typed errors into `ToolFailure`; do not use `catchCause`, because interruption and defects must survive.
|
||||
There are three relevant representations:
|
||||
|
||||
## Registration
|
||||
- `native.ts` defines the plain Core-native executable value exposed publicly as `Tool.make(...)`. It combines an `@opencode-ai/llm` model-facing definition with a Session-aware handler.
|
||||
- `application-tools.ts` stores process-scoped application contributions. It owns availability and scoped attachment, but it does not execute tools.
|
||||
- `registry.ts` is the single execution registry. Each Location owns one registry, its built-in contributions, effective precedence, input/output validation, permissions, and settlement.
|
||||
|
||||
Built-ins register through `Tools.Service.register({ [name]: tool })`. Application tools register through `ApplicationTools.Service.register(...)`, exposed publicly as `opencode.tools.register(...)`.
|
||||
`ToolRegistry.Entry` is intentionally more powerful than the public native tool value. Internal Location tools may use Core-owned capabilities such as `assertPermission`; embedding applications receive only the narrow public execution context.
|
||||
|
||||
Both are scoped:
|
||||
## Placement And Layers
|
||||
|
||||
- The latest active same-placement registration wins.
|
||||
- Closing any registration removes only that registration and reveals the next active one.
|
||||
- Location registrations take precedence over application registrations.
|
||||
- An invocation captures the effective tool once settlement starts.
|
||||
- `ApplicationTools.Service` is process-scoped and must be shared by current and future Locations.
|
||||
- `ToolRegistry.Service` is Location-scoped because built-in handlers close over Location services such as filesystem, permissions, and tool-output storage.
|
||||
- `LocationServiceMap` constructs fresh Location services while receiving the shared `ApplicationTools.Service` as a dependency.
|
||||
- `OpenCode.layer` exposes the same shared application-tool service through `opencode.tools.attach(...)`.
|
||||
- `ToolRegistry.defaultLayer` creates isolated application-tool state. It is suitable for self-contained consumers and tests, but not when attachments must be shared with a separately constructed `LocationServiceMap`.
|
||||
|
||||
`ApplicationTools.Service` is process-scoped and shared by all Locations. `ToolRegistry.Service` is Location-scoped. Do not make the registry process-global or construct a separate application-tool service for each Location.
|
||||
Do not make `ToolRegistry` process-global. Do not move Location resources into `ApplicationTools`. Do not construct independent `ApplicationTools.layer` instances when the caller expects one attachment to appear across Locations.
|
||||
|
||||
## Permissions
|
||||
## Contribution And Precedence
|
||||
|
||||
The registry has no `PermissionV2.Service` dependency and performs no execution authorization. An internal built-in-only operation attaches a permission action solely to preserve whole-tool definition filtering; it is not part of public `Tool.make`. Most tools default to their registered name; `edit`, `write`, and `apply_patch` declare the shared `edit` action.
|
||||
Built-in Location tools contribute through `ToolRegistry.contribute(...)`. Application tools attach through `ApplicationTools.attach(...)`, exposed publicly as `opencode.tools.attach(...)`.
|
||||
|
||||
Definition filtering is catalog visibility, not execution authorization. A call still executes the captured leaf policy if it reaches settlement.
|
||||
Both contribution mechanisms use `State` scoped transforms:
|
||||
|
||||
## Output
|
||||
- Closing a contribution Scope rebuilds state without that contribution.
|
||||
- A later same-name application attachment wins while active.
|
||||
- Closing that later attachment reveals the earlier active application contribution.
|
||||
- A Location tool always takes precedence over an application tool with the same name.
|
||||
- Application attachment inputs are captured before registering the replayable transform; later caller mutation must not alter a contribution during an unrelated rebuild.
|
||||
|
||||
Built-ins return complete validated domain output. `ToolRegistry.Materialization.settle` is the only execution and generic model-output bounding boundary and owns managed retention paths.
|
||||
Do not introduce another application-specific tool type or registry. Plugins should contribute existing native tools or internal registry entries at the lifetime they actually own.
|
||||
|
||||
Producer capture limits are separate. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss, but it does not run model-output truncation or return a managed `outputPath`.
|
||||
## Dynamic Removal Semantics
|
||||
|
||||
## Current Gaps
|
||||
Definitions and settlement intentionally resolve the current effective tools independently. There is no provider-turn snapshot, attachment lease, or draining detach.
|
||||
|
||||
- Plugin boot has not been redesigned to register canonical tools through `Tools.Service`; do not redesign it as part of leaf migrations.
|
||||
- MCP and future Session-scoped registrations still need an explicit canonical registration design.
|
||||
- The public Session result shape currently exposes managed `outputPaths`; full storage encapsulation requires a future opaque managed-output reference design.
|
||||
```txt
|
||||
Embedding App ApplicationTools Location ToolRegistry Session Runner
|
||||
│ │ │ │
|
||||
├─ attach({ opencord_run }) ──▶ │ │
|
||||
│ │ │ │
|
||||
│ │ ◀─ definitions() ──────────────────┤
|
||||
│ │ │ │
|
||||
│ ◀─ entries() ────────────┤ │
|
||||
│ │ │ │
|
||||
│ │ ├─ current effective definitions ──▶
|
||||
│ │ │ │
|
||||
├─ attachment Scope closes ───▶ │ │
|
||||
│ │ │ │
|
||||
│ │ ◀─ settle(opencord_run) ───────────┤
|
||||
│ │ │ │
|
||||
│ ◀─ current lookup ───────┤ │
|
||||
│ │ │ │
|
||||
│ │ ├─ Unknown tool ───────────────────▶
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
Consequences of this choice:
|
||||
|
||||
- Closing an attachment Scope revokes the tool immediately for calls that have not started settling.
|
||||
- A call produced from an earlier advertised definition may fail as unknown.
|
||||
- If a same-name replacement is currently active, a later call may execute that replacement.
|
||||
- An execution that already resolved its entry continues with the handler it captured.
|
||||
- Attachment Scope closure does not wait for already-started executions. Applications whose handlers depend on scoped resources must coordinate graceful shutdown themselves.
|
||||
|
||||
These are deliberate simplifications. Do not add snapshots, semaphores, leases, or deferred finalizers without a concrete requirement for stronger consistency or graceful draining.
|
||||
|
||||
## File Roles
|
||||
|
||||
```txt
|
||||
tool/
|
||||
native.ts plain public/Core-native executable tool value
|
||||
application-tools.ts process-scoped State-backed application contributions
|
||||
registry.ts Location-scoped effective lookup, validation, and execution
|
||||
builtins.ts shipped Location tool layer composition
|
||||
read.ts, bash.ts, ... individual Location-scoped built-in contributions
|
||||
```
|
||||
|
||||
Keep model/provider-neutral tool schemas and output projection in `@opencode-ai/llm`. Keep Session identity, permissions, Location precedence, and settlement in Core.
|
||||
|
||||
## Future Directions
|
||||
|
||||
Tool availability may eventually gain a real third scope, such as Session-specific or plugin-owned contributions:
|
||||
|
||||
```txt
|
||||
╭─────────────────╮
|
||||
│ Tool definition │
|
||||
╰────────┬────────╯
|
||||
╭────────────────────────────────────────╰╮─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╮
|
||||
│ │
|
||||
▼ ▼ ▼
|
||||
╭───────────────────────╮ ╭────────────────────────╮ ╭───────────────────────╮
|
||||
│ Process contributions │ │ Location contributions │ │ Session contributions │
|
||||
╰───────────┬───────────╯ ╰────────────┬───────────╯ ╰───────────┬───────────╯
|
||||
│ │ │
|
||||
│ │
|
||||
╰─────────────────────────────────────────◀─ ─ ─ ─ ─ ─ ─ ─ future ─ ─ ─ ─ ─ ─ ─ ─ ╯
|
||||
╭──────────────────────╮
|
||||
│ Effective resolution │
|
||||
╭─────────╰───────────┬──────────╯────────────╮
|
||||
│ │ │
|
||||
▼ ▼
|
||||
╭───────────────────────────────╮ ╭─────────────────────────╮
|
||||
│ Advertise current definitions │ │ Execute current handler │
|
||||
╰───────────────────────────────╯ ╰─────────────────────────╯
|
||||
```
|
||||
|
||||
Prefer these directions only when a concrete use requires them:
|
||||
|
||||
- **Contextual availability:** Add Session/agent/plugin filtering at effective resolution. Keep tool definitions independent from where they are enabled.
|
||||
- **Hierarchical overlays:** If a third contribution scope becomes real, consider one registry abstraction with process, Location, and Session overlays rather than adding another special registry service.
|
||||
- **Plugin tools:** Reuse the existing native tool value for restricted handlers and `ToolRegistry.Entry` for trusted Core-owned capabilities. Choose process or Location contribution lifetime explicitly.
|
||||
- **Stale-call rejection:** If executing a same-name replacement is unsafe, attach an identity/version to advertised definitions and reject stale calls without retaining removed handlers.
|
||||
- **Pinned provider turns:** If exact advertisement-to-execution consistency becomes necessary, snapshot effective entries for one provider turn. This weakens immediate revocation.
|
||||
- **Graceful plugin unload:** If attachment-owned resources must outlive started executions, add explicit execution draining. Keep this separate from whether new calls can discover the tool.
|
||||
- **Cluster placement:** `ApplicationTools` is process-global, not cluster-global. Cluster-wide contribution and execution ownership require a separate durable design.
|
||||
|
||||
When choosing stronger semantics, state which property matters: immediate revocation, stale-call rejection, exact handler pinning, or graceful resource draining. They are different guarantees and should not arrive as one bundled lifecycle mechanism.
|
||||
|
||||
@@ -1,28 +1,21 @@
|
||||
export * as ApplicationTools from "./application-tools"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { enableMapSet } from "immer"
|
||||
import { castDraft, enableMapSet } from "immer"
|
||||
import { State } from "../state"
|
||||
import { Tool } from "./tool"
|
||||
import { NativeTool } from "./native"
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
readonly entries: Map<string, NativeTool.Any>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
readonly set: (name: string, entry: Entry) => void
|
||||
}
|
||||
|
||||
export interface Entry {
|
||||
readonly identity: object
|
||||
readonly tool: Tool.AnyTool
|
||||
readonly set: (name: string, tool: NativeTool.Any) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
readonly entries: () => ReadonlyMap<string, Entry>
|
||||
readonly attach: (tools: Readonly<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly entries: () => ReadonlyMap<string, NativeTool.Any>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ApplicationTools") {}
|
||||
@@ -36,20 +29,20 @@ export const layer = Layer.effect(
|
||||
initial: () => ({ entries: new Map() }),
|
||||
editor: (draft) => ({
|
||||
set: (name, tool) => {
|
||||
draft.entries.set(name, tool)
|
||||
draft.entries.set(
|
||||
name,
|
||||
castDraft(tool) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ApplicationTools.register")(function* (tools) {
|
||||
attach: Effect.fn("ApplicationTools.attach")(function* (tools) {
|
||||
const entries = Object.entries(tools)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(entries, ([name]) => Tool.validateName(name), { discard: true })
|
||||
const registrations = entries.map(([name, tool]) => [name, { identity: {}, tool }] as const)
|
||||
const transform = yield* state.transform()
|
||||
yield* transform((editor) => {
|
||||
for (const [name, entry] of registrations) editor.set(name, entry)
|
||||
for (const [name, tool] of entries) editor.set(name, tool)
|
||||
})
|
||||
}),
|
||||
entries: () => state.get().entries,
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
export * as ApplyPatchTool from "./apply-patch"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Patch } from "../patch"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "apply_patch"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
patchText: Schema.String.annotate({
|
||||
description: "The full patch text describing add, update, and delete operations",
|
||||
}),
|
||||
@@ -24,10 +22,10 @@ export const Applied = Schema.Struct({
|
||||
target: Schema.String,
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({ applied: Schema.Array(Applied) })
|
||||
export type Output = typeof Output.Type
|
||||
export const Success = Schema.Struct({ applied: Schema.Array(Applied) })
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Output) =>
|
||||
export const toModelOutput = (output: Success) =>
|
||||
[
|
||||
"Applied patch sequentially:",
|
||||
...output.applied.map(
|
||||
@@ -35,6 +33,14 @@ export const toModelOutput = (output: Output) =>
|
||||
),
|
||||
].join("\n")
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
type Prepared =
|
||||
| (Extract<Patch.Hunk, { readonly type: "add" | "delete" }> & { readonly target: LocationMutation.Target })
|
||||
| (Extract<Patch.Hunk, { readonly type: "update" }> & {
|
||||
@@ -45,133 +51,116 @@ type Prepared =
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.withPermission(
|
||||
Tool.make({
|
||||
description:
|
||||
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string) => {
|
||||
const prefix =
|
||||
applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
if (!input.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(input.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
|
||||
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const fail = (path: string, cause: unknown) => {
|
||||
const prefix =
|
||||
applied.length === 0
|
||||
? `Unable to apply patch at ${path}`
|
||||
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
|
||||
return new ToolFailure({ message: prefix, error: cause })
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
if (!parameters.patchText.trim()) return yield* new ToolFailure({ message: "patchText is required" })
|
||||
const hunks = yield* Effect.try({
|
||||
try: () => Patch.parse(parameters.patchText),
|
||||
catch: (cause) => new ToolFailure({ message: `apply_patch verification failed: ${String(cause)}` }),
|
||||
})
|
||||
if (hunks.length === 0) return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
const move = hunks.find((hunk) => hunk.type === "update" && hunk.movePath !== undefined)
|
||||
if (move) return yield* new ToolFailure({ message: "apply_patch moves are not supported yet" })
|
||||
|
||||
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
|
||||
for (const hunk of hunks)
|
||||
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { target } of targets) {
|
||||
const external = target.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
}
|
||||
for (const external of externalDirectories.values()) {
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
}
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map(({ target }) => target.resource))],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const targets: Array<{ readonly hunk: Patch.Hunk; readonly target: LocationMutation.Target }> = []
|
||||
for (const hunk of hunks)
|
||||
targets.push({ hunk, target: yield* mutation.resolve({ path: hunk.path, kind: "file" }) })
|
||||
const externalDirectories = new Map<string, LocationMutation.ExternalDirectoryAuthorization>()
|
||||
for (const { target } of targets) {
|
||||
const external = target.externalDirectory
|
||||
if (external) externalDirectories.set(external.resource, external)
|
||||
}
|
||||
for (const external of externalDirectories.values()) {
|
||||
yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
}
|
||||
yield* assertPermission({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map(({ target }) => target.resource))],
|
||||
save: ["*"],
|
||||
})
|
||||
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, target } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path)
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const update = Patch.derive(
|
||||
hunk.path,
|
||||
hunk.chunks,
|
||||
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
|
||||
)
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
source,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
})
|
||||
}).pipe(Effect.mapError(() => fail(hunk.path)))
|
||||
const prepared: Prepared[] = []
|
||||
for (const { hunk, target } of targets) {
|
||||
yield* Effect.gen(function* () {
|
||||
if (hunk.type === "add") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({
|
||||
target: change.target,
|
||||
content:
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
const result = yield* files.remove({ target: change.target })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({
|
||||
target: change.target,
|
||||
expected: change.source,
|
||||
content: change.content,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.mapError(() => fail(change.path))),
|
||||
{ discard: true },
|
||||
if ((yield* fs.stat(target.canonical)).type !== "File")
|
||||
yield* fail(hunk.path, new Error("Target file does not exist"))
|
||||
if (hunk.type === "delete") {
|
||||
prepared.push({ ...hunk, target })
|
||||
return
|
||||
}
|
||||
const source = yield* fs.readFile(target.canonical)
|
||||
const update = Patch.derive(
|
||||
hunk.path,
|
||||
hunk.chunks,
|
||||
new TextDecoder("utf-8", { ignoreBOM: true }).decode(source),
|
||||
)
|
||||
return { applied }
|
||||
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch"))))
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
prepared.push({
|
||||
...hunk,
|
||||
target,
|
||||
source,
|
||||
content: Patch.joinBom(update.content, update.bom),
|
||||
})
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(hunk.path, Cause.squash(cause)))))
|
||||
}
|
||||
|
||||
yield* Effect.forEach(
|
||||
prepared,
|
||||
(change) =>
|
||||
Effect.gen(function* () {
|
||||
if (change.type === "add") {
|
||||
const result = yield* files.create({
|
||||
target: change.target,
|
||||
content:
|
||||
change.contents.endsWith("\n") || change.contents === ""
|
||||
? change.contents
|
||||
: `${change.contents}\n`,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
if (change.type === "delete") {
|
||||
const result = yield* files.remove({ target: change.target })
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
return
|
||||
}
|
||||
const result = yield* files.writeIfUnchanged({
|
||||
target: change.target,
|
||||
expected: change.source,
|
||||
content: change.content,
|
||||
})
|
||||
applied.push({ type: change.type, resource: result.resource, target: result.target })
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(fail(change.path, Cause.squash(cause))))),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(error instanceof ToolFailure ? error : fail("patch", error))
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
export * as BashTool from "./bash"
|
||||
|
||||
import path from "path"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../config"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { AppProcess } from "../process"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "bash"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
command: Schema.String.annotate({ description: "Shell command string to execute" }),
|
||||
workdir: Schema.String.pipe(Schema.optional).annotate({
|
||||
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
|
||||
@@ -33,7 +32,7 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
const Success = Schema.Struct({
|
||||
command: Schema.String,
|
||||
cwd: Schema.String,
|
||||
exitCode: Schema.Number.pipe(Schema.optional),
|
||||
@@ -42,11 +41,12 @@ const Output = Schema.Struct({
|
||||
truncated: Schema.Boolean,
|
||||
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
timedOut: Schema.Boolean.pipe(Schema.optional),
|
||||
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Output = typeof Output.Type
|
||||
type Success = typeof Success.Type
|
||||
|
||||
const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh")
|
||||
|
||||
@@ -59,10 +59,9 @@ const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => {
|
||||
if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]"
|
||||
if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]"
|
||||
if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]"
|
||||
return undefined
|
||||
}
|
||||
|
||||
const modelOutput = (output: Output) => {
|
||||
const modelOutput = (output: Success) => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
@@ -73,6 +72,13 @@ const modelOutput = (output: Output) => {
|
||||
const isTimeout = (error: AppProcess.AppProcessError) =>
|
||||
error.cause instanceof Error && error.cause.message === "Timed out"
|
||||
|
||||
const definition = Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Minimal V2 core shell boundary. Keep parity debt visible without pulling the
|
||||
* legacy shell runtime into core.
|
||||
@@ -106,101 +112,96 @@ const externalCommandDirectories = (command: string, cwd: string) => {
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
const config = yield* Config.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: modelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = externalCommandDirectories(input.command, target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
const warnings = externalCommandDirectories(parameters.command, target.canonical).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
yield* assertPermission({ action: name, resources: [parameters.command], save: [parameters.command] })
|
||||
|
||||
if ((yield* fs.stat(target.canonical)).type !== "Directory")
|
||||
throw new Error(`Working directory is not a directory: ${target.canonical}`)
|
||||
|
||||
const entries = yield* config.entries()
|
||||
const shell =
|
||||
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : []))).shell ??
|
||||
defaultShell()
|
||||
const command = ChildProcess.make(parameters.command, [], {
|
||||
cwd: target.canonical,
|
||||
shell,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
})
|
||||
const timeout = parameters.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const result = yield* appProcess
|
||||
.run(command, {
|
||||
timeout: Duration.millis(timeout),
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("AppProcessError", (error) =>
|
||||
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
if ((yield* fs.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
|
||||
const entries = yield* config.entries()
|
||||
const shell =
|
||||
Object.assign({}, ...entries.flatMap((entry) => (entry.type === "document" ? [entry.info] : [])))
|
||||
.shell ?? defaultShell()
|
||||
const command = ChildProcess.make(input.command, [], {
|
||||
cwd: target.canonical,
|
||||
shell,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
})
|
||||
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
|
||||
const result = yield* appProcess
|
||||
.run(command, {
|
||||
timeout: Duration.millis(timeout),
|
||||
maxOutputBytes: MAX_CAPTURE_BYTES,
|
||||
maxErrorBytes: MAX_CAPTURE_BYTES,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTag("AppProcessError", (error) =>
|
||||
isTimeout(error) ? Effect.succeed(undefined) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
if (!result) {
|
||||
return {
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timedOut: true,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
|
||||
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
|
||||
if (!result) {
|
||||
return {
|
||||
command: input.command,
|
||||
command: parameters.command,
|
||||
cwd: target.canonical,
|
||||
exitCode: result.exitCode,
|
||||
output: notice ? `${compact}\n\n${notice}` : compact,
|
||||
truncated: result.stdoutTruncated || result.stderrTruncated,
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
timedOut: true,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8"))
|
||||
const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated)
|
||||
const truncated = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content: notice ? `${compact}\n\n${notice}` : compact,
|
||||
})
|
||||
return {
|
||||
command: parameters.command,
|
||||
cwd: target.canonical,
|
||||
exitCode: result.exitCode,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated || result.stdoutTruncated || result.stderrTruncated,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
|
||||
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
|
||||
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
|
||||
? { outputPath: truncated.outputPath }
|
||||
: {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to execute command: ${parameters.command}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||
* Composes only the shipped Location-scoped built-in tool contributions.
|
||||
* Each tool retains its implementation and focused tests independently. Dynamic
|
||||
* MCP and plugin tools later use separate scoped canonical registrations, while
|
||||
* MCP and plugin tools later use separate scoped ToolRegistry transforms, while
|
||||
* provider/model filtering belongs to a future materialization phase rather
|
||||
* than this static list. The caller intentionally supplies shared Location
|
||||
* services once to this merged set.
|
||||
@@ -25,7 +25,7 @@ import { WriteTool } from "./write"
|
||||
* TODO: Port the remaining launch-follow-up leaves deliberately: edit fuzzy
|
||||
* parity, task, LSP,
|
||||
* repo_clone, repo_overview, plan_exit, and Rune/code mode. Keep MCP and plugin
|
||||
* transforms separate from this static built-in list.
|
||||
* contributions separate from this static built-in list.
|
||||
*/
|
||||
export const locationLayer = Layer.mergeAll(
|
||||
ApplyPatchTool.layer,
|
||||
|
||||
+85
-109
@@ -7,18 +7,16 @@
|
||||
*/
|
||||
export * as EditTool from "./edit"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = 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. Named project references are read-oriented and are not accepted.",
|
||||
@@ -30,14 +28,14 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
export const Success = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
replacements: Schema.Number,
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n")
|
||||
const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n")
|
||||
@@ -70,7 +68,7 @@ const previewLines = (value: string, prefix: "+" | "-") => {
|
||||
return shown
|
||||
}
|
||||
|
||||
export const toModelOutput = (output: Output, oldString: string, newString: string) =>
|
||||
export const toModelOutput = (output: Success, oldString: string, newString: string) =>
|
||||
[
|
||||
`Edited file successfully: ${output.resource}`,
|
||||
`Replacements: ${output.replacements}`,
|
||||
@@ -80,6 +78,16 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
|
||||
"```",
|
||||
].join("\n")
|
||||
|
||||
const definition = 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. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, parameters.oldString, parameters.newString) }),
|
||||
],
|
||||
})
|
||||
|
||||
/** Deferred V2 edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review.
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
@@ -89,112 +97,80 @@ export const toModelOutput = (output: Output, oldString: string, newString: stri
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[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. Named project references are read-oriented and are not accepted.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(output, input.oldString, input.newString) }),
|
||||
],
|
||||
execute: (input, context) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
})
|
||||
: new ToolFailure({ message: `Unable to edit ${input.path}` }),
|
||||
),
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const unableToEdit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
return Effect.fail(
|
||||
error instanceof FileMutation.StaleContentError
|
||||
? new ToolFailure({
|
||||
message: "File changed after permission approval. Read it again before editing.",
|
||||
})
|
||||
: new ToolFailure({ message: `Unable to edit ${parameters.path}`, error }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const permissionSource = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
if (input.oldString === input.newString) {
|
||||
return yield* new ToolFailure({
|
||||
message: "No changes to apply: oldString and newString are identical.",
|
||||
})
|
||||
}
|
||||
if (input.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
}
|
||||
|
||||
const target = yield* unableToEdit(mutation.resolve({ path: input.path, kind: "file" }))
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(
|
||||
permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
yield* unableToEdit(
|
||||
permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
}),
|
||||
)
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(input.oldString, ending)
|
||||
const newString = convertToLineEnding(input.newString, ending)
|
||||
const replacements = countOccurrences(source.text, oldString)
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && input.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
input.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
target,
|
||||
expected: source.content,
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Output
|
||||
return Effect.gen(function* () {
|
||||
if (parameters.oldString === parameters.newString) {
|
||||
return yield* new ToolFailure({ message: "No changes to apply: oldString and newString are identical." })
|
||||
}
|
||||
if (parameters.oldString === "") {
|
||||
return yield* new ToolFailure({
|
||||
message: "oldString must not be empty. Use write to create or overwrite a file.",
|
||||
})
|
||||
},
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const target = yield* unableToEdit(mutation.resolve({ path: parameters.path, kind: "file" }))
|
||||
const external = target.externalDirectory
|
||||
if (external) {
|
||||
yield* unableToEdit(assertPermission(LocationMutation.externalDirectoryPermission(external)))
|
||||
}
|
||||
|
||||
yield* unableToEdit(assertPermission({ action: "edit", resources: [target.resource], save: ["*"] }))
|
||||
const source = decodeUtf8(yield* unableToEdit(fs.readFile(target.canonical)))
|
||||
const ending = detectLineEnding(source.text)
|
||||
const oldString = convertToLineEnding(parameters.oldString, ending)
|
||||
const newString = convertToLineEnding(parameters.newString, ending)
|
||||
const replacements = countOccurrences(source.text, oldString)
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace and indentation.",
|
||||
})
|
||||
}
|
||||
if (replacements > 1 && parameters.replaceAll !== true) {
|
||||
return yield* new ToolFailure({
|
||||
message:
|
||||
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true.",
|
||||
})
|
||||
}
|
||||
|
||||
const replaced =
|
||||
parameters.replaceAll === true
|
||||
? source.text.replaceAll(oldString, newString)
|
||||
: source.text.replace(oldString, newString)
|
||||
const next = splitBom(replaced)
|
||||
const result = yield* unableToEdit(
|
||||
files.writeIfUnchanged({
|
||||
target,
|
||||
expected: source.content,
|
||||
content: joinBom(next.text, source.bom || next.bom),
|
||||
}),
|
||||
)
|
||||
return { ...result, replacements } satisfies Success
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
export * as GlobTool from "./glob"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "glob"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: LocationSearch.FilesInput.fields.pattern.annotate({ description: "Glob pattern to match files against" }),
|
||||
path: LocationSearch.FilesInput.fields.path.annotate({
|
||||
description: "Relative directory to search. Defaults to the active Location.",
|
||||
@@ -38,6 +36,14 @@ export const toModelOutput = (output: ModelOutput) => {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"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.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.FilesResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped glob leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and traversal.
|
||||
@@ -46,42 +52,39 @@ export const toModelOutput = (output: ModelOutput) => {
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"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, 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,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
return yield* search.files(input)
|
||||
}).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: `Unable to find files matching ${input.pattern}` })),
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot({ path: parameters.path, reference: parameters.reference })
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: parameters.reference,
|
||||
path: parameters.path,
|
||||
limit: parameters.limit,
|
||||
},
|
||||
})
|
||||
return yield* search.files(parameters)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to find files matching ${parameters.pattern}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
export * as GrepTool from "./grep"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { LocationSearch } from "../location-search"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "grep"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
pattern: LocationSearch.GrepInput.fields.pattern.annotate({
|
||||
description: "Regex pattern to search for in file contents",
|
||||
}),
|
||||
@@ -29,10 +27,10 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
type Output = typeof LocationSearch.GrepResult.Encoded
|
||||
type Success = typeof LocationSearch.GrepResult.Encoded
|
||||
|
||||
/** Format raw Location search matches into the familiar concise model output. */
|
||||
export const toModelOutput = (output: Output) => {
|
||||
export const toModelOutput = (output: Success) => {
|
||||
const lines = output.items.length === 0 ? ["No files found"] : [`Found ${output.items.length} matches`]
|
||||
let current = ""
|
||||
for (const match of output.items) {
|
||||
@@ -53,6 +51,14 @@ export const toModelOutput = (output: Output) => {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"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.",
|
||||
parameters: Parameters,
|
||||
success: LocationSearch.GrepResult,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/**
|
||||
* Location-scoped grep leaf. FileSystem supplies canonical permission metadata;
|
||||
* LocationSearch resolves the current root and owns containment and ripgrep execution.
|
||||
@@ -61,49 +67,40 @@ export const toModelOutput = (output: Output) => {
|
||||
*/
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const search = yield* LocationSearch.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"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) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot(input)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: input.reference,
|
||||
path: input.path,
|
||||
include: input.include,
|
||||
limit: input.limit,
|
||||
},
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
return yield* search.grep(input)
|
||||
}).pipe(
|
||||
Effect.mapError((error) => {
|
||||
const message =
|
||||
error instanceof Ripgrep.InvalidPatternError
|
||||
? `Invalid grep pattern ${JSON.stringify(input.pattern)}: ${error.message}`
|
||||
: `Unable to grep for ${input.pattern}`
|
||||
return new ToolFailure({ message })
|
||||
}),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* filesystem.resolveRoot(parameters)
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.pattern],
|
||||
save: ["*"],
|
||||
metadata: {
|
||||
root: root.resource,
|
||||
reference: parameters.reference,
|
||||
path: parameters.path,
|
||||
include: parameters.include,
|
||||
limit: parameters.limit,
|
||||
},
|
||||
})
|
||||
return yield* search.grep(parameters)
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const error = Cause.squash(cause)
|
||||
const message =
|
||||
error instanceof Ripgrep.InvalidPatternError
|
||||
? `Invalid grep pattern ${JSON.stringify(parameters.pattern)}: ${error.message}`
|
||||
: `Unable to grep for ${parameters.pattern}`
|
||||
return Effect.fail(new ToolFailure({ message, error }))
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
export * as NativeTool from "./native"
|
||||
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { SessionSchema } from "../session/schema"
|
||||
|
||||
export interface Context {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any, never, never>
|
||||
|
||||
export interface Executable<Parameters extends SchemaType<any>, Success extends SchemaType<any>> {
|
||||
readonly definition: Tool.Tool<Parameters, Success>
|
||||
readonly execute: (
|
||||
parameters: Schema.Schema.Type<Parameters>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
}
|
||||
|
||||
export type Any = Executable<any, any>
|
||||
|
||||
export const Failure = ToolFailure
|
||||
export type Failure = ToolFailure
|
||||
|
||||
export type Content =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| {
|
||||
readonly type: "file"
|
||||
readonly data: string
|
||||
readonly mime: string
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
export function make<Parameters extends SchemaType<any>, Success extends SchemaType<any>>(config: {
|
||||
readonly description: string
|
||||
readonly parameters: Parameters
|
||||
readonly success: Success
|
||||
readonly execute: (
|
||||
parameters: Schema.Schema.Type<Parameters>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
readonly toModelOutput?: (input: {
|
||||
readonly callID: string
|
||||
readonly parameters: Schema.Schema.Type<Parameters>
|
||||
readonly output: Success["Encoded"]
|
||||
}) => ReadonlyArray<Content>
|
||||
}): Executable<Parameters, Success> {
|
||||
const toModelOutput = config.toModelOutput
|
||||
return {
|
||||
definition: Tool.make({
|
||||
description: config.description,
|
||||
parameters: config.parameters,
|
||||
success: config.success,
|
||||
toModelOutput: toModelOutput
|
||||
? (input) =>
|
||||
toModelOutput(input).map((content) =>
|
||||
content.type === "text"
|
||||
? content
|
||||
: {
|
||||
type: "file",
|
||||
source: { type: "data", data: content.data },
|
||||
mime: content.mime,
|
||||
name: content.name,
|
||||
},
|
||||
)
|
||||
: undefined,
|
||||
}),
|
||||
execute: config.execute,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
export * as QuestionTool from "./question"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
@@ -20,14 +18,14 @@ Usage notes:
|
||||
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
export const Success = Schema.Struct({
|
||||
answers: Schema.Array(QuestionV2.Answer),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (
|
||||
questions: ReadonlyArray<QuestionV2.Prompt>,
|
||||
@@ -42,45 +40,42 @@ export const toModelOutput = (
|
||||
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ parameters, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(parameters.questions, output.answers) }),
|
||||
],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => [
|
||||
toolText({ type: "text", text: toModelOutput(input.questions, output.answers) }),
|
||||
],
|
||||
execute: (input, context) =>
|
||||
permission
|
||||
.assert({
|
||||
action: "question",
|
||||
resources: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
Effect.andThen(
|
||||
question
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
questions: input.questions,
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
permission: { action: "question", resource: "*" },
|
||||
authorize: ({ assertPermission }) =>
|
||||
assertPermission({ action: "question", resources: ["*"] }).pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
),
|
||||
execute: ({ parameters, sessionID, source }) =>
|
||||
question
|
||||
.ask({
|
||||
sessionID,
|
||||
questions: parameters.questions,
|
||||
// The registry intentionally leaves source absent until it owns the durable assistant message ID.
|
||||
tool: source?.type === "tool" ? { messageID: source.messageID, callID: source.callID } : undefined,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((answers) => ({ answers })),
|
||||
// V1 treats a dismissed question as an interrupted tool invocation rather than model-facing text.
|
||||
Effect.orDie,
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
+198
-56
@@ -1,15 +1,46 @@
|
||||
export * as ReadTool from "./read"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure } from "@opencode-ai/llm"
|
||||
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
|
||||
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { Config } from "../config"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Image } from "../image"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "read"
|
||||
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
|
||||
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
|
||||
const MAX_IMAGE_WIDTH = 2_000
|
||||
const MAX_IMAGE_HEIGHT = 2_000
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
class ImageDecodeError extends Error {
|
||||
constructor(readonly resource: string) {
|
||||
super(`Image could not be decoded: ${resource}`)
|
||||
this.name = "ImageDecodeError"
|
||||
}
|
||||
}
|
||||
|
||||
class ImageSizeError extends Error {
|
||||
constructor(
|
||||
readonly resource: string,
|
||||
readonly width: number,
|
||||
readonly height: number,
|
||||
readonly bytes: number,
|
||||
readonly maxWidth: number,
|
||||
readonly maxHeight: number,
|
||||
readonly maxBytes: number,
|
||||
) {
|
||||
super(
|
||||
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
|
||||
)
|
||||
this.name = "ImageSizeError"
|
||||
}
|
||||
}
|
||||
const LocationInput = Schema.Struct({
|
||||
...FileSystem.ReadInput.fields,
|
||||
offset: FileSystem.ListPageInput.fields.offset.annotate({
|
||||
@@ -20,68 +51,179 @@ const LocationInput = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
const Input = LocationInput
|
||||
const Output = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
|
||||
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
|
||||
parameters: Input,
|
||||
success: Success,
|
||||
toStructuredOutput: (output) =>
|
||||
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
|
||||
? { type: "media", mime: output.mime }
|
||||
: output,
|
||||
toModelOutput: ({ parameters, output }) => {
|
||||
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
source: { type: "data", data: output.content },
|
||||
mime: output.mime,
|
||||
name: parameters.path,
|
||||
},
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const image = yield* Image.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const config = yield* Config.Service
|
||||
const loadPhoton = yield* Effect.cached(
|
||||
Effect.sync(() => {
|
||||
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
|
||||
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
|
||||
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
|
||||
)
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ input, output }) => {
|
||||
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
|
||||
return [
|
||||
{ type: "text", text: "Image read successfully" },
|
||||
{ type: "file", data: output.content, mime: output.mime, name: input.path },
|
||||
]
|
||||
},
|
||||
execute: (input, context) => {
|
||||
return Effect.gen(function* () {
|
||||
const resolved = yield* filesystem.resolveReadPath(input)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [resolved.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
if (resolved.type === "directory") return yield* filesystem.listPage(input)
|
||||
const content = yield* filesystem.readTool(input, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
return yield* image
|
||||
.normalize(resolved.resource, content)
|
||||
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) => {
|
||||
const input = parameters
|
||||
return Effect.gen(function* () {
|
||||
const resolved = yield* filesystem.resolveReadPath(input)
|
||||
if (resolved.type === "directory") {
|
||||
yield* assertPermission({ action: name, resources: [resolved.resource], save: ["*"] })
|
||||
return yield* filesystem.listPage(input)
|
||||
}
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [resolved.resource],
|
||||
save: ["*"],
|
||||
})
|
||||
const content = yield* filesystem.readTool(input, {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
})
|
||||
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
|
||||
const mime = content.mime
|
||||
const base64 = content.content
|
||||
const image = Object.assign(
|
||||
{},
|
||||
...(yield* config.entries()).flatMap((entry) =>
|
||||
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
|
||||
),
|
||||
)
|
||||
const limits = {
|
||||
autoResize: image.auto_resize ?? true,
|
||||
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
|
||||
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
|
||||
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
|
||||
}
|
||||
if (content.type === "binary")
|
||||
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.mapError((error) => {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
|
||||
catch: () => new ImageDecodeError(resolved.resource),
|
||||
})
|
||||
try {
|
||||
const width = decoded.get_width()
|
||||
const height = decoded.get_height()
|
||||
const bytes = Buffer.byteLength(base64, "utf-8")
|
||||
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
|
||||
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
|
||||
if (!limits.autoResize)
|
||||
return yield* Effect.die(
|
||||
new ImageSizeError(
|
||||
resolved.resource,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
limits.maxWidth,
|
||||
limits.maxHeight,
|
||||
limits.maxBase64Bytes,
|
||||
),
|
||||
)
|
||||
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
|
||||
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
|
||||
const previous = acc.at(-1) ?? {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
const next =
|
||||
acc.length === 0
|
||||
? previous
|
||||
: {
|
||||
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
|
||||
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
|
||||
}
|
||||
return acc.some((item) => item.width === next.width && item.height === next.height)
|
||||
? acc
|
||||
: [...acc, next]
|
||||
}, [])
|
||||
for (const size of sizes) {
|
||||
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
|
||||
try {
|
||||
const candidate = [
|
||||
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
|
||||
...JPEG_QUALITIES.map((quality) => ({
|
||||
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
|
||||
mime: "image/jpeg",
|
||||
})),
|
||||
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
|
||||
if (candidate)
|
||||
return new FileSystem.BinaryContent({
|
||||
type: "binary",
|
||||
content: candidate.content,
|
||||
encoding: "base64",
|
||||
mime: candidate.mime,
|
||||
})
|
||||
} finally {
|
||||
resized.free()
|
||||
}
|
||||
}
|
||||
return yield* Effect.die(
|
||||
new ImageSizeError(
|
||||
resolved.resource,
|
||||
width,
|
||||
height,
|
||||
bytes,
|
||||
limits.maxWidth,
|
||||
limits.maxHeight,
|
||||
limits.maxBase64Bytes,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
decoded.free()
|
||||
}
|
||||
}
|
||||
if (content.type === "binary") return yield* Effect.die(new FileSystem.BinaryFileError(resolved.resource))
|
||||
return content
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.gen(function* () {
|
||||
const error = Cause.squash(cause)
|
||||
const message =
|
||||
error instanceof FileSystem.BinaryFileError ||
|
||||
error instanceof FileSystem.MediaIngestLimitError ||
|
||||
error instanceof Image.DecodeError ||
|
||||
error instanceof Image.SizeError
|
||||
error instanceof ImageDecodeError ||
|
||||
error instanceof ImageSizeError
|
||||
? error.message
|
||||
: `Unable to read ${input.path}`
|
||||
return new ToolFailure({ message })
|
||||
return yield* new ToolFailure({ message, error })
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(ToolRegistry.defaultLayer),
|
||||
Layer.provideMerge(FileSystem.locationLayer),
|
||||
Layer.provideMerge(Config.locationLayer),
|
||||
Layer.provideMerge(PermissionV2.locationLayer),
|
||||
)
|
||||
|
||||
@@ -1,33 +1,91 @@
|
||||
export * as ToolRegistry from "./registry"
|
||||
|
||||
import { ToolOutput, type ToolCall, type ToolDefinition, type ToolSettlement } from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { AgentV2 } from "../agent"
|
||||
import {
|
||||
Tool,
|
||||
ToolFailure,
|
||||
ToolOutput,
|
||||
ToolResultValue as ToolResult,
|
||||
type Tool as TypedTool,
|
||||
type ToolCall,
|
||||
type ToolResultValue,
|
||||
type ToolSchema,
|
||||
type ToolSettlement,
|
||||
} from "@opencode-ai/llm"
|
||||
import { Context, Effect, Layer, Schema, Scope } from "effect"
|
||||
import { castDraft, enableMapSet } from "immer"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionMessage } from "../session/message"
|
||||
import { State } from "../state"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
import type { SessionV2 } from "../session"
|
||||
import { ApplicationTools } from "./application-tools"
|
||||
import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { AgentV2 } from "../agent"
|
||||
import { Wildcard } from "../util/wildcard"
|
||||
|
||||
export type ExecuteInput = {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly agent?: AgentV2.ID
|
||||
readonly call: ToolCall
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
/** Internal registration capability exposed publicly only through Tools.Service. */
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
/**
|
||||
* Narrow cross-cutting context for one registry invocation. Leaf tools retain
|
||||
* ownership of sequence-sensitive policy decisions; the registry only binds
|
||||
* identity and shared helper behavior consistently.
|
||||
*
|
||||
* TODO: Add `source` when the runner can pass the durable owning assistant
|
||||
* message ID alongside the call ID. Do not infer it from the tool call alone.
|
||||
* TODO: Add cancellation and progress only when the runner exposes a real
|
||||
* signal and durable/live progress sink.
|
||||
*/
|
||||
export type Invocation = ExecuteInput & {
|
||||
readonly source?: PermissionV2.Source
|
||||
readonly assertPermission: (
|
||||
input: Omit<PermissionV2.AssertInput, "sessionID" | "agent" | "source">,
|
||||
) => Effect.Effect<void, PermissionV2.Error | SessionV2.NotFoundError>
|
||||
}
|
||||
|
||||
export interface Materialization {
|
||||
readonly definitions: ReadonlyArray<ToolDefinition>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement, ToolOutputStore.Error>
|
||||
/** Kept as the leaf entry input name for backwards-compatible execute usage. */
|
||||
export type AuthorizeInput<Parameters = unknown> = Invocation & {
|
||||
readonly parameters: Parameters
|
||||
}
|
||||
|
||||
export type Entry<
|
||||
Parameters extends ToolSchema<any> = ToolSchema<any>,
|
||||
Success extends ToolSchema<any> = ToolSchema<any>,
|
||||
> = {
|
||||
readonly tool: TypedTool<Parameters, Success>
|
||||
/** Catalog visibility only. Execution authorization remains leaf-owned. */
|
||||
readonly permission?: { readonly action: string; readonly resource: "*" }
|
||||
readonly authorize?: (input: AuthorizeInput<Schema.Schema.Type<Parameters>>) => Effect.Effect<void, ToolFailure>
|
||||
readonly execute?: (
|
||||
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
|
||||
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
|
||||
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
readonly entries: Map<string, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
readonly list: () => ReadonlyArray<readonly [string, Entry]>
|
||||
readonly get: (name: string) => Entry | undefined
|
||||
readonly set: <Parameters extends ToolSchema<any>, Success extends ToolSchema<any>>(
|
||||
name: string,
|
||||
entry: Entry<Parameters, Success>,
|
||||
) => void
|
||||
readonly remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly definitions: (
|
||||
permissions?: PermissionV2.Ruleset,
|
||||
) => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
|
||||
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
|
||||
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
|
||||
}
|
||||
|
||||
export interface Settlement extends ToolSettlement {
|
||||
@@ -36,98 +94,153 @@ export interface Settlement extends ToolSettlement {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
|
||||
|
||||
const registryLayer = Layer.effect(
|
||||
enableMapSet()
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const permission = yield* PermissionV2.Service
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
type Registration = { readonly identity: object; readonly tool: AnyTool }
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: Registration }>>()
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ entries: new Map() }),
|
||||
editor: (draft) => ({
|
||||
list: () => Array.from(draft.entries.entries()) as Array<[string, Entry]>,
|
||||
get: (name) => draft.entries.get(name) as Entry | undefined,
|
||||
set: (name, entry) => {
|
||||
draft.entries.set(
|
||||
name,
|
||||
castDraft(entry) as typeof draft.entries extends Map<string, infer Value> ? Value : never,
|
||||
)
|
||||
},
|
||||
remove: (name) => {
|
||||
draft.entries.delete(name)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised?: object) {
|
||||
const registration =
|
||||
local.get(input.call.name)?.at(-1)?.registration ?? applications.entries().get(input.call.name)
|
||||
if (!registration)
|
||||
return {
|
||||
result: {
|
||||
type: "error" as const,
|
||||
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
|
||||
},
|
||||
}
|
||||
if (advertised && registration.identity !== advertised)
|
||||
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
|
||||
const pending = yield* settle(registration.tool, input.call, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
toolCallID: input.call.id,
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output })),
|
||||
const definitions = Effect.fn("ToolRegistry.definitions")(function* (permissions: PermissionV2.Ruleset = []) {
|
||||
const tools = new Map(state.get().entries)
|
||||
// Location tools own their names. Application tools fill otherwise-unclaimed names.
|
||||
for (const [name, tool] of applications.entries()) {
|
||||
if (!tools.has(name)) tools.set(name, { tool: tool.definition })
|
||||
}
|
||||
return Tool.toDefinitions(
|
||||
Object.fromEntries(
|
||||
Array.from(tools)
|
||||
.filter(([name, entry]) => !whollyDisabled(entry.permission ?? defaultPermission(name), permissions))
|
||||
.map(([name, entry]) => [name, entry.tool]),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const entry = (name: string): Entry | undefined => {
|
||||
const local = state.get().entries.get(name)
|
||||
if (local !== undefined) return local
|
||||
const tool = applications.entries().get(name)
|
||||
if (tool === undefined) return
|
||||
return {
|
||||
tool: tool.definition,
|
||||
execute: ({ parameters, sessionID, call }) =>
|
||||
tool.execute(parameters, { sessionID, id: call.id, name: call.name }),
|
||||
}
|
||||
}
|
||||
|
||||
const invocation = (input: ExecuteInput): Invocation => ({
|
||||
...input,
|
||||
// Source needs the durable owning assistant message ID, which the registry does not receive yet.
|
||||
assertPermission: (request) =>
|
||||
permission.assert({ ...request, sessionID: input.sessionID, ...(input.agent ? { agent: input.agent } : {}) }),
|
||||
})
|
||||
|
||||
const settleEntry = Effect.fn("ToolRegistry.settleEntry")(function* (
|
||||
entry: Entry | undefined,
|
||||
input: ExecuteInput,
|
||||
) {
|
||||
if (!entry) return { result: { type: "error" as const, value: `Unknown tool: ${input.call.name}` } }
|
||||
if (!entry.execute && !entry.tool.execute)
|
||||
return { result: { type: "error" as const, value: `Tool has no execute handler: ${input.call.name}` } }
|
||||
|
||||
return yield* entry.tool._decode(input.call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((parameters) => {
|
||||
const context = { ...invocation(input), parameters }
|
||||
const execute =
|
||||
entry.execute?.(context) ?? entry.tool.execute!(parameters, { id: input.call.id, name: input.call.name })
|
||||
return (
|
||||
entry.authorize === undefined ? execute : entry.authorize(context).pipe(Effect.andThen(execute))
|
||||
).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
entry.tool._encode(value).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its success schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map((value): Settlement => {
|
||||
const settled = (() => {
|
||||
if (entry.tool._legacyResult && ToolResult.is(value))
|
||||
return { result: value, output: ToolOutput.fromResultValue(value) }
|
||||
const output = entry.tool._project(parameters, input.call.id, value)
|
||||
const result = ToolOutput.toResultValue(output)
|
||||
return result.type === "error" ? { result } : { result, output }
|
||||
})()
|
||||
const retained = entry.outputPaths?.(value) ?? []
|
||||
return retained.length > 0 ? { ...settled, outputPaths: retained } : settled
|
||||
}),
|
||||
)
|
||||
}),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.succeed({ result: { type: "error" as const, value: failure.message } }),
|
||||
),
|
||||
)
|
||||
if ("result" in pending) return pending
|
||||
const output = pending.output
|
||||
const bounded = yield* resources.bound({ sessionID: input.sessionID, toolCallID: input.call.id, output })
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
if (result.type === "error")
|
||||
return bounded.outputPaths.length > 0 ? { result, outputPaths: bounded.outputPaths } : { result }
|
||||
return bounded.outputPaths.length > 0
|
||||
? { result, output: bounded.output, outputPaths: bounded.outputPaths }
|
||||
: { result, output: bounded.output }
|
||||
})
|
||||
|
||||
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* restore(settleEntry(entry(input.call.name), input))
|
||||
if (!settled.output) return settled
|
||||
const bounded = yield* resources.bound({
|
||||
sessionID: input.sessionID,
|
||||
toolCallID: input.call.id,
|
||||
output: settled.output,
|
||||
})
|
||||
if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled
|
||||
const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths]
|
||||
const result = ToolOutput.toResultValue(bounded.output)
|
||||
return result.type === "error"
|
||||
? { result, outputPaths: retained }
|
||||
: { result, output: bounded.output, outputPaths: retained }
|
||||
}),
|
||||
),
|
||||
)
|
||||
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
|
||||
return (yield* settle(input)).result
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("ToolRegistry.register")(function* (tools) {
|
||||
const entries = Object.entries(tools)
|
||||
if (entries.length === 0) return
|
||||
yield* Effect.forEach(entries, ([name]) => validateName(name), { discard: true })
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const [name, tool] of entries)
|
||||
local.set(name, [...(local.get(name) ?? []), { token, registration: { identity: {}, tool } }])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const [name] of entries) {
|
||||
const registrations = local.get(name)?.filter((registration) => registration.token !== token) ?? []
|
||||
if (registrations.length > 0) local.set(name, registrations)
|
||||
else local.delete(name)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
materialize: Effect.fn("ToolRegistry.materialize")(function* (permissions = []) {
|
||||
const registrations = new Map(applications.entries())
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (registration) registrations.set(name, registration)
|
||||
}
|
||||
for (const [name, registration] of registrations)
|
||||
if (whollyDisabled(permission(registration.tool, name), permissions)) registrations.delete(name)
|
||||
return {
|
||||
definitions: Array.from(registrations, ([name, registration]) => definition(name, registration.tool)),
|
||||
settle: (input) => {
|
||||
const registration = registrations.get(input.call.name)
|
||||
if (registration) return settleWith(input, registration.identity)
|
||||
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } })
|
||||
},
|
||||
}
|
||||
transform: state.transform,
|
||||
contribute: Effect.fn("ToolRegistry.contribute")(function* (update) {
|
||||
const transform = yield* state.transform()
|
||||
yield* transform(update)
|
||||
}),
|
||||
definitions,
|
||||
execute,
|
||||
settle,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Tools.Service,
|
||||
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
|
||||
).pipe(Layer.provideMerge(registryLayer))
|
||||
function defaultPermission(name: string) {
|
||||
return { action: ["edit", "write", "apply_patch"].includes(name) ? "edit" : name, resource: "*" as const }
|
||||
}
|
||||
|
||||
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
|
||||
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
|
||||
function whollyDisabled(permission: { readonly action: string; readonly resource: "*" }, rules: PermissionV2.Ruleset) {
|
||||
const rule = rules.findLast((rule) => Wildcard.match(permission.action, rule.action))
|
||||
return rule?.resource === "*" && rule.effect === "deny"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,27 @@ export * as SkillTool from "./skill"
|
||||
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "skill"
|
||||
const FILE_LIMIT = 10
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
name: Schema.String.annotate({ description: "The name of the skill from the available skills list" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
export const Success = Schema.Struct({
|
||||
name: Schema.String,
|
||||
directory: Schema.String,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
export const description = [
|
||||
@@ -56,50 +57,53 @@ const unableToLoad = (name: string, error?: unknown) =>
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
yield* boot.wait()
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.name === input.name)
|
||||
if (!skill) return yield* unableToLoad(input.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [skill.name],
|
||||
save: [skill.name],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: toModelOutput(skill, files),
|
||||
}
|
||||
}).pipe(Effect.mapError((error) => unableToLoad(input.name, error)))
|
||||
}),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* skills.list()
|
||||
const skill = current.find((skill) => skill.name === parameters.name)
|
||||
if (!skill) return yield* unableToLoad(parameters.name)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: [skill.name], save: [skill.name] })
|
||||
const directory = path.dirname(skill.location)
|
||||
const files =
|
||||
path.basename(skill.location) === "SKILL.md"
|
||||
? (yield* fs.glob("**/*", { cwd: directory, absolute: true, include: "file", dot: true }))
|
||||
.filter((file) => path.basename(file) !== "SKILL.md")
|
||||
.toSorted()
|
||||
.slice(0, FILE_LIMIT)
|
||||
: []
|
||||
const output = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content: toModelOutput(skill, files),
|
||||
})
|
||||
return {
|
||||
name: skill.name,
|
||||
directory,
|
||||
output: output.content,
|
||||
truncated: output.truncated,
|
||||
...(output.truncated ? { outputPath: output.outputPath } : {}),
|
||||
}
|
||||
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause)))))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,54 +1,50 @@
|
||||
export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "todowrite"
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
todos: Schema.Array(SessionTodo.Info).annotate({ description: "The updated todo list" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
export const Success = Schema.Struct({
|
||||
todos: Schema.Array(SessionTodo.Info),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Output) => JSON.stringify(output.todos, null, 2)
|
||||
export const toModelOutput = (output: Success) => JSON.stringify(output.todos, null, 2)
|
||||
|
||||
const definition = Tool.make({
|
||||
description:
|
||||
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const todos = yield* SessionTodo.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description:
|
||||
"Create and maintain a structured task list for the current coding session. Use it to track progress during multi-step work and keep todo statuses current.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: ["*"],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
|
||||
return { todos: input.todos }
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, sessionID, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* assertPermission({ action: name, resources: ["*"], save: ["*"] })
|
||||
yield* todos.update({ sessionID, todos: parameters.todos })
|
||||
return { todos: parameters.todos }
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(new ToolFailure({ message: "Unable to update todos", error: Cause.squash(cause) })),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
export * as Tool from "./tool"
|
||||
|
||||
import { ToolDefinition, ToolFailure, ToolOutput, type ToolCall } from "@opencode-ai/llm"
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import type { AgentV2 } from "../agent"
|
||||
import type { SessionMessage } from "../session/message"
|
||||
import type { SessionSchema } from "../session/schema"
|
||||
|
||||
export interface Context {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly agent: AgentV2.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly toolCallID: string
|
||||
}
|
||||
|
||||
export type SchemaType<A> = Schema.Codec<A, any, never, never>
|
||||
|
||||
declare const TypeId: unique symbol
|
||||
|
||||
export interface Definition<Input extends SchemaType<any>, Output extends SchemaType<any>> {
|
||||
readonly [TypeId]: {
|
||||
readonly _Input: Input
|
||||
readonly _Output: Output
|
||||
}
|
||||
}
|
||||
|
||||
export type AnyTool = Definition<any, any>
|
||||
export const Failure = ToolFailure
|
||||
export type Failure = ToolFailure
|
||||
|
||||
export class RegistrationError extends Schema.TaggedErrorClass<RegistrationError>()("Tool.RegistrationError", {
|
||||
name: Schema.String,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type Content =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string }
|
||||
|
||||
type Config<Input extends SchemaType<any>, Output extends SchemaType<any>> = {
|
||||
readonly description: string
|
||||
readonly input: Input
|
||||
readonly output: Output
|
||||
readonly execute: (
|
||||
input: Schema.Schema.Type<Input>,
|
||||
context: Context,
|
||||
) => Effect.Effect<Schema.Schema.Type<Output>, ToolFailure>
|
||||
readonly toModelOutput?: (input: {
|
||||
readonly input: Schema.Schema.Type<Input>
|
||||
readonly output: Output["Encoded"]
|
||||
}) => ReadonlyArray<Content>
|
||||
}
|
||||
|
||||
type Runtime = {
|
||||
readonly permission?: string
|
||||
readonly definition: (name: string) => ToolDefinition
|
||||
readonly settle: (call: ToolCall, context: Context) => Effect.Effect<ToolOutput, ToolFailure>
|
||||
}
|
||||
|
||||
const runtimes = new WeakMap<AnyTool, Runtime>()
|
||||
|
||||
export function make<Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
config: Config<Input, Output>,
|
||||
): Definition<Input, Output> {
|
||||
const tool = Object.freeze({}) as Definition<Input, Output>
|
||||
const definitions = new Map<string, ToolDefinition>()
|
||||
runtimes.set(tool, {
|
||||
definition: (name) => {
|
||||
const cached = definitions.get(name)
|
||||
if (cached) return cached
|
||||
const definition = new ToolDefinition({
|
||||
name,
|
||||
description: config.description,
|
||||
inputSchema: toJsonSchema(config.input),
|
||||
outputSchema: toJsonSchema(config.output),
|
||||
})
|
||||
definitions.set(name, definition)
|
||||
return definition
|
||||
},
|
||||
settle: (call, context) =>
|
||||
Schema.decodeUnknownEffect(config.input)(call.input).pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Invalid tool input: ${error.message}` })),
|
||||
Effect.flatMap((input) =>
|
||||
config.execute(input, context).pipe(
|
||||
Effect.flatMap((output) =>
|
||||
Schema.encodeEffect(config.output)(output).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `Tool returned an invalid value for its output schema: ${error.message}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map((output) =>
|
||||
ToolOutput.make(
|
||||
output,
|
||||
config.toModelOutput?.({ input, output }).map((part) =>
|
||||
part.type === "text"
|
||||
? { type: "text" as const, text: part.text }
|
||||
: {
|
||||
type: "file" as const,
|
||||
source: { type: "data" as const, data: part.data },
|
||||
mime: part.mime,
|
||||
name: part.name,
|
||||
},
|
||||
) ?? (typeof output === "string" ? [{ type: "text", text: output }] : []),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
})
|
||||
return tool
|
||||
}
|
||||
|
||||
export const validateName = (name: string) =>
|
||||
/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(name)
|
||||
? Effect.void
|
||||
: Effect.fail(new RegistrationError({ name, message: `Invalid tool name: ${name}` }))
|
||||
|
||||
export const withPermission = <Input extends SchemaType<any>, Output extends SchemaType<any>>(
|
||||
tool: Definition<Input, Output>,
|
||||
permission: string,
|
||||
) => {
|
||||
const decorated = Object.freeze({}) as Definition<Input, Output>
|
||||
runtimes.set(decorated, { ...runtimeOf(tool), permission })
|
||||
return decorated
|
||||
}
|
||||
|
||||
export const permission = (tool: AnyTool, name: string) => runtimeOf(tool).permission ?? name
|
||||
export const definition = (name: string, tool: AnyTool) => runtimeOf(tool).definition(name)
|
||||
export const settle = (tool: AnyTool, call: ToolCall, context: Context) => runtimeOf(tool).settle(call, context)
|
||||
|
||||
function runtimeOf(tool: AnyTool) {
|
||||
const runtime = runtimes.get(tool)
|
||||
if (!runtime) throw new TypeError("Invalid Core Tool value")
|
||||
return runtime
|
||||
}
|
||||
|
||||
function toJsonSchema(schema: Schema.Top): JsonSchema.JsonSchema {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
if (Object.keys(document.definitions).length === 0) return document.schema
|
||||
return { ...document.schema, $defs: document.definitions }
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export * as Tools from "./tools"
|
||||
|
||||
import { Context, Effect, Scope } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
tools: Readonly<Record<string, Tool.AnyTool>>,
|
||||
) => Effect.Effect<void, Tool.RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
/** Narrow registration-only Location capability. */
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Tools") {}
|
||||
@@ -1,13 +1,12 @@
|
||||
export * as WebFetchTool from "./webfetch"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Duration, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "webfetch"
|
||||
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
|
||||
@@ -16,11 +15,11 @@ export const MAX_TIMEOUT_SECONDS = 120
|
||||
|
||||
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
|
||||
|
||||
Use a more targeted tool when one is available. This tool is read-only. Large text results may be replaced with a preview while the complete output is retained in managed storage.`
|
||||
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
|
||||
|
||||
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The HTTP or HTTPS URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.annotate({ description: "The format to return the content in. Defaults to markdown." })
|
||||
@@ -30,14 +29,16 @@ export const Input = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
const Success = Schema.Struct({
|
||||
url: Schema.String,
|
||||
contentType: Schema.String,
|
||||
format: Input.fields.format,
|
||||
format: Parameters.fields.format,
|
||||
output: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
type Format = (typeof Input.Type)["format"]
|
||||
type Format = (typeof Parameters.Type)["format"]
|
||||
|
||||
const acceptHeader = (format: Format) => {
|
||||
switch (format) {
|
||||
@@ -48,7 +49,6 @@ const acceptHeader = (format: Format) => {
|
||||
case "html":
|
||||
return "text/html;q=1.0, application/xhtml+xml;q=0.9, text/plain;q=0.8, text/markdown;q=0.7, */*;q=0.1"
|
||||
}
|
||||
return "*/*"
|
||||
}
|
||||
|
||||
const headers = (format: Format, userAgent: string) => ({
|
||||
@@ -89,17 +89,15 @@ const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
if (contentLength && Number.parseInt(contentLength, 10) > MAX_RESPONSE_BYTES) {
|
||||
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
||||
return yield* Effect.die(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
||||
}
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
yield* Stream.runForEach(response.stream, (chunk) =>
|
||||
Effect.gen(function* () {
|
||||
Effect.sync(() => {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_RESPONSE_BYTES)
|
||||
return yield* Effect.fail(new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`))
|
||||
if (size > MAX_RESPONSE_BYTES) throw new Error(`Response too large (exceeds ${MAX_RESPONSE_BYTES} byte limit)`)
|
||||
chunks.push(chunk)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
return Buffer.concat(chunks, size)
|
||||
@@ -117,6 +115,9 @@ const isTextualMime = (mime: string) =>
|
||||
mime.endsWith("+xml") ||
|
||||
mime === "application/javascript" ||
|
||||
mime === "application/x-javascript"
|
||||
const outputMime = (format: Format) =>
|
||||
format === "markdown" ? "text/markdown" : format === "html" ? "text/html" : "text/plain"
|
||||
|
||||
const convert = (content: string, contentType: string, format: Format) => {
|
||||
if (!contentType.includes("text/html")) return content
|
||||
if (format === "markdown") return convertHTMLToMarkdown(content)
|
||||
@@ -124,64 +125,71 @@ const convert = (content: string, contentType: string, format: Format) => {
|
||||
return content
|
||||
}
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* PermissionV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.output })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.try({
|
||||
try: () => assertHttpUrl(new URL(input.url)),
|
||||
catch: (error) => error,
|
||||
})
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const parsed = new URL(parameters.url)
|
||||
assertHttpUrl(parsed)
|
||||
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.url],
|
||||
save: ["*"],
|
||||
metadata: input,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* assertPermission({ action: name, resources: [parameters.url], save: ["*"], metadata: parameters })
|
||||
|
||||
const { body, contentType } = yield* Effect.gen(function* () {
|
||||
const response = yield* execute(http, input.url, input.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () => execute(http, input.url, input.format, "opencode")),
|
||||
)
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched image content type: ${mime}`))
|
||||
if (!isTextualMime(mime))
|
||||
return yield* Effect.fail(new Error(`Unsupported fetched file content type: ${mime}`))
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(input.timeout ?? DEFAULT_TIMEOUT_SECONDS),
|
||||
orElse: () => Effect.fail(new Error("Request timed out")),
|
||||
}),
|
||||
const { body, contentType } = yield* Effect.gen(function* () {
|
||||
const response = yield* execute(http, parameters.url, parameters.format).pipe(
|
||||
Effect.catchIf(isCloudflareChallenge, () =>
|
||||
execute(http, parameters.url, parameters.format, "opencode"),
|
||||
),
|
||||
)
|
||||
const content = convert(new TextDecoder().decode(body), contentType, input.format)
|
||||
return {
|
||||
url: input.url,
|
||||
contentType,
|
||||
format: input.format,
|
||||
output: content,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))),
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const contentType = response.headers["content-type"] || ""
|
||||
const mime = mimeFrom(contentType)
|
||||
if (isImageAttachment(mime)) throw new Error(`Unsupported fetched image content type: ${mime}`)
|
||||
if (!isTextualMime(mime)) throw new Error(`Unsupported fetched file content type: ${mime}`)
|
||||
return { body: yield* collectBody(response), contentType }
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(parameters.timeout ?? DEFAULT_TIMEOUT_SECONDS),
|
||||
orElse: () => Effect.die(new Error("Request timed out")),
|
||||
}),
|
||||
)
|
||||
const content = convert(new TextDecoder().decode(body), contentType, parameters.format)
|
||||
const truncated = yield* resources.truncate({
|
||||
sessionID,
|
||||
toolCallID: call.id,
|
||||
content,
|
||||
mime: outputMime(parameters.format),
|
||||
})
|
||||
return {
|
||||
url: parameters.url,
|
||||
contentType,
|
||||
format: parameters.format,
|
||||
output: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to fetch ${parameters.url}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
export * as WebSearchTool from "./websearch"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolOutputStore } from "../tool-output-store"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { checksum } from "../util/encode"
|
||||
|
||||
export const name = "websearch"
|
||||
@@ -33,7 +32,7 @@ Optional controls support result count, live crawling ('fallback' or 'preferred'
|
||||
|
||||
The current year is ${new Date().getFullYear()}. Use this year when searching for recent information or current events.`
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
numResults: Schema.optional(PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_NUM_RESULTS))).annotate({
|
||||
description: `Number of search results to return (default: 8, maximum: ${MAX_NUM_RESULTS})`,
|
||||
@@ -166,81 +165,95 @@ const callMcp = <F extends Schema.Struct.Fields>(
|
||||
const response = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
const body = yield* response.text
|
||||
if (Buffer.byteLength(body, "utf8") > MAX_RESPONSE_BYTES)
|
||||
return yield* Effect.fail(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
|
||||
return yield* Effect.die(new Error(`${tool} response exceeded ${MAX_RESPONSE_BYTES} bytes`))
|
||||
return yield* parseResponse(body)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error(`${tool} request timed out`)),
|
||||
orElse: () => Effect.die(new Error(`${tool} request timed out`)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const Output = Schema.Struct({
|
||||
const Success = Schema.Struct({
|
||||
provider: Provider,
|
||||
text: Schema.String,
|
||||
truncated: Schema.Boolean,
|
||||
outputPath: Schema.String.pipe(Schema.optional),
|
||||
})
|
||||
|
||||
const definition = Tool.make({
|
||||
description,
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
|
||||
})
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const config = yield* ConfigService
|
||||
const permission = yield* PermissionV2.Service
|
||||
const resources = yield* ToolOutputStore.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[name]: Tool.make({
|
||||
description,
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: output.text })],
|
||||
execute: (input, context) => {
|
||||
const provider = selectProvider(context.sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.query],
|
||||
save: ["*"],
|
||||
metadata: { ...input, provider },
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
|
||||
execute: ({ parameters, sessionID, call, assertPermission }) => {
|
||||
const provider = selectProvider(sessionID, config, config.provider)
|
||||
return Effect.gen(function* () {
|
||||
yield* assertPermission({
|
||||
action: name,
|
||||
resources: [parameters.query],
|
||||
save: ["*"],
|
||||
metadata: { ...parameters, provider },
|
||||
})
|
||||
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: input.query,
|
||||
type: input.type || "auto",
|
||||
numResults: input.numResults || 8,
|
||||
livecrawl: input.livecrawl || "fallback",
|
||||
contextMaxCharacters: input.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: input.query,
|
||||
search_queries: [input.query],
|
||||
session_id: context.sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
return {
|
||||
provider,
|
||||
text: text ?? NO_RESULTS,
|
||||
}
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })))
|
||||
},
|
||||
}),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const text =
|
||||
provider === "exa"
|
||||
? yield* callMcp(http, exaUrl(config.exaApiKey), "web_search_exa", ExaArgs, {
|
||||
query: parameters.query,
|
||||
type: parameters.type || "auto",
|
||||
numResults: parameters.numResults || 8,
|
||||
livecrawl: parameters.livecrawl || "fallback",
|
||||
contextMaxCharacters: parameters.contextMaxCharacters,
|
||||
})
|
||||
: yield* callMcp(
|
||||
http,
|
||||
PARALLEL_URL,
|
||||
"web_search",
|
||||
ParallelArgs,
|
||||
{
|
||||
objective: parameters.query,
|
||||
search_queries: [parameters.query],
|
||||
session_id: sessionID,
|
||||
// V2 invocation context does not safely expose the model yet.
|
||||
},
|
||||
{
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
...(config.parallelApiKey ? { Authorization: `Bearer ${config.parallelApiKey}` } : {}),
|
||||
},
|
||||
)
|
||||
const truncated = yield* resources.truncate({ sessionID, toolCallID: call.id, content: text ?? NO_RESULTS })
|
||||
return {
|
||||
provider,
|
||||
text: truncated.content,
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({
|
||||
message: `Unable to search the web for ${parameters.query}`,
|
||||
error: Cause.squash(cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,18 +7,16 @@
|
||||
*/
|
||||
export * as WriteTool from "./write"
|
||||
|
||||
import { ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Tool, ToolFailure, toolText } from "@opencode-ai/llm"
|
||||
import { Cause, Effect, Layer, Schema } from "effect"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
|
||||
export const Input = Schema.Struct({
|
||||
export const Parameters = 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. Named project references are read-oriented and are not accepted.",
|
||||
@@ -26,17 +24,25 @@ export const Input = Schema.Struct({
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
export const Success = Schema.Struct({
|
||||
operation: Schema.Literal("write"),
|
||||
target: Schema.String,
|
||||
resource: Schema.String,
|
||||
existed: Schema.Boolean,
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const toModelOutput = (output: Output) =>
|
||||
export const toModelOutput = (output: Success) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
const definition = 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. Named project references are read-oriented and are not accepted.",
|
||||
parameters: Parameters,
|
||||
success: Success,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
})
|
||||
|
||||
/** Deferred V2 write UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after V2 formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after V2 watcher integration exists.
|
||||
@@ -45,50 +51,28 @@ export const toModelOutput = (output: Output) =>
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
.register({
|
||||
[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. Named project references are read-oriented and are not accepted.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const source = {
|
||||
type: "tool" as const,
|
||||
messageID: context.assistantMessageID,
|
||||
callID: context.toolCallID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
return yield* files.writeTextPreservingBom({ target, content: input.content })
|
||||
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))),
|
||||
}),
|
||||
"edit",
|
||||
),
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
yield* registry.contribute((editor) =>
|
||||
editor.set(name, {
|
||||
tool: definition,
|
||||
execute: ({ parameters, assertPermission }) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: parameters.path, kind: "file" })
|
||||
const external = target.externalDirectory
|
||||
if (external) yield* assertPermission(LocationMutation.externalDirectoryPermission(external))
|
||||
yield* assertPermission({ action: "edit", resources: [target.resource], save: ["*"] })
|
||||
return yield* files.writeTextPreservingBom({ target, content: parameters.content })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ToolFailure({ message: `Unable to write ${parameters.path}`, error: Cause.squash(cause) }),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("AgentV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes a transform when its scope closes", () =>
|
||||
it.effect("removes a transform contribution when its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* AgentV2.Service
|
||||
const id = AgentV2.ID.make("scoped")
|
||||
|
||||
@@ -3,13 +3,9 @@ import { Tool } from "@opencode-ai/core/public"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Tools } from "@opencode-ai/core/tool/tools"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const permission = Layer.mock(PermissionV2.Service, {
|
||||
@@ -24,13 +20,11 @@ const registry = ToolRegistry.layer.pipe(
|
||||
const it = testEffect(Layer.mergeAll(applications, registry))
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_application_tool")
|
||||
const agent = AgentV2.ID.make("build")
|
||||
const assistantMessageID = SessionMessage.ID.make("msg_application_tool")
|
||||
const contextual = (contexts: Tool.Context[]) =>
|
||||
Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.Struct({ answer: Schema.String }),
|
||||
parameters: Schema.Struct({ query: Schema.String }),
|
||||
success: Schema.Struct({ answer: Schema.String }),
|
||||
execute: ({ query }, context) =>
|
||||
Effect.sync(() => {
|
||||
contexts.push(context)
|
||||
@@ -43,69 +37,23 @@ const contextual = (contexts: Tool.Context[]) =>
|
||||
})
|
||||
|
||||
describe("ApplicationTools", () => {
|
||||
it.effect("keeps the Core carrier opaque and executes its single handler", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
const tool = contextual(contexts)
|
||||
expect(Object.keys(tool)).toEqual([])
|
||||
|
||||
yield* applications.register({ opaque: tool })
|
||||
expect(
|
||||
yield* executeTool(registry, {
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-opaque", name: "opaque", input: { query: "once" } },
|
||||
}),
|
||||
).toEqual({
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "ONCE" },
|
||||
{ type: "media", mediaType: "image/png", data: "aGVsbG8=", filename: "result.png" },
|
||||
],
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-opaque" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes narrow scoped Location registration and validates names", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools: Tools.Interface = yield* Tools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* tools.register({ location_tool: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["location_tool"])
|
||||
expect(yield* Effect.flip(tools.register({ "invalid name": contextual([]) }))).toBeInstanceOf(
|
||||
Tool.RegistrationError,
|
||||
)
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters an application tool by its name without adding execution authorization", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
yield* applications.attach({ application_context: contextual(contexts) })
|
||||
|
||||
expect(yield* registry.definitions([{ action: "application_context", resource: "*", effect: "deny" }])).toEqual(
|
||||
[],
|
||||
)
|
||||
expect(
|
||||
yield* toolDefinitions(registry, [{ action: "application_context", resource: "*", effect: "deny" }]),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-denied", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-denied" }])
|
||||
expect(contexts).toEqual([{ sessionID, id: "call-denied", name: "application_context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -115,16 +63,14 @@ describe("ApplicationTools", () => {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const contexts: Tool.Context[] = []
|
||||
|
||||
yield* applications.register({ application_context: contextual(contexts) })
|
||||
yield* applications.attach({ application_context: contextual(contexts) })
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toMatchObject([
|
||||
expect(yield* registry.definitions()).toMatchObject([
|
||||
{ name: "application_context", description: "Read application context" },
|
||||
])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-context", name: "application_context", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -143,21 +89,21 @@ describe("ApplicationTools", () => {
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(contexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-context" }])
|
||||
expect(contexts).toEqual([{ sessionID, id: "call-context", name: "application_context" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes an application tool when its registration scope closes", () =>
|
||||
it.effect("removes an application tool when its attachment scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* applications.register({ temporary: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["temporary"])
|
||||
yield* applications.attach({ temporary: contextual([]) }).pipe(Scope.provide(scope))
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["temporary"])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
expect(yield* registry.definitions()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -165,99 +111,70 @@ describe("ApplicationTools", () => {
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registrationScope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual([]) }).pipe(Scope.provide(registrationScope))
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
const attachmentScope = yield* Scope.make()
|
||||
yield* applications.attach({ contextual: contextual([]) }).pipe(Scope.provide(attachmentScope))
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
|
||||
yield* Scope.close(registrationScope, Exit.void)
|
||||
yield* Scope.close(attachmentScope, Exit.void)
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-removed", name: "contextual", input: { query: "hello" } },
|
||||
}),
|
||||
).toEqual({ result: { type: "error", value: "Unknown tool: contextual" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not leak a registration into an already closed scope", () =>
|
||||
it.effect("does not leak an attachment into an already closed scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
|
||||
yield* applications.register({ closed: contextual([]) }).pipe(Scope.provide(scope))
|
||||
yield* applications.attach({ closed: contextual([]) }).pipe(Scope.provide(scope))
|
||||
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
expect(yield* registry.definitions()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an interrupted application registration until its scope closes", () =>
|
||||
it.effect("captures the attached record before later State rebuilds", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const scope = yield* Scope.make()
|
||||
const registered = yield* Deferred.make<void>()
|
||||
const fiber = yield* applications
|
||||
.register({ interrupted: contextual([]) })
|
||||
.pipe(
|
||||
Effect.andThen(Deferred.succeed(registered, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
Scope.provide(scope),
|
||||
Effect.forkChild,
|
||||
)
|
||||
yield* Deferred.await(registered)
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const attached = { stable: contextual([]) }
|
||||
yield* applications.attach(attached)
|
||||
Object.assign(attached, { late: contextual([]) })
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["interrupted"])
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* toolDefinitions(registry)).toEqual([])
|
||||
yield* Effect.scoped(applications.attach({ temporary: contextual([]) }))
|
||||
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["stable"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("captures the registered record before later State rebuilds", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const registered = { stable: contextual([]) }
|
||||
yield* applications.register(registered)
|
||||
Object.assign(registered, { late: contextual([]) })
|
||||
|
||||
yield* Effect.scoped(applications.register({ temporary: contextual([]) }))
|
||||
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["stable"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles with the current same-name application tool and restores earlier registrations", () =>
|
||||
it.effect("settles with the current same-name application tool and restores earlier attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const firstContexts: Tool.Context[] = []
|
||||
const secondContexts: Tool.Context[] = []
|
||||
const scope = yield* Scope.make()
|
||||
yield* applications.register({ contextual: contextual(firstContexts) })
|
||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
yield* applications.register({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
|
||||
yield* applications.attach({ contextual: contextual(firstContexts) })
|
||||
expect((yield* registry.definitions()).map((tool) => tool.name)).toEqual(["contextual"])
|
||||
yield* applications.attach({ contextual: contextual(secondContexts) }).pipe(Scope.provide(scope))
|
||||
|
||||
yield* settleTool(registry, {
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-second", name: "contextual", input: { query: "second" } },
|
||||
})
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* settleTool(registry, {
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-first", name: "contextual", input: { query: "first" } },
|
||||
})
|
||||
|
||||
expect(secondContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-second" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-first" }])
|
||||
expect(secondContexts).toEqual([{ sessionID, id: "call-second", name: "contextual" }])
|
||||
expect(firstContexts).toEqual([{ sessionID, id: "call-first", name: "contextual" }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -265,26 +182,32 @@ describe("ApplicationTools", () => {
|
||||
Effect.gen(function* () {
|
||||
const applications = yield* ApplicationTools.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const transform = yield* registry.transform()
|
||||
const locationContexts: Tool.Context[] = []
|
||||
const applicationContexts: Tool.Context[] = []
|
||||
const location = contextual(locationContexts)
|
||||
yield* registry.register({ shared: location })
|
||||
yield* applications.register({ shared: contextual(applicationContexts) })
|
||||
yield* transform((editor) =>
|
||||
editor.set("shared", {
|
||||
tool: location.definition,
|
||||
permission: { action: "question", resource: "*" },
|
||||
execute: ({ parameters, sessionID, call }) =>
|
||||
location.execute(parameters, { sessionID, id: call.id, name: call.name }),
|
||||
}),
|
||||
)
|
||||
yield* applications.attach({ shared: contextual(applicationContexts) })
|
||||
|
||||
expect(
|
||||
(yield* toolDefinitions(registry, [{ action: "shared", resource: "*", effect: "deny" }])).map(
|
||||
(yield* registry.definitions([{ action: "question", resource: "*", effect: "deny" }])).map(
|
||||
(definition) => definition.name,
|
||||
),
|
||||
).toEqual([])
|
||||
expect(
|
||||
yield* settleTool(registry, {
|
||||
yield* registry.settle({
|
||||
sessionID,
|
||||
agent,
|
||||
assistantMessageID,
|
||||
call: { type: "tool-call", id: "call-shared", name: "shared", input: { query: "location" } },
|
||||
}),
|
||||
).toMatchObject({ result: { type: "content" } })
|
||||
expect(locationContexts).toEqual([{ sessionID, agent, assistantMessageID, toolCallID: "call-shared" }])
|
||||
expect(locationContexts).toEqual([{ sessionID, id: "call-shared", name: "shared" }])
|
||||
expect(applicationContexts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const toolIdentity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_tool_test"),
|
||||
}
|
||||
|
||||
export const toolDefinitions = (
|
||||
registry: ToolRegistry.Interface,
|
||||
permissions?: Parameters<typeof registry.materialize>[0],
|
||||
) => registry.materialize(permissions).pipe(Effect.map((materialized) => materialized.definitions))
|
||||
|
||||
export const settleTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
|
||||
registry.materialize().pipe(Effect.flatMap((materialized) => materialized.settle(input)))
|
||||
|
||||
export const executeTool = (registry: ToolRegistry.Interface, input: ToolRegistry.ExecuteInput) =>
|
||||
settleTool(registry, input).pipe(Effect.map((settlement) => settlement.result))
|
||||
@@ -10,7 +10,6 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { FSUtil } from "../src/fs-util"
|
||||
import { Auth } from "../src/auth"
|
||||
import { EventV2 } from "../src/event"
|
||||
@@ -51,11 +50,11 @@ describe("LocationServiceMap", () => {
|
||||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* (yield* ApplicationTools.Service).register({
|
||||
yield* (yield* ApplicationTools.Service).attach({
|
||||
application_context: Tool.make({
|
||||
description: "Read application context",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
@@ -78,7 +77,7 @@ describe("LocationServiceMap", () => {
|
||||
yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* toolDefinitions(yield* ToolRegistry.Service),
|
||||
tools: yield* (yield* ToolRegistry.Service).definitions(),
|
||||
}
|
||||
}).pipe(Effect.scoped, Effect.provide(LocationServiceMap.get({ directory: AbsolutePath.make(directory) })))
|
||||
|
||||
|
||||
@@ -161,21 +161,6 @@ describe("PermissionV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows managed output reads without granting external directory access", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
const service = yield* PermissionV2.Service
|
||||
|
||||
expect(yield* service.ask(assertion({ resources: ["tool_123"] }))).toMatchObject({ effect: "allow" })
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "external_directory", resources: ["/tmp/tool-output/*"] })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses build permissions when the Session agent is omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("PluginV2", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("serializes same-ID additions and leaves one removable attachment", () =>
|
||||
it.effect("serializes same-ID additions and leaves one removable contribution", () =>
|
||||
Effect.gen(function* () {
|
||||
const values = state()
|
||||
const layerScope = yield* Scope.fork(yield* Scope.Scope)
|
||||
|
||||
@@ -30,11 +30,11 @@ describe("public native OpenCode API", () => {
|
||||
expect(Session.ID.create()).toStartWith("ses_")
|
||||
expect(Session.MessageID.create()).toStartWith("msg_")
|
||||
expect(yield* opencode.sessions.list()).toBeArray()
|
||||
yield* opencode.tools.register({
|
||||
yield* opencode.tools.attach({
|
||||
public_tool: Tool.make({
|
||||
description: "Public tool",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({ ok: Schema.Boolean }),
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({ ok: Schema.Boolean }),
|
||||
execute: () => Effect.succeed({ ok: true }),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/public"
|
||||
import { Effect } from "effect"
|
||||
|
||||
describe("public Tool API", () => {
|
||||
it("keeps the public registration capability narrow", () => {
|
||||
const tools = {
|
||||
register: () => Effect.void,
|
||||
} satisfies Tool.Interface
|
||||
|
||||
expect(Object.keys(tools)).toEqual(["register"])
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user