mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-23 01:28:37 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee1e7acd | |||
| 4ed3afea84 | |||
| 3cf7c7536b | |||
| 85674f4bfd | |||
| f2525a63c9 | |||
| 8c42d391f5 | |||
| 7f9bf91073 | |||
| 6ce5c01b1a | |||
| a53fae1511 | |||
| 4626458175 | |||
| 9a5178e4ac | |||
| 68384613be | |||
| 4f967d5bc0 | |||
| ff60859e36 | |||
| 020c47a055 | |||
| 64171db173 | |||
| ad265797ab | |||
| b1312a3181 | |||
| a8f9f6b705 | |||
| d312c677c5 | |||
| 5b60e51c9f | |||
| 7cbe1627ec | |||
| d6840868d4 | |||
| 9b2648dd57 | |||
| f954854232 | |||
| 6a99079012 | |||
| 0a8b6298cd | |||
| f40209bdfb | |||
| a2cb4909da | |||
| 7a05ba47d1 | |||
| 36745caa2a | |||
| c2403d0f15 | |||
| 34e2429c49 | |||
| 10ba68c772 |
@@ -3,4 +3,5 @@ plans
|
||||
package.json
|
||||
bun.lock
|
||||
.gitignore
|
||||
package-lock.json
|
||||
package-lock.json
|
||||
references/
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: effect
|
||||
description: Answer questions about the Effect framework
|
||||
---
|
||||
|
||||
# Effect
|
||||
|
||||
This codebase uses Effect, a framework for writing typescript.
|
||||
|
||||
## How to Answer Effect Questions
|
||||
|
||||
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
|
||||
`.opencode/references/effect-smol` in this project NOT the skill folder.
|
||||
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
|
||||
3. Provide responses based on the actual Effect source code and documentation
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always use the explore agent with the cloned repository when answering Effect-related questions
|
||||
- Reference specific files and patterns found in the Effect codebase
|
||||
- Do not answer from memory - always verify against the source
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
"dark": "#abafb7",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
|
||||
@@ -358,8 +358,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "0.1.97",
|
||||
"@opentui/solid": "0.1.97",
|
||||
"@opentui/core": "0.1.99",
|
||||
"@opentui/solid": "0.1.99",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -386,6 +386,7 @@
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
@@ -396,6 +397,7 @@
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"ripgrep": "0.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
@@ -455,16 +457,16 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentui/core": "0.1.97",
|
||||
"@opentui/solid": "0.1.97",
|
||||
"@opentui/core": "0.1.99",
|
||||
"@opentui/solid": "0.1.99",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.97",
|
||||
"@opentui/solid": ">=0.1.97",
|
||||
"@opentui/core": ">=0.1.99",
|
||||
"@opentui/solid": ">=0.1.99",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -1545,21 +1547,21 @@
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.1.97", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.97", "@opentui/core-darwin-x64": "0.1.97", "@opentui/core-linux-arm64": "0.1.97", "@opentui/core-linux-x64": "0.1.97", "@opentui/core-win32-arm64": "0.1.97", "@opentui/core-win32-x64": "0.1.97", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-2ENH0Dc4NUAeHeeQCQhF1lg68RuyntOUP68UvortvDqTz/hqLG0tIwF+DboCKtWi8Nmao4SAQEJ7lfmyQNEDOQ=="],
|
||||
"@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-t7oMGEfMPQsqLEx7/rPqv/UGJ+vqhe4RWHRRQRYcuHuLKssZ2S8P9mSS7MBPtDqGcxg4PosCrh5nHYeZ94EXUw=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.99", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bzVrqeX2vb5iWrc/ftOUOqeUY8XO+qSgoTwj5TXHuwagavgwD3Hpeyjx8+icnTTeM4pao0som1WR9xfye6/X5Q=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZuPWAawlVat6ZHb8vaH/CVUeGwI0pI4vd+6zz1ZocZn95ZWJztfyhzNZOJrq1WjHmUROieJ7cOuYUZfvYNuLrg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.99", "", { "os": "darwin", "cpu": "x64" }, "sha512-VE4FrXBYpkxnvkqcCV1a8aN9jyyMJMihVW+V2NLCtp+4yQsj0AapG5TiUSN76XnmSZRptxDy5rBmEempeoIZbg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXxhz654vXgEu2wrFFFFnrSWbyk6/r6nXNnDTcMRWofdMZQLx87NhbcsErNmz9KmFdzoPiQSmlpYubLflKKzqQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.99", "", { "os": "linux", "cpu": "arm64" }, "sha512-viXQsbpS7yHjYkl7+am32JdvG96QU9lvHh1UiZtpOxcNUUqiYmA2ZwZFPD2Bi54jNyj5l2hjH6YkD3DzE2FEWA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-v3z0QWpRS3p8blE/A7pTu15hcFMtSndeiYhRxhrjp6zAhQ+UlruQs9DAG1ifSuVO1RJJ0pUKklFivdbu0pMzuw=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.99", "", { "os": "linux", "cpu": "x64" }, "sha512-WLoEFINOSp0tZSR9y4LUuGc7n4Y7H1wcpjUPzQ9vChkYDXrfZltEanzoDWbDcQ4kZQW5tHVC7LrZHpAsRLwFZg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-o/m9mD1dvOCwkxOUUyoEILl+d6tzh/85foJc4uqjXYi71NNcwg8u+Eq3/gdHuSKnlT1pusCPKoS1IDuBvZE24A=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.99", "", { "os": "win32", "cpu": "arm64" }, "sha512-yWMOLWCEO8HdrctU1dMkgZC8qGkiO4Dwr4/e11tTvVpRmYhDsP/IR89ZjEEtOwnKwFOFuB/MxvflqaEWVQ2g5Q=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-Rwp7JOwrYm4wtzPHY2vv+2l91LXmKSI7CtbmWN1sSUGhBPtPGSvfwux3W5xaAZQa2KPEXicPjaKJZc+pob3YRg=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.99", "", { "os": "win32", "cpu": "x64" }, "sha512-aYRlsL2w8YRL6vPd7/hrqlNVkXU3QowWb01TOvAcHS8UAsXaGFUr47kSDyjxDi1wg1MzmVduCfsC7T3NoThV1w=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.1.97", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.97", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-ma/uihG38F+6oLJVD8yR7z82FWmR8QhfesNV5SBXbN74riMCRyy6kyQ6SI4xs4ykt9BbZOjrKLq+Xt/0Pd0SJQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.1.99", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.99", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-DrqqO4h2V88FmeIP2cErYkMU0ZK5MrUsZw3w6IzZpoXyyiL4/9qpWzUq+CXx+r16VP2iGxDJwGKUmtFAzUch2Q=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3335,6 +3337,8 @@
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -4345,6 +4349,8 @@
|
||||
|
||||
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||
|
||||
"ripgrep": ["ripgrep@0.3.1", "", { "bin": { "rg": "lib/rg.mjs", "ripgrep": "lib/rg.mjs" } }, "sha512-6bDtNIBh1qPviVIU685/4uv0Ap5t8eS4wiJhy/tR2LdIeIey9CVasENlGS+ul3HnTmGANIp7AjnfsztsRmALfQ=="],
|
||||
|
||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||
|
||||
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
|
||||
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
|
||||
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
|
||||
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
|
||||
"x86_64-linux": "sha256-1qeXIfqPCo5Px0ebdTvL09kav5Ib79E35Ed1FgiLnPY=",
|
||||
"aarch64-linux": "sha256-987XZxDqDf6gm0kjXg606BSEYvryrUD8vZopsADQIN8=",
|
||||
"aarch64-darwin": "sha256-RI3D3bPxotudTWmdQNIPZ/oBrmDl5PAdJGc93M4bKHs=",
|
||||
"x86_64-darwin": "sha256-LE6hrDPXWKjcePc1nv+O6tIN0ZXUrzWI1XBN6Fm/NKw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"generate:check": "bun ./script/check-generate.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
|
||||
@@ -115,8 +115,8 @@
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||
"@opentui/core": "0.1.97",
|
||||
"@opentui/solid": "0.1.97",
|
||||
"@opentui/core": "0.1.99",
|
||||
"@opentui/solid": "0.1.99",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@pierre/diffs": "catalog:",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
@@ -143,6 +143,7 @@
|
||||
"hono": "catalog:",
|
||||
"hono-openapi": "catalog:",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
@@ -153,6 +154,7 @@
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"ripgrep": "0.3.1",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
|
||||
@@ -187,6 +187,7 @@ for (const item of targets) {
|
||||
const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
|
||||
const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath)
|
||||
const workerPath = "./src/cli/cmd/tui/worker.ts"
|
||||
const rgPath = "./src/file/ripgrep.worker.ts"
|
||||
|
||||
// Use platform-specific bunfs root path based on target OS
|
||||
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
|
||||
@@ -197,6 +198,9 @@ for (const item of targets) {
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [plugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
splitting: true,
|
||||
compile: {
|
||||
autoloadBunfig: false,
|
||||
autoloadDotenv: false,
|
||||
@@ -210,12 +214,19 @@ for (const item of targets) {
|
||||
files: {
|
||||
...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}),
|
||||
},
|
||||
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
|
||||
entrypoints: [
|
||||
"./src/index.ts",
|
||||
parserWorker,
|
||||
workerPath,
|
||||
rgPath,
|
||||
...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []),
|
||||
],
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
|
||||
OPENCODE_WORKER_PATH: workerPath,
|
||||
OPENCODE_RIPGREP_WORKER_PATH: rgPath,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
|
||||
},
|
||||
|
||||
@@ -33,31 +33,38 @@ const seed = async () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const session = await Session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
await Session.updatePart(part)
|
||||
await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const result = yield* session.create({ title })
|
||||
const messageID = MessageID.ascending()
|
||||
const partID = PartID.ascending()
|
||||
const message = {
|
||||
id: messageID,
|
||||
sessionID: result.id,
|
||||
role: "user" as const,
|
||||
time: { created: now },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.make(providerID),
|
||||
modelID: ModelID.make(modelID),
|
||||
},
|
||||
}
|
||||
const part = {
|
||||
id: partID,
|
||||
sessionID: result.id,
|
||||
messageID,
|
||||
type: "text" as const,
|
||||
text,
|
||||
time: { start: now },
|
||||
}
|
||||
yield* session.updateMessage(message)
|
||||
yield* session.updatePart(part)
|
||||
}),
|
||||
)
|
||||
await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.update({ projectID: Instance.project.id, name: "E2E Project" })),
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -104,6 +104,19 @@ Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial
|
||||
|
||||
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
|
||||
|
||||
Recommended first slice:
|
||||
|
||||
- start with `question`
|
||||
- start with `GET /question`
|
||||
- start with `POST /question/:requestID/reply`
|
||||
|
||||
Why `question` first:
|
||||
|
||||
- already JSON-only
|
||||
- already delegates into an Effect service
|
||||
- proves list + mutation + params + payload + OpenAPI in one small slice
|
||||
- avoids the harder streaming and middleware cases
|
||||
|
||||
### 3. Reuse existing services
|
||||
|
||||
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
|
||||
@@ -121,13 +134,257 @@ Prefer mounting an experimental `HttpApi` surface alongside the existing Hono ro
|
||||
|
||||
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
||||
|
||||
## Proposed first steps
|
||||
## Schema rule for HttpApi work
|
||||
|
||||
- [ ] add one small spike that defines an `HttpApi` group for a simple JSON route set
|
||||
- [ ] use Effect Schema request / response types for that slice
|
||||
- [ ] keep the underlying service calls identical to the current handlers
|
||||
- [ ] compare generated OpenAPI against the current Hono/OpenAPI setup
|
||||
- [ ] document how auth, instance lookup, and error mapping would compose in the new stack
|
||||
Every `HttpApi` slice should follow `specs/effect/schema.md` and the Schema -> Zod interop rule in `specs/effect/migration.md`.
|
||||
|
||||
Default rule:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- `.zod` exists only as a compatibility surface
|
||||
- do not introduce a new hand-written Zod schema for a type that is already migrating to Effect Schema
|
||||
|
||||
Practical implication for `HttpApi` migration:
|
||||
|
||||
- if a route boundary already depends on a shared DTO, ID, input, output, or tagged error, migrate that model to Effect Schema first or in the same change
|
||||
- if an existing Hono route or tool still needs Zod, derive it with `@/util/effect-zod`
|
||||
- avoid maintaining parallel Zod and Effect definitions for the same request or response type
|
||||
|
||||
Ordering for a route-group migration:
|
||||
|
||||
1. move implicated shared `schema.ts` leaf types to Effect Schema first
|
||||
2. move exported `Info` / `Input` / `Output` route DTOs to Effect Schema
|
||||
3. move tagged route-facing errors to `Schema.TaggedErrorClass` where needed
|
||||
4. switch existing Zod boundary validators to derived `.zod`
|
||||
5. define the `HttpApi` contract from the canonical Effect schemas
|
||||
|
||||
Temporary exception:
|
||||
|
||||
- it is acceptable to keep a route-local Zod schema for the first spike only when the type is boundary-local and migrating it would create unrelated churn
|
||||
- if that happens, leave a short note so the type does not become a permanent second source of truth
|
||||
|
||||
## First vertical slice
|
||||
|
||||
The first `HttpApi` spike should be intentionally small and repeatable.
|
||||
|
||||
Chosen slice:
|
||||
|
||||
- group: `question`
|
||||
- endpoints: `GET /question` and `POST /question/:requestID/reply`
|
||||
|
||||
Non-goals:
|
||||
|
||||
- no `session` routes
|
||||
- no SSE or websocket routes
|
||||
- no auth redesign
|
||||
- no broad service refactor
|
||||
|
||||
Behavior rule:
|
||||
|
||||
- preserve current runtime behavior first
|
||||
- treat semantic changes such as introducing new `404` behavior as a separate follow-up unless they are required to make the contract honest
|
||||
|
||||
Add `POST /question/:requestID/reject` only after the first two endpoints work cleanly.
|
||||
|
||||
## Repeatable slice template
|
||||
|
||||
Use the same sequence for each route group.
|
||||
|
||||
1. Pick one JSON-only route group that already mostly delegates into services.
|
||||
2. Identify the shared DTOs, IDs, and errors implicated by that slice.
|
||||
3. Apply the schema migration ordering above so those types are Effect Schema-first.
|
||||
4. Define the `HttpApi` contract separately from the handlers.
|
||||
5. Implement handlers by yielding the existing service from context.
|
||||
6. Mount the new surface in parallel under an experimental prefix.
|
||||
7. Add one end-to-end test and one OpenAPI-focused test.
|
||||
8. Compare ergonomics before migrating the next endpoint.
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- migrate one route group at a time
|
||||
- migrate one or two endpoints first, not the whole file
|
||||
- keep business logic in the existing service
|
||||
- keep the first spike easy to delete if the experiment is not worth continuing
|
||||
|
||||
## Example structure
|
||||
|
||||
Placement rule:
|
||||
|
||||
- keep `HttpApi` code under `src/server`, not `src/effect`
|
||||
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
|
||||
- place each `HttpApi` slice next to the HTTP boundary it serves
|
||||
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
|
||||
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
|
||||
|
||||
Suggested file layout for a repeatable spike:
|
||||
|
||||
- `src/server/instance/httpapi/question.ts`
|
||||
- `src/server/instance/httpapi/index.ts`
|
||||
- `test/server/question-httpapi.test.ts`
|
||||
- `test/server/question-httpapi-openapi.test.ts`
|
||||
|
||||
Suggested responsibilities:
|
||||
|
||||
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice
|
||||
- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer
|
||||
- `question-httpapi.test.ts` proves the route works end-to-end against the real service
|
||||
- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
|
||||
|
||||
## Example migration shape
|
||||
|
||||
Each route-group spike should follow the same shape.
|
||||
|
||||
### 1. Contract
|
||||
|
||||
- define an experimental `HttpApi`
|
||||
- define one `HttpApiGroup`
|
||||
- define endpoint params, payload, success, and error schemas from canonical Effect schemas
|
||||
- annotate summary, description, and operation ids explicitly so generated docs are stable
|
||||
|
||||
### 2. Handler layer
|
||||
|
||||
- implement with `HttpApiBuilder.group(api, groupName, ...)`
|
||||
- yield the existing Effect service from context
|
||||
- keep handler bodies thin
|
||||
- keep transport mapping at the HTTP boundary only
|
||||
|
||||
### 3. Mounting
|
||||
|
||||
- mount under an experimental prefix such as `/experimental/httpapi`
|
||||
- keep existing Hono routes unchanged
|
||||
- expose separate OpenAPI output for the experimental slice first
|
||||
|
||||
### 4. Verification
|
||||
|
||||
- seed real state through the existing service
|
||||
- call the experimental endpoints
|
||||
- assert that the service behavior is unchanged
|
||||
- assert that the generated OpenAPI contains the migrated paths and schemas
|
||||
|
||||
## Boundary composition
|
||||
|
||||
The first slices should keep the existing outer server composition and only replace the route contract and handler layer.
|
||||
|
||||
### Auth
|
||||
|
||||
- keep `AuthMiddleware` at the outer Hono app level
|
||||
- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices
|
||||
- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler
|
||||
|
||||
Practical rule:
|
||||
|
||||
- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack
|
||||
|
||||
### Instance and workspace lookup
|
||||
|
||||
- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context
|
||||
- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler
|
||||
- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them
|
||||
|
||||
Practical rule:
|
||||
|
||||
- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided
|
||||
- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself
|
||||
|
||||
### Error mapping
|
||||
|
||||
- keep domain and service errors typed in the service layer
|
||||
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
|
||||
- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior
|
||||
|
||||
Practical rule:
|
||||
|
||||
- request decoding failures should remain transport-level `400`s
|
||||
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
|
||||
- unexpected defects can still fall through to the outer error middleware while the slice is experimental
|
||||
|
||||
For the current parallel slices, this means:
|
||||
|
||||
- auth still composes outside `HttpApi`
|
||||
- instance selection still composes outside `HttpApi`
|
||||
- success payloads should be schema-defined from canonical Effect schemas
|
||||
- known route errors should be modeled at the endpoint boundary incrementally instead of all at once
|
||||
|
||||
## Exit criteria for the spike
|
||||
|
||||
The first slice is successful if:
|
||||
|
||||
- the endpoints run in parallel with the current Hono routes
|
||||
- the handlers reuse the existing Effect service
|
||||
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
||||
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
||||
- OpenAPI is generated from the `HttpApi` contract
|
||||
- the tests are straightforward enough that the next slice feels mechanical
|
||||
|
||||
## Learnings from the question slice
|
||||
|
||||
The first parallel `question` spike gave us a concrete pattern to reuse.
|
||||
|
||||
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
|
||||
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
|
||||
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
|
||||
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
|
||||
- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged.
|
||||
- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix.
|
||||
|
||||
## Route inventory
|
||||
|
||||
Status legend:
|
||||
|
||||
- `done` - parallel `HttpApi` slice exists
|
||||
- `next` - good near-term candidate
|
||||
- `later` - possible, but not first wave
|
||||
- `defer` - not a good early `HttpApi` target
|
||||
|
||||
Current instance route inventory:
|
||||
|
||||
- `question` - `done`
|
||||
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
|
||||
- `permission` - `done`
|
||||
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
|
||||
- `provider` - `next`
|
||||
best next endpoint: `GET /provider/auth`
|
||||
later endpoint: `GET /provider`
|
||||
defer first-wave OAuth mutations
|
||||
- `config` - `next`
|
||||
best next endpoint: `GET /config/providers`
|
||||
later endpoint: `GET /config`
|
||||
defer `PATCH /config` for now
|
||||
- `project` - `later`
|
||||
best small reads: `GET /project`, `GET /project/current`
|
||||
defer git-init mutation first
|
||||
- `workspace` - `later`
|
||||
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
|
||||
defer create/remove mutations first
|
||||
- `file` - `later`
|
||||
good JSON-only candidate set, but larger than the current first-wave slices
|
||||
- `mcp` - `later`
|
||||
has JSON-only endpoints, but interactive OAuth/auth flows make it a worse early fit
|
||||
- `session` - `defer`
|
||||
large, stateful, mixes CRUD with prompt/shell/command/share/revert flows and a streaming route
|
||||
- `event` - `defer`
|
||||
SSE only
|
||||
- `global` - `defer`
|
||||
mixed bag with SSE and process-level side effects
|
||||
- `pty` - `defer`
|
||||
websocket-heavy route surface
|
||||
- `tui` - `defer`
|
||||
queue-style UI bridge, weak early `HttpApi` fit
|
||||
|
||||
Recommended near-term sequence after the first spike:
|
||||
|
||||
1. `provider` auth read endpoint
|
||||
2. `config` providers read endpoint
|
||||
3. `project` read endpoints
|
||||
4. `workspace` read endpoints
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] add one small spike that defines an `HttpApi` group for a simple JSON route set
|
||||
- [x] use Effect Schema request / response types for that slice
|
||||
- [x] keep the underlying service calls identical to the current handlers
|
||||
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
|
||||
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
|
||||
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Effect loose ends
|
||||
|
||||
Small follow-ups that do not fit neatly into the main facade, route, tool, or schema migration checklists.
|
||||
|
||||
## Config / TUI
|
||||
|
||||
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
|
||||
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
|
||||
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
|
||||
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
|
||||
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
|
||||
|
||||
## ConfigPaths
|
||||
|
||||
- [ ] `config/paths.ts` - split pure helpers from effectful helpers.
|
||||
Keep `fileInDirectory(...)` as a plain function.
|
||||
- [ ] `config/paths.ts` - add a `ConfigPaths.Service` for the effectful operations so callers do not inherit `AppFileSystem.Service` directly.
|
||||
Initial service surface should cover:
|
||||
- `projectFiles(...)`
|
||||
- `directories(...)`
|
||||
- `readFile(...)`
|
||||
- `parseText(...)`
|
||||
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
|
||||
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
|
||||
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
|
||||
|
||||
## Instance cleanup
|
||||
|
||||
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
|
||||
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
|
||||
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer small, semantics-preserving config migrations. Config precedence, legacy key migration, and plugin origin tracking are easy to break accidentally.
|
||||
- When changing config loading internals, rerun the config and TUI suites first before broad package sweeps.
|
||||
@@ -453,19 +453,12 @@ export namespace ACP {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ACP clients already know the prompt they just submitted, so replaying
|
||||
// live user parts duplicates the message. We still replay user history in
|
||||
// loadSession() and forkSession() via processMessage().
|
||||
if (part.type !== "text" && part.type !== "file") return
|
||||
const msg = await this.sdk.session
|
||||
.message(
|
||||
{ sessionID: part.sessionID, messageID: part.messageID, directory: session.cwd },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
log.error("failed to fetch message for user chunk", { error: err })
|
||||
return undefined
|
||||
})
|
||||
if (!msg || msg.info.role !== "user") return
|
||||
await this.processMessage({ info: msg.info, parts: [part] })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ export namespace Agent {
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const skill = yield* Skill.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
@@ -335,9 +336,7 @@ export namespace Agent {
|
||||
const language = yield* provider.getLanguage(resolved)
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* Effect.promise(() =>
|
||||
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
|
||||
)
|
||||
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
|
||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
@@ -398,6 +397,7 @@ export namespace Agent {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
|
||||
@@ -123,45 +123,49 @@ function parseToolParams(input?: string) {
|
||||
}
|
||||
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model =
|
||||
agent.model ??
|
||||
(await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
}),
|
||||
))
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: session.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
await Session.updateMessage(message)
|
||||
const { session, messageID } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model
|
||||
? agent.model
|
||||
: yield* Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
const now = Date.now()
|
||||
const message: MessageV2.Assistant = {
|
||||
id: messageID,
|
||||
sessionID: result.id,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
parentID: messageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "debug",
|
||||
agent: agent.name,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
yield* session.updateMessage(message)
|
||||
return { session: result, messageID }
|
||||
}),
|
||||
)
|
||||
|
||||
const ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ const FilesCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files: string[] = []
|
||||
for await (const file of Ripgrep.files({
|
||||
for await (const file of await Ripgrep.files({
|
||||
cwd: Instance.directory,
|
||||
glob: args.glob ? [args.glob] : undefined,
|
||||
})) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -14,7 +15,7 @@ const TrackCommand = cmd({
|
||||
describe: "track current snapshot state",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await Snapshot.track())
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -30,7 +31,7 @@ const PatchCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await Snapshot.patch(args.hash))
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -46,7 +47,7 @@ const DiffCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await Snapshot.diff(args.hash))
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,6 +6,8 @@ import { bootstrap } from "../bootstrap"
|
||||
import { UI } from "../ui"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ExportCommand = cmd({
|
||||
command: "export [sessionID]",
|
||||
@@ -67,8 +69,16 @@ export const ExportCommand = cmd({
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionInfo = await Session.get(sessionID!)
|
||||
const messages = await Session.messages({ sessionID: sessionInfo.id })
|
||||
const { sessionInfo, messages } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const sessionInfo = yield* session.get(sessionID!)
|
||||
return {
|
||||
sessionInfo,
|
||||
messages: yield* session.messages({ sessionID: sessionInfo.id }),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const exportData = {
|
||||
info: sessionInfo,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -551,20 +552,24 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await Session.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
})
|
||||
session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await SessionShare.share(session.id)
|
||||
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
@@ -937,96 +942,86 @@ export const GithubRunCommand = cmd({
|
||||
async function chat(message: string, files: PromptFiles = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
|
||||
const result = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
// agent is omitted - server will use default_agent from config or fall back to "build"
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
...files.flatMap((f) => [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "file" as const,
|
||||
mime: f.mime,
|
||||
url: `data:${f.mime};base64,${f.content}`,
|
||||
filename: f.filename,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: f.replacement,
|
||||
start: f.start,
|
||||
end: f.end,
|
||||
},
|
||||
path: f.filename,
|
||||
},
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
// agent is omitted - server will use default_agent from config or fall back to "build"
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: message,
|
||||
},
|
||||
...files.flatMap((f) => [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "file" as const,
|
||||
mime: f.mime,
|
||||
url: `data:${f.mime};base64,${f.content}`,
|
||||
filename: f.filename,
|
||||
source: {
|
||||
type: "file" as const,
|
||||
text: {
|
||||
value: f.replacement,
|
||||
start: f.start,
|
||||
end: f.end,
|
||||
},
|
||||
path: f.filename,
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
// result should always be assistant just satisfying type checker
|
||||
if (result.info.role === "assistant" && result.info.error) {
|
||||
const err = result.info.error
|
||||
console.error("Agent error:", err)
|
||||
if (result.info.role === "assistant" && result.info.error) {
|
||||
const err = result.info.error
|
||||
console.error("Agent error:", err)
|
||||
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||
}
|
||||
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
console.log("Requesting summary from agent...")
|
||||
const summary = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
tools: { "*": false },
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
const err = summary.info.error
|
||||
console.error("Summary agent error:", err)
|
||||
if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files))
|
||||
throw new Error(`${err.name}: ${err.data?.message || ""}`)
|
||||
}
|
||||
|
||||
// No text part (tool-only or reasoning-only) - ask agent to summarize
|
||||
console.log("Requesting summary from agent...")
|
||||
const summary = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
messageID: MessageID.ascending(),
|
||||
variant,
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
tools: { "*": false }, // Disable all tools to force text response
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text",
|
||||
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (summary.info.role === "assistant" && summary.info.error) {
|
||||
const err = summary.info.error
|
||||
console.error("Summary agent error:", err)
|
||||
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
if (!summaryText) {
|
||||
throw new Error("Failed to get summary from agent")
|
||||
}
|
||||
|
||||
return summaryText
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
if (!summaryText) throw new Error("Failed to get summary from agent")
|
||||
return summaryText
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function getOidcToken() {
|
||||
|
||||
@@ -158,13 +158,13 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
if (method.authorize) {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
@@ -340,6 +340,12 @@ export const ProvidersLoginCommand = cmd({
|
||||
}
|
||||
return filtered
|
||||
})
|
||||
const hooks = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
return yield* plugin.list()
|
||||
}),
|
||||
)
|
||||
|
||||
const priority: Record<string, number> = {
|
||||
opencode: 0,
|
||||
@@ -351,7 +357,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks: await Plugin.list(),
|
||||
hooks,
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
@@ -408,7 +414,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
@@ -422,7 +428,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
provider = custom.replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Process } from "../../util/process"
|
||||
import { EOL } from "os"
|
||||
import path from "path"
|
||||
import { which } from "../../util/which"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
function pagerCmd(): string[] {
|
||||
const lessOptions = ["-R", "-S"]
|
||||
@@ -60,12 +61,12 @@ export const SessionDeleteCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
try {
|
||||
await Session.get(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await Session.remove(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Database } from "../../storage/db"
|
||||
import { SessionTable } from "../../session/session.sql"
|
||||
import { Project } from "../../project/project"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
interface SessionStats {
|
||||
totalSessions: number
|
||||
@@ -167,7 +168,9 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
|
||||
const batch = filteredSessions.slice(i, i + BATCH_SIZE)
|
||||
|
||||
const batchPromises = batch.map(async (session) => {
|
||||
const messages = await Session.messages({ sessionID: session.id })
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
|
||||
)
|
||||
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
@@ -542,8 +542,10 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
|
||||
const diffAlpha = isDark ? 0.22 : 0.14
|
||||
const diffAddedBg = tint(bg, ansiColors.green, diffAlpha)
|
||||
const diffRemovedBg = tint(bg, ansiColors.red, diffAlpha)
|
||||
const diffAddedLineNumberBg = tint(grays[3], ansiColors.green, diffAlpha)
|
||||
const diffRemovedLineNumberBg = tint(grays[3], ansiColors.red, diffAlpha)
|
||||
const diffContextBg = grays[2]
|
||||
const diffAddedLineNumberBg = tint(diffContextBg, ansiColors.green, diffAlpha)
|
||||
const diffRemovedLineNumberBg = tint(diffContextBg, ansiColors.red, diffAlpha)
|
||||
const diffLineNumber = textMuted
|
||||
|
||||
return {
|
||||
theme: {
|
||||
@@ -583,8 +585,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
|
||||
diffHighlightRemoved: ansiColors.redBright,
|
||||
diffAddedBg,
|
||||
diffRemovedBg,
|
||||
diffContextBg: grays[1],
|
||||
diffLineNumber: grays[6],
|
||||
diffContextBg,
|
||||
diffLineNumber,
|
||||
diffAddedLineNumberBg,
|
||||
diffRemovedLineNumberBg,
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"diffAddedBg": "#354933",
|
||||
"diffRemovedBg": "#3f191a",
|
||||
"diffContextBg": "darkBgPanel",
|
||||
"diffLineNumber": "darkBorder",
|
||||
"diffLineNumber": "#898989",
|
||||
"diffAddedLineNumberBg": "#162620",
|
||||
"diffRemovedLineNumberBg": "#26161a",
|
||||
"markdownText": "darkFg",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"diffAddedBg": "#20303b",
|
||||
"diffRemovedBg": "#37222c",
|
||||
"diffContextBg": "darkPanel",
|
||||
"diffLineNumber": "darkGutter",
|
||||
"diffLineNumber": "diffContext",
|
||||
"diffAddedLineNumberBg": "#1b2b34",
|
||||
"diffRemovedLineNumberBg": "#2d1f26",
|
||||
"markdownText": "darkFg",
|
||||
|
||||
@@ -141,8 +141,8 @@
|
||||
"light": "lbg1"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "fg3",
|
||||
"light": "lfg3"
|
||||
"dark": "#808792",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "diffGreenBg",
|
||||
|
||||
@@ -125,10 +125,7 @@
|
||||
"dark": "frappeMantle",
|
||||
"light": "frappeMantle"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "frappeSurface1",
|
||||
"light": "frappeSurface1"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#223025",
|
||||
"light": "#223025"
|
||||
|
||||
@@ -125,10 +125,7 @@
|
||||
"dark": "macMantle",
|
||||
"light": "macMantle"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "macSurface1",
|
||||
"light": "macSurface1"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#223025",
|
||||
"light": "#223025"
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
"diffAddedBg": { "dark": "#24312b", "light": "#d6f0d9" },
|
||||
"diffRemovedBg": { "dark": "#3c2a32", "light": "#f6dfe2" },
|
||||
"diffContextBg": { "dark": "darkMantle", "light": "lightMantle" },
|
||||
"diffLineNumber": { "dark": "darkSurface1", "light": "lightSurface1" },
|
||||
"diffLineNumber": { "dark": "textMuted", "light": "#5b5d63" },
|
||||
"diffAddedLineNumberBg": { "dark": "#1e2a25", "light": "#c9e3cb" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#32232a", "light": "#e9d3d6" },
|
||||
"markdownText": { "dark": "darkText", "light": "lightText" },
|
||||
|
||||
@@ -120,10 +120,7 @@
|
||||
"dark": "#122738",
|
||||
"light": "#f5f7fa"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#2d5a7b",
|
||||
"light": "#b0bec5"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a2a",
|
||||
"light": "#e8f5e9"
|
||||
|
||||
@@ -142,8 +142,8 @@
|
||||
"light": "lightPanel"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#e4e4e442",
|
||||
"light": "#1414147a"
|
||||
"dark": "#eeeeee87",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3fa26633",
|
||||
|
||||
@@ -112,8 +112,8 @@
|
||||
"light": "#e8e8e2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "currentLine",
|
||||
"light": "#c8c8c2"
|
||||
"dark": "#989aa4",
|
||||
"light": "#686865"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a1a",
|
||||
|
||||
@@ -134,8 +134,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "#a0a5a7",
|
||||
"light": "#5b5951"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1b2b34",
|
||||
|
||||
@@ -130,8 +130,8 @@
|
||||
"light": "base50"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "base600",
|
||||
"light": "base600"
|
||||
"dark": "#888883",
|
||||
"light": "#5a5955"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#152515",
|
||||
|
||||
@@ -126,8 +126,8 @@
|
||||
"light": "lightBgAlt"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#484f58",
|
||||
"light": "#afb8c1"
|
||||
"dark": "#95999e",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#033a16",
|
||||
|
||||
@@ -135,8 +135,8 @@
|
||||
"light": "lightBg1"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkBg3",
|
||||
"light": "lightBg3"
|
||||
"dark": "#a8a29e",
|
||||
"light": "#564f43"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#2a2827",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"diffAddedBg": { "dark": "#252E25", "light": "#EAF3E4" },
|
||||
"diffRemovedBg": { "dark": "#362020", "light": "#FBE6E6" },
|
||||
"diffContextBg": { "dark": "sumiInk1", "light": "lightPaper" },
|
||||
"diffLineNumber": { "dark": "sumiInk3", "light": "#C7BEB4" },
|
||||
"diffLineNumber": { "dark": "#9090a0", "light": "#65615c" },
|
||||
"diffAddedLineNumberBg": { "dark": "#202820", "light": "#DDE8D6" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#2D1C1C", "light": "#F2DADA" },
|
||||
"markdownText": { "dark": "fujiWhite", "light": "lightText" },
|
||||
|
||||
@@ -129,10 +129,7 @@
|
||||
"dark": "transparent",
|
||||
"light": "transparent"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#666666",
|
||||
"light": "#999999"
|
||||
},
|
||||
"diffLineNumber": "textMuted",
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "transparent",
|
||||
"light": "transparent"
|
||||
|
||||
@@ -128,8 +128,8 @@
|
||||
"light": "lightBgAlt"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#37474f",
|
||||
"light": "#cfd8dc"
|
||||
"dark": "#9aa2a6",
|
||||
"light": "#6a6e70"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#2e3c2b",
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"diffAddedBg": { "dark": "#132616", "light": "#e0efde" },
|
||||
"diffRemovedBg": { "dark": "#261212", "light": "#f9e5e5" },
|
||||
"diffContextBg": { "dark": "matrixInk1", "light": "lightPaper" },
|
||||
"diffLineNumber": { "dark": "matrixInk3", "light": "lightGray" },
|
||||
"diffLineNumber": { "dark": "textMuted", "light": "#556156" },
|
||||
"diffAddedLineNumberBg": { "dark": "#0f1b11", "light": "#d6e7d2" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#1b1414", "light": "#f2d2d2" },
|
||||
"markdownText": { "dark": "rainGreenHi", "light": "lightText" },
|
||||
|
||||
@@ -114,8 +114,8 @@
|
||||
"light": "#f0f0f0"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#3e3d32",
|
||||
"light": "#d0d0d0"
|
||||
"dark": "#9b9b95",
|
||||
"light": "#686868"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a1a",
|
||||
|
||||
@@ -114,8 +114,8 @@
|
||||
"light": "nightOwlPanel"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nightOwlMuted",
|
||||
"light": "nightOwlMuted"
|
||||
"dark": "#7791a6",
|
||||
"light": "#7791a6"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#0a2e1a",
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "nord5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "nord2",
|
||||
"light": "nord4"
|
||||
"dark": "#a9aeb6",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#3B4252",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" },
|
||||
"diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" },
|
||||
"diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" },
|
||||
"diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" },
|
||||
"diffLineNumber": { "dark": "#9398a2", "light": "#666666" },
|
||||
"diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" },
|
||||
"markdownText": { "dark": "darkFg", "light": "lightFg" },
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "#8f8f8f",
|
||||
"light": "#595959"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1b2b34",
|
||||
|
||||
@@ -142,8 +142,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "diffContext",
|
||||
"light": "#595755"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#162535",
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
"diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" },
|
||||
"diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" },
|
||||
"diffContextBg": { "dark": "darkBg1", "light": "lightBg1" },
|
||||
"diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" },
|
||||
"diffLineNumber": { "dark": "#828b87", "light": "#5f5e4f" },
|
||||
"diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" },
|
||||
"diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" },
|
||||
"markdownText": { "dark": "darkFg0", "light": "lightFg0" },
|
||||
|
||||
@@ -115,8 +115,8 @@
|
||||
"light": "#f5f5f5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#444760",
|
||||
"light": "#cfd8dc"
|
||||
"dark": "#a0a2af",
|
||||
"light": "#6a6e70"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#2e3c2b",
|
||||
|
||||
@@ -127,8 +127,8 @@
|
||||
"light": "dawnSurface"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "muted",
|
||||
"light": "dawnMuted"
|
||||
"dark": "#9491a6",
|
||||
"light": "#6c6875"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1f2d3a",
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "base2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "base01",
|
||||
"light": "base1"
|
||||
"dark": "#8b9b9f",
|
||||
"light": "#5f6969"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#073642",
|
||||
|
||||
@@ -119,8 +119,8 @@
|
||||
"light": "#f5f5f5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#495495",
|
||||
"light": "#b0b0b0"
|
||||
"dark": "#959bc1",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1a3a2a",
|
||||
|
||||
@@ -136,8 +136,8 @@
|
||||
"light": "lightStep2"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "darkStep3",
|
||||
"light": "lightStep3"
|
||||
"dark": "#8f909a",
|
||||
"light": "#59595b"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#1b2b34",
|
||||
|
||||
@@ -138,8 +138,8 @@
|
||||
"light": "lightBackground"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "gray600",
|
||||
"light": "lightGray600"
|
||||
"dark": "#8a8a8a",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#0F2613",
|
||||
|
||||
@@ -111,8 +111,8 @@
|
||||
"light": "#F8F8F8"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#505050",
|
||||
"light": "#808080"
|
||||
"dark": "textMuted",
|
||||
"light": "#6a6a6a"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#0d2818",
|
||||
|
||||
@@ -116,8 +116,8 @@
|
||||
"light": "#f5f5e5"
|
||||
},
|
||||
"diffLineNumber": {
|
||||
"dark": "#6f6f6f",
|
||||
"light": "#b0b0a0"
|
||||
"dark": "#d2d2d2",
|
||||
"light": "textMuted"
|
||||
},
|
||||
"diffAddedLineNumberBg": {
|
||||
"dark": "#4f5f4f",
|
||||
|
||||
@@ -2195,7 +2195,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
const { theme } = useTheme()
|
||||
const count = createMemo(() => props.input.questions?.length ?? 0)
|
||||
|
||||
function format(answer?: string[]) {
|
||||
function format(answer?: ReadonlyArray<string>) {
|
||||
if (!answer?.length) return "(no answer)"
|
||||
return answer.join(", ")
|
||||
}
|
||||
|
||||
@@ -1161,502 +1161,500 @@ export namespace Config {
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | Auth.Service | Account.Service | Env.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
const env = yield* Env.Service
|
||||
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
const original = text
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
const data = yield* Effect.promise(() =>
|
||||
ConfigPaths.parseText(
|
||||
text,
|
||||
"path" in options ? options.path : { source: options.source, dir: options.dir },
|
||||
),
|
||||
)
|
||||
|
||||
const normalized = (() => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||
if (!hadLegacy) return copy
|
||||
delete copy.theme
|
||||
delete copy.keybinds
|
||||
delete copy.tui
|
||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||
return copy
|
||||
})()
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
const list = data.plugin
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
yield* Effect.promise(() =>
|
||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fsNode.unlink(legacy)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||
loadGlobal().pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||
),
|
||||
Effect.orElseSucceed((): Info => ({})),
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Duration.infinity,
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
const original = text
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
const data = yield* Effect.promise(() =>
|
||||
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
|
||||
)
|
||||
|
||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||
return yield* cachedGlobal
|
||||
const normalized = (() => {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||
const copy = { ...(data as Record<string, unknown>) }
|
||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||
if (!hadLegacy) return copy
|
||||
delete copy.theme
|
||||
delete copy.keybinds
|
||||
delete copy.tui
|
||||
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
|
||||
return copy
|
||||
})()
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
const list = data.plugin
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
yield* Effect.promise(() =>
|
||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fsNode.unlink(legacy)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||
loadGlobal().pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||
),
|
||||
Effect.orElseSucceed((): Info => ({})),
|
||||
),
|
||||
Duration.infinity,
|
||||
)
|
||||
|
||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||
return yield* cachedGlobal
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (dir: string) {
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
const json = yield* fs.readJson(pkg).pipe(
|
||||
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
||||
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
||||
)
|
||||
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
|
||||
const hasIgnore = yield* fs.existsSafe(gitignore)
|
||||
const hasPkg = yield* fs.existsSafe(plugin)
|
||||
|
||||
if (!hasDep) {
|
||||
yield* fs.writeJson(pkg, {
|
||||
...json,
|
||||
dependencies: {
|
||||
...json.dependencies,
|
||||
"@opencode-ai/plugin": target,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasIgnore) {
|
||||
yield* fs.writeFileString(
|
||||
gitignore,
|
||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
if (hasDep && hasIgnore && hasPkg) return
|
||||
|
||||
yield* Effect.promise(() => Npm.install(dir))
|
||||
})
|
||||
|
||||
const installDependencies = Effect.fn("Config.installDependencies")(function* (
|
||||
dir: string,
|
||||
input?: InstallInput,
|
||||
) {
|
||||
if (
|
||||
!(yield* fs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
))
|
||||
)
|
||||
return
|
||||
|
||||
const key =
|
||||
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) =>
|
||||
Flock.acquire(key, {
|
||||
signal,
|
||||
onWait: (tick) =>
|
||||
input?.waitTick?.({
|
||||
dir,
|
||||
attempt: tick.attempt,
|
||||
delay: tick.delay,
|
||||
waited: tick.waited,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
() => install(dir),
|
||||
(lease) => Effect.promise(() => lease.release()),
|
||||
)
|
||||
})
|
||||
|
||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
let result: Info = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
|
||||
const scope = Effect.fnUntraced(function* (source: string) {
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||
return "global"
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (dir: string) {
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const gitignore = path.join(dir, ".gitignore")
|
||||
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
|
||||
const target = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
const json = yield* fs.readJson(pkg).pipe(
|
||||
Effect.catch(() => Effect.succeed({} satisfies Package)),
|
||||
Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
|
||||
)
|
||||
const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
|
||||
const hasIgnore = yield* fs.existsSafe(gitignore)
|
||||
const hasPkg = yield* fs.existsSafe(plugin)
|
||||
const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
|
||||
if (!list?.length) return
|
||||
const hit = kind ?? (yield* scope(source))
|
||||
const plugins = deduplicatePluginOrigins([
|
||||
...(result.plugin_origins ?? []),
|
||||
...list.map((spec) => ({ spec, source, scope: hit })),
|
||||
])
|
||||
result.plugin = plugins.map((item) => item.spec)
|
||||
result.plugin_origins = plugins
|
||||
})
|
||||
|
||||
if (!hasDep) {
|
||||
yield* fs.writeJson(pkg, {
|
||||
...json,
|
||||
dependencies: {
|
||||
...json.dependencies,
|
||||
"@opencode-ai/plugin": target,
|
||||
},
|
||||
const merge = (source: string, next: Info, kind?: PluginScope) => {
|
||||
result = mergeConfigConcatArrays(result, next)
|
||||
return track(source, next.plugin, kind)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
const source = `${url}/.well-known/opencode`
|
||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
const global = yield* getGlobal()
|
||||
yield* merge(Global.Path.config, global, "global")
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* Effect.promise(() =>
|
||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||
)) {
|
||||
yield* merge(file, yield* loadFile(file), "local")
|
||||
}
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps: Fiber.Fiber<void, never>[] = []
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(dir, file)
|
||||
log.debug(`loading config from ${source}`)
|
||||
yield* merge(source, yield* loadFile(source))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIgnore) {
|
||||
yield* fs.writeFileString(
|
||||
gitignore,
|
||||
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
if (hasDep && hasIgnore && hasPkg) return
|
||||
|
||||
yield* Effect.promise(() => Npm.install(dir))
|
||||
})
|
||||
|
||||
const installDependencies = Effect.fn("Config.installDependencies")(function* (
|
||||
dir: string,
|
||||
input?: InstallInput,
|
||||
) {
|
||||
if (
|
||||
!(yield* fs.access(dir, { writable: true }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.orElseSucceed(() => false),
|
||||
))
|
||||
)
|
||||
return
|
||||
|
||||
const key =
|
||||
process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.promise((signal) =>
|
||||
Flock.acquire(key, {
|
||||
signal,
|
||||
onWait: (tick) =>
|
||||
input?.waitTick?.({
|
||||
dir,
|
||||
attempt: tick.attempt,
|
||||
delay: tick.delay,
|
||||
waited: tick.waited,
|
||||
}),
|
||||
}),
|
||||
const dep = yield* installDependencies(dir).pipe(
|
||||
Effect.exit,
|
||||
Effect.tap((exit) =>
|
||||
Exit.isFailure(exit)
|
||||
? Effect.sync(() => {
|
||||
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
() => install(dir),
|
||||
(lease) => Effect.promise(() => lease.release()),
|
||||
Effect.asVoid,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
})
|
||||
deps.push(dep)
|
||||
|
||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||
const list = yield* Effect.promise(() => loadPlugin(dir))
|
||||
yield* track(dir, list)
|
||||
}
|
||||
|
||||
let result: Info = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
|
||||
const scope = Effect.fnUntraced(function* (source: string) {
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
|
||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||
return "global"
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
const source = "OPENCODE_CONFIG_CONTENT"
|
||||
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: ctx.directory,
|
||||
source,
|
||||
})
|
||||
yield* merge(source, next, "local")
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
source: string,
|
||||
list: PluginSpec[] | undefined,
|
||||
kind?: PluginScope,
|
||||
) {
|
||||
if (!list?.length) return
|
||||
const hit = kind ?? (yield* scope(source))
|
||||
const plugins = deduplicatePluginOrigins([
|
||||
...(result.plugin_origins ?? []),
|
||||
...list.map((spec) => ({ spec, source, scope: hit })),
|
||||
])
|
||||
result.plugin = plugins.map((item) => item.spec)
|
||||
result.plugin_origins = plugins
|
||||
})
|
||||
const activeOrg = Option.getOrUndefined(
|
||||
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
|
||||
)
|
||||
if (activeOrg) {
|
||||
yield* Effect.gen(function* () {
|
||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
||||
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (Option.isSome(tokenOpt)) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
|
||||
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
|
||||
}
|
||||
|
||||
const merge = (source: string, next: Info, kind?: PluginScope) => {
|
||||
result = mergeConfigConcatArrays(result, next)
|
||||
return track(source, next.plugin, kind)
|
||||
}
|
||||
activeOrgName = activeOrg.org.name
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
const source = `${url}/.well-known/opencode`
|
||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
if (Option.isSome(configOpt)) {
|
||||
const source = `${activeOrg.account.url}/api/config`
|
||||
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
for (const providerID of Object.keys(next.provider ?? {})) {
|
||||
consoleManagedProviders.add(providerID)
|
||||
}
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
const global = yield* getGlobal()
|
||||
yield* merge(Global.Path.config, global, "global")
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* Effect.promise(() =>
|
||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||
)) {
|
||||
yield* merge(file, yield* loadFile(file), "local")
|
||||
}
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps: Fiber.Fiber<void, never>[] = []
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(dir, file)
|
||||
log.debug(`loading config from ${source}`)
|
||||
yield* merge(source, yield* loadFile(source))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
const dep = yield* installDependencies(dir).pipe(
|
||||
Effect.exit,
|
||||
Effect.tap((exit) =>
|
||||
Exit.isFailure(exit)
|
||||
? Effect.sync(() => {
|
||||
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
deps.push(dep)
|
||||
|
||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||
const list = yield* Effect.promise(() => loadPlugin(dir))
|
||||
yield* track(dir, list)
|
||||
}
|
||||
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
const source = "OPENCODE_CONFIG_CONTENT"
|
||||
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: ctx.directory,
|
||||
source,
|
||||
})
|
||||
yield* merge(source, next, "local")
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const activeOrg = Option.getOrUndefined(
|
||||
yield* accountSvc.activeOrg().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
|
||||
}).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.debug("failed to fetch remote account config", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
if (activeOrg) {
|
||||
yield* Effect.gen(function* () {
|
||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
||||
[accountSvc.config(activeOrg.account.id, activeOrg.org.id), accountSvc.token(activeOrg.account.id)],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (Option.isSome(tokenOpt)) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
|
||||
Env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
|
||||
}
|
||||
}
|
||||
|
||||
activeOrgName = activeOrg.org.name
|
||||
|
||||
if (Option.isSome(configOpt)) {
|
||||
const source = `${activeOrg.account.url}/api/config`
|
||||
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
for (const providerID of Object.keys(next.provider ?? {})) {
|
||||
consoleManagedProviders.add(providerID)
|
||||
}
|
||||
yield* merge(source, next, "global")
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.debug("failed to fetch remote account config", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(managedDir, file)
|
||||
yield* merge(source, yield* loadFile(source), "global")
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(managedDir, file)
|
||||
yield* merge(source, yield* loadFile(source), "global")
|
||||
}
|
||||
}
|
||||
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
|
||||
|
||||
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
|
||||
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
[name]: {
|
||||
...mode,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, Config.PermissionAction> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
deps,
|
||||
consoleState: {
|
||||
consoleManagedProviders: Array.from(consoleManagedProviders),
|
||||
activeOrgName,
|
||||
switchableOrgCount: 0,
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
[name]: {
|
||||
...mode,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, Config.PermissionAction> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
})
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Config.state")(function* (ctx) {
|
||||
return yield* loadInstanceState(ctx)
|
||||
}),
|
||||
)
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
const get = Effect.fn("Config.get")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.config)
|
||||
})
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
const directories = Effect.fn("Config.directories")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.directories)
|
||||
})
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.consoleState)
|
||||
})
|
||||
|
||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||
yield* InstanceState.useEffect(state, (s) =>
|
||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||
)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const dir = yield* InstanceState.directory
|
||||
const file = path.join(dir, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs
|
||||
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
const input = writable(config)
|
||||
|
||||
let next: Info
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, file)
|
||||
const merged = mergeDeep(writable(existing), input)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, input)
|
||||
next = parseConfig(updated, file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
yield* invalidate()
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
getGlobal,
|
||||
getConsoleState,
|
||||
installDependencies,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
waitForDependencies,
|
||||
})
|
||||
}),
|
||||
)
|
||||
deps,
|
||||
consoleState: {
|
||||
consoleManagedProviders: Array.from(consoleManagedProviders),
|
||||
activeOrgName,
|
||||
switchableOrgCount: 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Config.state")(function* (ctx) {
|
||||
return yield* loadInstanceState(ctx)
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Config.get")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.config)
|
||||
})
|
||||
|
||||
const directories = Effect.fn("Config.directories")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.directories)
|
||||
})
|
||||
|
||||
const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.consoleState)
|
||||
})
|
||||
|
||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||
yield* InstanceState.useEffect(state, (s) =>
|
||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||
)
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const dir = yield* InstanceState.directory
|
||||
const file = path.join(dir, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs
|
||||
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
const input = writable(config)
|
||||
|
||||
let next: Info
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, file)
|
||||
const merged = mergeDeep(writable(existing), input)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, input)
|
||||
next = parseConfig(updated, file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
yield* invalidate()
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
getGlobal,
|
||||
getConsoleState,
|
||||
installDependencies,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
directories,
|
||||
waitForDependencies,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { existsSync } from "fs"
|
||||
import z from "zod"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { Config } from "./config"
|
||||
import { ConfigPaths } from "./paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { TuiInfo } from "./tui-schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@/global"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
|
||||
export namespace TuiConfig {
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
@@ -21,13 +24,26 @@ export namespace TuiConfig {
|
||||
result: Info
|
||||
}
|
||||
|
||||
type State = {
|
||||
config: Info
|
||||
deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
||||
}
|
||||
|
||||
export type Info = z.output<typeof Info> & {
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
plugin_origins?: Config.PluginOrigin[]
|
||||
}
|
||||
|
||||
function pluginScope(file: string): Config.PluginScope {
|
||||
if (Instance.containsPath(file)) return "local"
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly waitForDependencies: () => Effect.Effect<void, AppFileSystem.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
||||
|
||||
function pluginScope(file: string, ctx: { directory: string; worktree: string }): Config.PluginScope {
|
||||
if (Filesystem.contains(ctx.directory, file)) return "local"
|
||||
if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
@@ -51,16 +67,12 @@ export namespace TuiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function installDeps(dir: string): Promise<void> {
|
||||
return AppRuntime.runPromise(Config.Service.use((cfg) => cfg.installDependencies(dir)))
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string) {
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file)
|
||||
const scope = pluginScope(file, ctx)
|
||||
const plugins = Config.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
@@ -69,46 +81,48 @@ export namespace TuiConfig {
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
|
||||
const custom = customPath()
|
||||
const managed = Config.managedConfigDir()
|
||||
await migrateTuiConfig({ directories, custom, managed })
|
||||
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
|
||||
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(acc, file)
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(acc, custom)
|
||||
await mergeFile(acc, custom, ctx)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file)
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
await mergeFile(acc, file)
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
await mergeFile(acc, file)
|
||||
await mergeFile(acc, file, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,27 +136,48 @@ export namespace TuiConfig {
|
||||
}
|
||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (acc.result.plugin?.length) {
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
deps.push(installDeps(dir))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: acc.result,
|
||||
deps,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("TuiConfig.state")(function* (ctx) {
|
||||
const data = yield* Effect.promise(() => loadState(ctx))
|
||||
const deps = yield* Effect.forEach(data.dirs, (dir) => cfg.installDependencies(dir).pipe(Effect.forkScoped), {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
return { config: data.config, deps }
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("TuiConfig.get")(() => InstanceState.use(state, (s) => s.config))
|
||||
|
||||
const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
||||
InstanceState.useEffect(state, (s) =>
|
||||
Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ get, waitForDependencies })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get() {
|
||||
return state().then((x) => x.config)
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
const deps = await state().then((x) => x.deps)
|
||||
await Promise.all(deps)
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||
|
||||
@@ -12,7 +13,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
async configure(info) {
|
||||
const worktree = await Worktree.makeWorktreeInfo(undefined)
|
||||
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
|
||||
return {
|
||||
...info,
|
||||
name: worktree.name,
|
||||
@@ -22,15 +23,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
},
|
||||
async create(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Worktree.Service.use((svc) =>
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async remove(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
|
||||
},
|
||||
target(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
|
||||
Vendored
+41
-13
@@ -1,28 +1,56 @@
|
||||
import { Instance } from "../project/instance"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Env {
|
||||
const state = Instance.state(() => {
|
||||
// Create a shallow copy to isolate environment per instance
|
||||
// Prevents parallel tests from interfering with each other's env vars
|
||||
return { ...process.env } as Record<string, string | undefined>
|
||||
})
|
||||
type State = Record<string, string | undefined>
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (key: string) => Effect.Effect<string | undefined>
|
||||
readonly all: () => Effect.Effect<State>
|
||||
readonly set: (key: string, value: string) => Effect.Effect<void>
|
||||
readonly remove: (key: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Env") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env })))
|
||||
|
||||
const get = Effect.fn("Env.get")((key: string) => InstanceState.use(state, (env) => env[key]))
|
||||
const all = Effect.fn("Env.all")(() => InstanceState.get(state))
|
||||
const set = Effect.fn("Env.set")(function* (key: string, value: string) {
|
||||
const env = yield* InstanceState.get(state)
|
||||
env[key] = value
|
||||
})
|
||||
const remove = Effect.fn("Env.remove")(function* (key: string) {
|
||||
const env = yield* InstanceState.get(state)
|
||||
delete env[key]
|
||||
})
|
||||
|
||||
return Service.of({ get, all, set, remove })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
const rt = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function get(key: string) {
|
||||
const env = state()
|
||||
return env[key]
|
||||
return rt.runSync((svc) => svc.get(key))
|
||||
}
|
||||
|
||||
export function all() {
|
||||
return state()
|
||||
return rt.runSync((svc) => svc.all())
|
||||
}
|
||||
|
||||
export function set(key: string, value: string) {
|
||||
const env = state()
|
||||
env[key] = value
|
||||
return rt.runSync((svc) => svc.set(key, value))
|
||||
}
|
||||
|
||||
export function remove(key: string) {
|
||||
const env = state()
|
||||
delete env[key]
|
||||
return rt.runSync((svc) => svc.remove(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
@@ -342,6 +344,7 @@ export namespace File {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const appFs = yield* AppFileSystem.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
@@ -381,7 +384,10 @@ export namespace File {
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
|
||||
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
const seen = new Set<string>()
|
||||
for (const file of files) {
|
||||
next.files.push(file)
|
||||
@@ -642,5 +648,31 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return runPromise((svc) => svc.status())
|
||||
}
|
||||
|
||||
export async function read(file: string): Promise<Content> {
|
||||
return runPromise((svc) => svc.read(file))
|
||||
}
|
||||
|
||||
export async function list(dir?: string) {
|
||||
return runPromise((svc) => svc.list(dir))
|
||||
}
|
||||
|
||||
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
|
||||
return runPromise((svc) => svc.search(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
// Ripgrep utility functions
|
||||
import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { lazy } from "../util/lazy"
|
||||
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Process } from "../util/process"
|
||||
import { which } from "../util/which"
|
||||
import { text } from "node:stream/consumers"
|
||||
|
||||
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { ripgrep } from "ripgrep"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export namespace Ripgrep {
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
|
||||
const Stats = z.object({
|
||||
elapsed: z.object({
|
||||
secs: z.number(),
|
||||
@@ -94,437 +82,508 @@ export namespace Ripgrep {
|
||||
|
||||
const Result = z.union([Begin, Match, End, Summary])
|
||||
|
||||
const Hit = Schema.Struct({
|
||||
type: Schema.Literal("match"),
|
||||
data: Schema.Struct({
|
||||
path: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: Schema.Number,
|
||||
absolute_offset: Schema.Number,
|
||||
submatches: Schema.mutable(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
const Row = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("begin"), data: Schema.Unknown }),
|
||||
Hit,
|
||||
Schema.Struct({ type: Schema.Literal("end"), data: Schema.Unknown }),
|
||||
Schema.Struct({ type: Schema.Literal("summary"), data: Schema.Unknown }),
|
||||
])
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Row))
|
||||
|
||||
export type Result = z.infer<typeof Result>
|
||||
export type Match = z.infer<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
export type Begin = z.infer<typeof Begin>
|
||||
export type End = z.infer<typeof End>
|
||||
export type Summary = z.infer<typeof Summary>
|
||||
const PLATFORM = {
|
||||
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
|
||||
"arm64-linux": {
|
||||
platform: "aarch64-unknown-linux-gnu",
|
||||
extension: "tar.gz",
|
||||
},
|
||||
"x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
|
||||
"x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
|
||||
"arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" },
|
||||
"x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
|
||||
} as const
|
||||
export type Row = Match["data"]
|
||||
|
||||
export const ExtractionFailedError = NamedError.create(
|
||||
"RipgrepExtractionFailedError",
|
||||
z.object({
|
||||
filepath: z.string(),
|
||||
stderr: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const UnsupportedPlatformError = NamedError.create(
|
||||
"RipgrepUnsupportedPlatformError",
|
||||
z.object({
|
||||
platform: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const DownloadFailedError = NamedError.create(
|
||||
"RipgrepDownloadFailedError",
|
||||
z.object({
|
||||
url: z.string(),
|
||||
status: z.number(),
|
||||
}),
|
||||
)
|
||||
|
||||
const state = lazy(async () => {
|
||||
const system = which("rg")
|
||||
if (system) {
|
||||
const stat = await fs.stat(system).catch(() => undefined)
|
||||
if (stat?.isFile()) return { filepath: system }
|
||||
log.warn("bun.which returned invalid rg path", { filepath: system })
|
||||
}
|
||||
const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : ""))
|
||||
|
||||
if (!(await Filesystem.exists(filepath))) {
|
||||
const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM
|
||||
const config = PLATFORM[platformKey]
|
||||
if (!config) throw new UnsupportedPlatformError({ platform: platformKey })
|
||||
|
||||
const version = "14.1.1"
|
||||
const filename = `ripgrep-${version}-${config.platform}.${config.extension}`
|
||||
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new DownloadFailedError({ url, status: response.status })
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const archivePath = path.join(Global.Path.bin, filename)
|
||||
await Filesystem.write(archivePath, Buffer.from(arrayBuffer))
|
||||
if (config.extension === "tar.gz") {
|
||||
const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
|
||||
|
||||
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
|
||||
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
|
||||
|
||||
const proc = Process.spawn(args, {
|
||||
cwd: Global.Path.bin,
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
})
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
const stderr = proc.stderr ? await text(proc.stderr) : ""
|
||||
throw new ExtractionFailedError({
|
||||
filepath,
|
||||
stderr,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
if (entry.filename.endsWith("rg.exe")) {
|
||||
rgEntry = entry
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!rgEntry) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "rg.exe not found in zip archive",
|
||||
})
|
||||
}
|
||||
|
||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
||||
if (!rgBlob) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "Failed to extract rg.exe from zip archive",
|
||||
})
|
||||
}
|
||||
await Filesystem.write(filepath, Buffer.from(await rgBlob.arrayBuffer()))
|
||||
await zipFileReader.close()
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||
}
|
||||
|
||||
return {
|
||||
filepath,
|
||||
}
|
||||
})
|
||||
|
||||
export async function filepath() {
|
||||
const { filepath } = await state()
|
||||
return filepath
|
||||
export interface SearchResult {
|
||||
items: Item[]
|
||||
partial: boolean
|
||||
}
|
||||
|
||||
export async function* files(input: {
|
||||
export interface FilesInput {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
signal?: AbortSignal
|
||||
}) {
|
||||
input.signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
const args = [await filepath(), "--files", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.hidden !== false) args.push("--hidden")
|
||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
args.push(`--glob=${g}`)
|
||||
}
|
||||
}
|
||||
export interface SearchInput {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
file?: string[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// Guard against invalid cwd to provide a consistent ENOENT error.
|
||||
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
|
||||
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: input.cwd,
|
||||
})
|
||||
}
|
||||
|
||||
const proc = Process.spawn(args, {
|
||||
cwd: input.cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
abort: input.signal,
|
||||
})
|
||||
|
||||
if (!proc.stdout) {
|
||||
throw new Error("Process output not available")
|
||||
}
|
||||
|
||||
let buffer = ""
|
||||
const stream = proc.stdout as AsyncIterable<Buffer | string>
|
||||
for await (const chunk of stream) {
|
||||
input.signal?.throwIfAborted()
|
||||
|
||||
buffer += typeof chunk === "string" ? chunk : chunk.toString()
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = buffer.split(/\r?\n/)
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line) yield line
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer) yield buffer
|
||||
await proc.exited
|
||||
|
||||
input.signal?.throwIfAborted()
|
||||
export interface TreeInput {
|
||||
cwd: string
|
||||
limit?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
}) => Stream.Stream<string, PlatformError>
|
||||
readonly search: (input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
file?: string[]
|
||||
}) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error>
|
||||
readonly files: (input: FilesInput) => Stream.Stream<string, Error>
|
||||
readonly tree: (input: TreeInput) => Effect.Effect<string, Error>
|
||||
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, ChildProcessSpawner | AppFileSystem.Service> = Layer.effect(
|
||||
type Run = { kind: "files" | "search"; cwd: string; args: string[] }
|
||||
|
||||
type WorkerResult = {
|
||||
type: "result"
|
||||
code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
type WorkerLine = {
|
||||
type: "line"
|
||||
line: string
|
||||
}
|
||||
|
||||
type WorkerDone = {
|
||||
type: "done"
|
||||
code: number
|
||||
stderr: string
|
||||
}
|
||||
|
||||
type WorkerError = {
|
||||
type: "error"
|
||||
error: {
|
||||
message: string
|
||||
name?: string
|
||||
stack?: string
|
||||
}
|
||||
}
|
||||
|
||||
function env() {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
|
||||
)
|
||||
delete env.RIPGREP_CONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
function text(input: unknown) {
|
||||
if (typeof input === "string") return input
|
||||
if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
|
||||
if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
|
||||
return String(input)
|
||||
}
|
||||
|
||||
function toError(input: unknown) {
|
||||
if (input instanceof Error) return input
|
||||
if (typeof input === "string") return new Error(input)
|
||||
return new Error(String(input))
|
||||
}
|
||||
|
||||
function abort(signal?: AbortSignal) {
|
||||
const err = signal?.reason
|
||||
if (err instanceof Error) return err
|
||||
const out = new Error("Aborted")
|
||||
out.name = "AbortError"
|
||||
return out
|
||||
}
|
||||
|
||||
function error(stderr: string, code: number) {
|
||||
const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`)
|
||||
err.name = "RipgrepError"
|
||||
return err
|
||||
}
|
||||
|
||||
function clean(file: string) {
|
||||
return path.normalize(file.replace(/^\.[\\/]/, ""))
|
||||
}
|
||||
|
||||
function row(data: Row): Row {
|
||||
return {
|
||||
...data,
|
||||
path: {
|
||||
...data.path,
|
||||
text: clean(data.path.text),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function opts(cwd: string) {
|
||||
return {
|
||||
env: env(),
|
||||
preopens: { ".": cwd },
|
||||
}
|
||||
}
|
||||
|
||||
function check(cwd: string) {
|
||||
return Effect.tryPromise({
|
||||
try: () => fs.stat(cwd).catch(() => undefined),
|
||||
catch: toError,
|
||||
}).pipe(
|
||||
Effect.flatMap((stat) =>
|
||||
stat?.isDirectory()
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
Object.assign(new Error(`No such file or directory: '${cwd}'`), {
|
||||
code: "ENOENT",
|
||||
errno: -2,
|
||||
path: cwd,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function filesArgs(input: FilesInput) {
|
||||
const args = ["--files", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.hidden !== false) args.push("--hidden")
|
||||
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const glob of input.glob) {
|
||||
args.push(`--glob=${glob}`)
|
||||
}
|
||||
}
|
||||
args.push(".")
|
||||
return args
|
||||
}
|
||||
|
||||
function searchArgs(input: SearchInput) {
|
||||
const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"]
|
||||
if (input.follow) args.push("--follow")
|
||||
if (input.glob) {
|
||||
for (const glob of input.glob) {
|
||||
args.push(`--glob=${glob}`)
|
||||
}
|
||||
}
|
||||
if (input.limit) args.push(`--max-count=${input.limit}`)
|
||||
args.push("--", input.pattern, ...(input.file ?? ["."]))
|
||||
return args
|
||||
}
|
||||
|
||||
function parse(stdout: string) {
|
||||
return stdout
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map((line) => Result.parse(JSON.parse(line)))
|
||||
.flatMap((item) => (item.type === "match" ? [row(item.data)] : []))
|
||||
}
|
||||
|
||||
declare const OPENCODE_RIPGREP_WORKER_PATH: string
|
||||
|
||||
function target(): Effect.Effect<string | URL, Error> {
|
||||
if (typeof OPENCODE_RIPGREP_WORKER_PATH !== "undefined") {
|
||||
return Effect.succeed(OPENCODE_RIPGREP_WORKER_PATH)
|
||||
}
|
||||
const js = new URL("./ripgrep.worker.js", import.meta.url)
|
||||
return Effect.tryPromise({
|
||||
try: () => Filesystem.exists(fileURLToPath(js)),
|
||||
catch: toError,
|
||||
}).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url))))
|
||||
}
|
||||
|
||||
function worker() {
|
||||
return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() }))))
|
||||
}
|
||||
|
||||
function drain(buf: string, chunk: unknown, push: (line: string) => void) {
|
||||
const lines = (buf + text(chunk)).split(/\r?\n/)
|
||||
buf = lines.pop() || ""
|
||||
for (const line of lines) {
|
||||
if (line) push(line)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
function fail(queue: Queue.Queue<string, Error | Cause.Done>, err: Error) {
|
||||
Queue.failCauseUnsafe(queue, Cause.fail(err))
|
||||
}
|
||||
|
||||
function searchDirect(input: SearchInput) {
|
||||
return Effect.tryPromise({
|
||||
try: () =>
|
||||
ripgrep(searchArgs(input), {
|
||||
buffer: true,
|
||||
...opts(input.cwd),
|
||||
}),
|
||||
catch: toError,
|
||||
}).pipe(
|
||||
Effect.flatMap((ret) => {
|
||||
const out = ret.stdout ?? ""
|
||||
if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) {
|
||||
return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1))
|
||||
}
|
||||
return Effect.sync(() => ({
|
||||
items: ret.code === 1 ? [] : parse(out),
|
||||
partial: ret.code === 2,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function searchWorker(input: SearchInput) {
|
||||
if (input.signal?.aborted) return Effect.fail(abort(input.signal))
|
||||
|
||||
return Effect.acquireUseRelease(
|
||||
worker(),
|
||||
(w) =>
|
||||
Effect.callback<SearchResult, Error>((resume, signal) => {
|
||||
let open = true
|
||||
const done = (effect: Effect.Effect<SearchResult, Error>) => {
|
||||
if (!open) return
|
||||
open = false
|
||||
resume(effect)
|
||||
}
|
||||
const onabort = () => done(Effect.fail(abort(input.signal)))
|
||||
|
||||
w.onerror = (evt) => {
|
||||
done(Effect.fail(toError(evt.error ?? evt.message)))
|
||||
}
|
||||
w.onmessage = (evt: MessageEvent<WorkerResult | WorkerError>) => {
|
||||
const msg = evt.data
|
||||
if (msg.type === "error") {
|
||||
done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error)))
|
||||
return
|
||||
}
|
||||
if (msg.code === 1) {
|
||||
done(Effect.succeed({ items: [], partial: false }))
|
||||
return
|
||||
}
|
||||
if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) {
|
||||
done(Effect.fail(error(msg.stderr, msg.code)))
|
||||
return
|
||||
}
|
||||
done(
|
||||
Effect.sync(() => ({
|
||||
items: parse(msg.stdout),
|
||||
partial: msg.code === 2,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
input.signal?.addEventListener("abort", onabort, { once: true })
|
||||
signal.addEventListener("abort", onabort, { once: true })
|
||||
w.postMessage({
|
||||
kind: "search",
|
||||
cwd: input.cwd,
|
||||
args: searchArgs(input),
|
||||
} satisfies Run)
|
||||
|
||||
return Effect.sync(() => {
|
||||
input.signal?.removeEventListener("abort", onabort)
|
||||
signal.removeEventListener("abort", onabort)
|
||||
w.onerror = null
|
||||
w.onmessage = null
|
||||
})
|
||||
}),
|
||||
(w) => Effect.sync(() => w.terminate()),
|
||||
)
|
||||
}
|
||||
|
||||
function filesDirect(input: FilesInput) {
|
||||
return Stream.callback<string, Error>(
|
||||
Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
|
||||
let buf = ""
|
||||
let err = ""
|
||||
|
||||
const out = {
|
||||
write(chunk: unknown) {
|
||||
buf = drain(buf, chunk, (line) => {
|
||||
Queue.offerUnsafe(queue, clean(line))
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const stderr = {
|
||||
write(chunk: unknown) {
|
||||
err += text(chunk)
|
||||
},
|
||||
}
|
||||
|
||||
yield* Effect.forkScoped(
|
||||
Effect.gen(function* () {
|
||||
yield* check(input.cwd)
|
||||
const ret = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
ripgrep(filesArgs(input), {
|
||||
stdout: out,
|
||||
stderr,
|
||||
...opts(input.cwd),
|
||||
}),
|
||||
catch: toError,
|
||||
})
|
||||
if (buf) Queue.offerUnsafe(queue, clean(buf))
|
||||
if (ret.code === 0 || ret.code === 1) {
|
||||
Queue.endUnsafe(queue)
|
||||
return
|
||||
}
|
||||
fail(queue, error(err, ret.code ?? 1))
|
||||
}).pipe(
|
||||
Effect.catch((err) =>
|
||||
Effect.sync(() => {
|
||||
fail(queue, err)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function filesWorker(input: FilesInput) {
|
||||
return Stream.callback<string, Error>(
|
||||
Effect.fnUntraced(function* (queue: Queue.Queue<string, Error | Cause.Done>) {
|
||||
if (input.signal?.aborted) {
|
||||
fail(queue, abort(input.signal))
|
||||
return
|
||||
}
|
||||
|
||||
const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate()))
|
||||
let open = true
|
||||
const close = () => {
|
||||
if (!open) return false
|
||||
open = false
|
||||
return true
|
||||
}
|
||||
const onabort = () => {
|
||||
if (!close()) return
|
||||
fail(queue, abort(input.signal))
|
||||
}
|
||||
|
||||
w.onerror = (evt) => {
|
||||
if (!close()) return
|
||||
fail(queue, toError(evt.error ?? evt.message))
|
||||
}
|
||||
w.onmessage = (evt: MessageEvent<WorkerLine | WorkerDone | WorkerError>) => {
|
||||
const msg = evt.data
|
||||
if (msg.type === "line") {
|
||||
if (open) Queue.offerUnsafe(queue, msg.line)
|
||||
return
|
||||
}
|
||||
if (!close()) return
|
||||
if (msg.type === "error") {
|
||||
fail(queue, Object.assign(new Error(msg.error.message), msg.error))
|
||||
return
|
||||
}
|
||||
if (msg.code === 0 || msg.code === 1) {
|
||||
Queue.endUnsafe(queue)
|
||||
return
|
||||
}
|
||||
fail(queue, error(msg.stderr, msg.code))
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
input.signal?.addEventListener("abort", onabort, { once: true })
|
||||
w.postMessage({
|
||||
kind: "files",
|
||||
cwd: input.cwd,
|
||||
args: filesArgs(input),
|
||||
} satisfies Run)
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
input.signal?.removeEventListener("abort", onabort)
|
||||
w.onerror = null
|
||||
w.onmessage = null
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const afs = yield* AppFileSystem.Service
|
||||
const bin = Effect.fn("Ripgrep.path")(function* () {
|
||||
return yield* Effect.promise(() => filepath())
|
||||
})
|
||||
const args = Effect.fn("Ripgrep.args")(function* (input: {
|
||||
mode: "files" | "search"
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
limit?: number
|
||||
pattern?: string
|
||||
file?: string[]
|
||||
}) {
|
||||
const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"]
|
||||
if (input.follow) out.push("--follow")
|
||||
if (input.hidden !== false) out.push("--hidden")
|
||||
if (input.maxDepth !== undefined) out.push(`--max-depth=${input.maxDepth}`)
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
out.push(`--glob=${g}`)
|
||||
const source = (input: FilesInput) => {
|
||||
const useWorker = !!input.signal && typeof Worker !== "undefined"
|
||||
if (!useWorker && input.signal) {
|
||||
log.warn("worker unavailable, ripgrep abort disabled")
|
||||
}
|
||||
return useWorker ? filesWorker(input) : filesDirect(input)
|
||||
}
|
||||
|
||||
const files: Interface["files"] = (input) => source(input)
|
||||
|
||||
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
|
||||
log.info("tree", input)
|
||||
const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
|
||||
|
||||
interface Node {
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
|
||||
function child(node: Node, name: string) {
|
||||
const item = node.children.get(name)
|
||||
if (item) return item
|
||||
const next = { name, children: new Map() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function count(node: Node): number {
|
||||
return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0)
|
||||
}
|
||||
|
||||
const root: Node = { name: "", children: new Map() }
|
||||
for (const file of list) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split(path.sep)
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = child(node, part)
|
||||
}
|
||||
}
|
||||
if (input.limit) out.push(`--max-count=${input.limit}`)
|
||||
if (input.mode === "search") out.push("--no-messages")
|
||||
if (input.pattern) out.push("--", input.pattern, ...(input.file ?? []))
|
||||
return out
|
||||
})
|
||||
|
||||
const files = Effect.fn("Ripgrep.files")(function* (input: {
|
||||
cwd: string
|
||||
glob?: string[]
|
||||
hidden?: boolean
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
}) {
|
||||
const rgPath = yield* bin()
|
||||
const isDir = yield* afs.isDir(input.cwd)
|
||||
if (!isDir) {
|
||||
return yield* Effect.die(
|
||||
Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
|
||||
code: "ENOENT" as const,
|
||||
errno: -2,
|
||||
path: input.cwd,
|
||||
}),
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: node.name }))
|
||||
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
const item = queue[i]
|
||||
lines.push(item.path)
|
||||
used++
|
||||
queue.push(
|
||||
...Array.from(item.node.children.values())
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((node) => ({ node, path: `${item.path}/${node.name}` })),
|
||||
)
|
||||
}
|
||||
|
||||
const cmd = yield* args({
|
||||
mode: "files",
|
||||
glob: input.glob,
|
||||
hidden: input.hidden,
|
||||
follow: input.follow,
|
||||
maxDepth: input.maxDepth,
|
||||
})
|
||||
|
||||
return spawner
|
||||
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
|
||||
.pipe(Stream.filter((line: string) => line.length > 0))
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
return lines.join("\n")
|
||||
})
|
||||
|
||||
const search = Effect.fn("Ripgrep.search")(function* (input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
file?: string[]
|
||||
}) {
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const cmd = yield* args({
|
||||
mode: "search",
|
||||
glob: input.glob,
|
||||
follow: input.follow,
|
||||
limit: input.limit,
|
||||
pattern: input.pattern,
|
||||
file: input.file,
|
||||
})
|
||||
|
||||
const handle = yield* spawner.spawn(
|
||||
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||
cwd: input.cwd,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
|
||||
const [items, stderr, code] = yield* Effect.all(
|
||||
[
|
||||
Stream.decodeText(handle.stdout).pipe(
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
Stream.mapEffect((line) =>
|
||||
decode(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))),
|
||||
),
|
||||
Stream.filter((row): row is Schema.Schema.Type<typeof Hit> => row.type === "match"),
|
||||
Stream.map((row): Item => row.data),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
if (code !== 0 && code !== 1 && code !== 2) {
|
||||
return yield* Effect.fail(new Error(`ripgrep failed: ${stderr}`))
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
partial: code === 2,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) {
|
||||
const useWorker = !!input.signal && typeof Worker !== "undefined"
|
||||
if (!useWorker && input.signal) {
|
||||
log.warn("worker unavailable, ripgrep abort disabled")
|
||||
}
|
||||
return yield* useWorker ? searchWorker(input) : searchDirect(input)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
files: (input) => Stream.unwrap(files(input)),
|
||||
search,
|
||||
})
|
||||
return Service.of({ files, tree, search })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
export const defaultLayer = layer
|
||||
|
||||
export async function tree(input: { cwd: string; limit?: number; signal?: AbortSignal }) {
|
||||
log.info("tree", input)
|
||||
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
|
||||
interface Node {
|
||||
name: string
|
||||
children: Map<string, Node>
|
||||
}
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
function dir(node: Node, name: string) {
|
||||
const existing = node.children.get(name)
|
||||
if (existing) return existing
|
||||
const next = { name, children: new Map() }
|
||||
node.children.set(name, next)
|
||||
return next
|
||||
}
|
||||
export function files(input: FilesInput) {
|
||||
return runPromise((svc) => Stream.toAsyncIterableEffect(svc.files(input)))
|
||||
}
|
||||
|
||||
const root: Node = { name: "", children: new Map() }
|
||||
for (const file of files) {
|
||||
if (file.includes(".opencode")) continue
|
||||
const parts = file.split(path.sep)
|
||||
if (parts.length < 2) continue
|
||||
let node = root
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
node = dir(node, part)
|
||||
}
|
||||
}
|
||||
export function tree(input: TreeInput) {
|
||||
return runPromise((svc) => svc.tree(input))
|
||||
}
|
||||
|
||||
function count(node: Node): number {
|
||||
let total = 0
|
||||
for (const child of node.children.values()) {
|
||||
total += 1 + count(child)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
const total = count(root)
|
||||
const limit = input.limit ?? total
|
||||
const lines: string[] = []
|
||||
const queue: { node: Node; path: string }[] = []
|
||||
for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
queue.push({ node: child, path: child.name })
|
||||
}
|
||||
|
||||
let used = 0
|
||||
for (let i = 0; i < queue.length && used < limit; i++) {
|
||||
const { node, path } = queue[i]
|
||||
lines.push(path)
|
||||
used++
|
||||
for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
queue.push({ node: child, path: `${path}/${child.name}` })
|
||||
}
|
||||
}
|
||||
|
||||
if (total > used) lines.push(`[${total - used} truncated]`)
|
||||
|
||||
return lines.join("\n")
|
||||
export function search(input: SearchInput) {
|
||||
return runPromise((svc) => svc.search(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ripgrep } from "ripgrep"
|
||||
|
||||
function env() {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
|
||||
)
|
||||
delete env.RIPGREP_CONFIG_PATH
|
||||
return env
|
||||
}
|
||||
|
||||
function opts(cwd: string) {
|
||||
return {
|
||||
env: env(),
|
||||
preopens: { ".": cwd },
|
||||
}
|
||||
}
|
||||
|
||||
type Run = {
|
||||
kind: "files" | "search"
|
||||
cwd: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
function text(input: unknown) {
|
||||
if (typeof input === "string") return input
|
||||
if (input instanceof ArrayBuffer) return Buffer.from(input).toString()
|
||||
if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString()
|
||||
return String(input)
|
||||
}
|
||||
|
||||
function error(input: unknown) {
|
||||
if (input instanceof Error) {
|
||||
return {
|
||||
message: input.message,
|
||||
name: input.name,
|
||||
stack: input.stack,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: String(input),
|
||||
}
|
||||
}
|
||||
|
||||
function clean(file: string) {
|
||||
return file.replace(/^\.[\\/]/, "")
|
||||
}
|
||||
|
||||
onmessage = async (evt: MessageEvent<Run>) => {
|
||||
const msg = evt.data
|
||||
|
||||
try {
|
||||
if (msg.kind === "search") {
|
||||
const ret = await ripgrep(msg.args, {
|
||||
buffer: true,
|
||||
...opts(msg.cwd),
|
||||
})
|
||||
postMessage({
|
||||
type: "result",
|
||||
code: ret.code ?? 0,
|
||||
stdout: ret.stdout ?? "",
|
||||
stderr: ret.stderr ?? "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let buf = ""
|
||||
let err = ""
|
||||
const out = {
|
||||
write(chunk: unknown) {
|
||||
buf += text(chunk)
|
||||
const lines = buf.split(/\r?\n/)
|
||||
buf = lines.pop() || ""
|
||||
for (const line of lines) {
|
||||
if (line) postMessage({ type: "line", line: clean(line) })
|
||||
}
|
||||
},
|
||||
}
|
||||
const stderr = {
|
||||
write(chunk: unknown) {
|
||||
err += text(chunk)
|
||||
},
|
||||
}
|
||||
|
||||
const ret = await ripgrep(msg.args, {
|
||||
stdout: out,
|
||||
stderr,
|
||||
...opts(msg.cwd),
|
||||
})
|
||||
|
||||
if (buf) postMessage({ type: "line", line: clean(buf) })
|
||||
postMessage({
|
||||
type: "done",
|
||||
code: ret.code ?? 0,
|
||||
stderr: err,
|
||||
})
|
||||
} catch (err) {
|
||||
postMessage({
|
||||
type: "error",
|
||||
error: error(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,16 +27,16 @@ export namespace Identifier {
|
||||
let counter = 0
|
||||
|
||||
export function ascending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, false, given)
|
||||
return generateID(prefix, "ascending", given)
|
||||
}
|
||||
|
||||
export function descending(prefix: keyof typeof prefixes, given?: string) {
|
||||
return generateID(prefix, true, given)
|
||||
return generateID(prefix, "descending", given)
|
||||
}
|
||||
|
||||
function generateID(prefix: keyof typeof prefixes, descending: boolean, given?: string): string {
|
||||
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
|
||||
if (!given) {
|
||||
return create(prefix, descending)
|
||||
return create(prefixes[prefix], direction)
|
||||
}
|
||||
|
||||
if (!given.startsWith(prefixes[prefix])) {
|
||||
@@ -55,7 +55,7 @@ export namespace Identifier {
|
||||
return result
|
||||
}
|
||||
|
||||
export function create(prefix: keyof typeof prefixes, descending: boolean, timestamp?: number): string {
|
||||
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
|
||||
const currentTimestamp = timestamp ?? Date.now()
|
||||
|
||||
if (currentTimestamp !== lastTimestamp) {
|
||||
@@ -66,14 +66,14 @@ export namespace Identifier {
|
||||
|
||||
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
|
||||
|
||||
now = descending ? ~now : now
|
||||
now = direction === "descending" ? ~now : now
|
||||
|
||||
const timeBytes = Buffer.alloc(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
|
||||
}
|
||||
|
||||
return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
|
||||
}
|
||||
|
||||
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
|
||||
|
||||
@@ -204,6 +204,12 @@ export namespace MCP {
|
||||
defs?: MCPToolDef[]
|
||||
}
|
||||
|
||||
interface AuthResult {
|
||||
authorizationUrl: string
|
||||
oauthState: string
|
||||
client?: MCPClient
|
||||
}
|
||||
|
||||
// --- Effect Service ---
|
||||
|
||||
interface State {
|
||||
@@ -552,6 +558,21 @@ export namespace MCP {
|
||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
const storeClient = Effect.fnUntraced(function* (
|
||||
s: State,
|
||||
name: string,
|
||||
client: MCPClient,
|
||||
listed: MCPToolDef[],
|
||||
timeout?: number,
|
||||
) {
|
||||
yield* closeClient(s, name)
|
||||
s.status[name] = { status: "connected" }
|
||||
s.clients[name] = client
|
||||
s.defs[name] = listed
|
||||
watch(s, name, client, timeout)
|
||||
return s.status[name]
|
||||
})
|
||||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
@@ -583,11 +604,7 @@ export namespace MCP {
|
||||
return result.status
|
||||
}
|
||||
|
||||
yield* closeClient(s, name)
|
||||
s.clients[name] = result.mcpClient
|
||||
s.defs[name] = result.defs!
|
||||
watch(s, name, result.mcpClient, mcp.timeout)
|
||||
return result.status
|
||||
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
|
||||
})
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
|
||||
@@ -753,14 +770,16 @@ export namespace MCP {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "opencode", version: Installation.VERSION })
|
||||
return client.connect(transport).then(() => ({ authorizationUrl: "", oauthState }))
|
||||
return client
|
||||
.connect(transport)
|
||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState })
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState } satisfies AuthResult)
|
||||
}
|
||||
return Effect.die(error)
|
||||
}),
|
||||
@@ -768,14 +787,31 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
||||
const { authorizationUrl, oauthState } = yield* startAuth(mcpName)
|
||||
if (!authorizationUrl) return { status: "connected" } as Status
|
||||
const result = yield* startAuth(mcpName)
|
||||
if (!result.authorizationUrl) {
|
||||
const client = "client" in result ? result.client : undefined
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) {
|
||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||
return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||
}
|
||||
|
||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
||||
const listed = client ? yield* defs(mcpName, client, mcpConfig.timeout) : undefined
|
||||
if (!client || !listed) {
|
||||
yield* Effect.tryPromise(() => client?.close() ?? Promise.resolve()).pipe(Effect.ignore)
|
||||
return { status: "failed", error: "Failed to get tools" } as Status
|
||||
}
|
||||
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState, mcpName)
|
||||
const s = yield* InstanceState.get(state)
|
||||
yield* auth.clearOAuthState(mcpName)
|
||||
return yield* storeClient(s, mcpName, client, listed, mcpConfig.timeout)
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise(() => open(authorizationUrl)).pipe(
|
||||
log.info("opening browser for oauth", { mcpName, url: result.authorizationUrl, state: result.oauthState })
|
||||
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(result.oauthState, mcpName)
|
||||
|
||||
yield* Effect.tryPromise(() => open(result.authorizationUrl)).pipe(
|
||||
Effect.flatMap((subprocess) =>
|
||||
Effect.callback<void, Error>((resume) => {
|
||||
const timer = setTimeout(() => resume(Effect.void), 500)
|
||||
@@ -793,14 +829,14 @@ export namespace MCP {
|
||||
),
|
||||
Effect.catch(() => {
|
||||
log.warn("failed to open browser, user must open URL manually", { mcpName })
|
||||
return bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }).pipe(Effect.ignore)
|
||||
return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
|
||||
const code = yield* Effect.promise(() => callbackPromise)
|
||||
|
||||
const storedState = yield* auth.getOAuthState(mcpName)
|
||||
if (storedState !== oauthState) {
|
||||
if (storedState !== result.oauthState) {
|
||||
yield* auth.clearOAuthState(mcpName)
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
@@ -290,21 +289,4 @@ export namespace Plugin {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function trigger<
|
||||
Name extends TriggerName,
|
||||
Input = Parameters<Required<Hooks>[Name]>[0],
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(name: Name, input: Input, output: Output): Promise<Output> {
|
||||
return runPromise((svc) => svc.trigger(name, input, output))
|
||||
}
|
||||
|
||||
export async function list(): Promise<Hooks[]> {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { LocalContext } from "../util/local-context"
|
||||
import { Project } from "./project"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { State } from "./state"
|
||||
|
||||
export interface InstanceContext {
|
||||
directory: string
|
||||
@@ -16,6 +16,7 @@ export interface InstanceContext {
|
||||
|
||||
const context = LocalContext.create<InstanceContext>("instance")
|
||||
const cache = new Map<string, Promise<InstanceContext>>()
|
||||
const project = makeRuntime(Project.Service, Project.defaultLayer)
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
@@ -30,11 +31,13 @@ function boot(input: { directory: string; init?: () => Promise<any>; worktree?:
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
: await project
|
||||
.runPromise((svc) => svc.fromDirectory(input.directory))
|
||||
.then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
}))
|
||||
await context.provide(ctx, async () => {
|
||||
await input.init?.()
|
||||
})
|
||||
@@ -113,13 +116,10 @@ export const Instance = {
|
||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||
return context.provide(ctx, fn)
|
||||
},
|
||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
|
||||
const directory = Filesystem.resolve(input.directory)
|
||||
Log.Default.info("reloading instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
await disposeInstance(directory)
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
|
||||
@@ -141,7 +141,7 @@ export const Instance = {
|
||||
const directory = Instance.directory
|
||||
const project = Instance.project
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
await disposeInstance(directory)
|
||||
cache.delete(directory)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
|
||||
@@ -10,8 +10,7 @@ import { which } from "../util/which"
|
||||
import { ProjectID } from "./schema"
|
||||
import { Effect, Layer, Path, Scope, Context, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -463,19 +462,6 @@ export namespace Project {
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promise-based API (delegates to Effect service via runPromise)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function fromDirectory(directory: string) {
|
||||
return runPromise((svc) => svc.fromDirectory(directory))
|
||||
}
|
||||
|
||||
export function discover(input: Info) {
|
||||
return runPromise((svc) => svc.discover(input))
|
||||
}
|
||||
|
||||
export function list() {
|
||||
return Database.use((db) =>
|
||||
@@ -498,24 +484,4 @@ export namespace Project {
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
}
|
||||
|
||||
export function initGit(input: { directory: string; project: Info }) {
|
||||
return runPromise((svc) => svc.initGit(input))
|
||||
}
|
||||
|
||||
export function update(input: UpdateInput) {
|
||||
return runPromise((svc) => svc.update(input))
|
||||
}
|
||||
|
||||
export function sandboxes(id: ProjectID) {
|
||||
return runPromise((svc) => svc.sandboxes(id))
|
||||
}
|
||||
|
||||
export function addSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.addSandbox(id, directory))
|
||||
}
|
||||
|
||||
export function removeSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.removeSandbox(id, directory))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
export namespace State {
|
||||
interface Entry {
|
||||
state: any
|
||||
dispose?: (state: any) => Promise<void>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "state" })
|
||||
const recordsByKey = new Map<string, Map<any, Entry>>()
|
||||
|
||||
export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) {
|
||||
return () => {
|
||||
const key = root()
|
||||
let entries = recordsByKey.get(key)
|
||||
if (!entries) {
|
||||
entries = new Map<string, Entry>()
|
||||
recordsByKey.set(key, entries)
|
||||
}
|
||||
const exists = entries.get(init)
|
||||
if (exists) return exists.state as S
|
||||
const state = init()
|
||||
entries.set(init, {
|
||||
state,
|
||||
dispose,
|
||||
})
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispose(key: string) {
|
||||
const entries = recordsByKey.get(key)
|
||||
if (!entries) return
|
||||
|
||||
log.info("waiting for state disposal to complete", { key })
|
||||
|
||||
let disposalFinished = false
|
||||
|
||||
setTimeout(() => {
|
||||
if (!disposalFinished) {
|
||||
log.warn(
|
||||
"state disposal is taking an unusually long time - if it does not complete in a reasonable time, please report this as a bug",
|
||||
{ key },
|
||||
)
|
||||
}
|
||||
}, 10000).unref()
|
||||
|
||||
const tasks: Promise<void>[] = []
|
||||
for (const [init, entry] of entries) {
|
||||
if (!entry.dispose) continue
|
||||
|
||||
const label = typeof init === "function" ? init.name : String(init)
|
||||
|
||||
const task = Promise.resolve(entry.state)
|
||||
.then((state) => entry.dispose!(state))
|
||||
.catch((error) => {
|
||||
log.error("Error while disposing state:", { error, key, init: label })
|
||||
})
|
||||
|
||||
tasks.push(task)
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
|
||||
entries.clear()
|
||||
recordsByKey.delete(key)
|
||||
|
||||
disposalFinished = true
|
||||
log.info("state disposal completed", { key })
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -832,7 +832,16 @@ export namespace ProviderTransform {
|
||||
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||
result["reasoningEffort"] = "medium"
|
||||
result["reasoningSummary"] = "auto"
|
||||
// Only inject reasoningSummary for providers that support it natively.
|
||||
// @ai-sdk/openai-compatible proxies (e.g. LiteLLM) do not understand this
|
||||
// parameter and return "Unknown parameter: 'reasoningSummary'".
|
||||
if (
|
||||
input.model.api.npm === "@ai-sdk/openai" ||
|
||||
input.model.api.npm === "@ai-sdk/azure" ||
|
||||
input.model.api.npm === "@ai-sdk/github-copilot"
|
||||
) {
|
||||
result["reasoningSummary"] = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
// Only set textVerbosity for non-chat gpt-5.x models
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { QuestionID } from "./schema"
|
||||
|
||||
export namespace Question {
|
||||
@@ -12,67 +13,91 @@ export namespace Question {
|
||||
|
||||
// Schemas
|
||||
|
||||
export const Option = z
|
||||
.object({
|
||||
label: z.string().describe("Display text (1-5 words, concise)"),
|
||||
description: z.string().describe("Explanation of choice"),
|
||||
})
|
||||
.meta({ ref: "QuestionOption" })
|
||||
export type Option = z.infer<typeof Option>
|
||||
export class Option extends Schema.Class<Option>("QuestionOption")({
|
||||
label: Schema.String.annotate({
|
||||
description: "Display text (1-5 words, concise)",
|
||||
}),
|
||||
description: Schema.String.annotate({
|
||||
description: "Explanation of choice",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().describe("Very short label (max 30 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
|
||||
})
|
||||
.meta({ ref: "QuestionInfo" })
|
||||
export type Info = z.infer<typeof Info>
|
||||
const base = {
|
||||
question: Schema.String.annotate({
|
||||
description: "Complete question",
|
||||
}),
|
||||
header: Schema.String.annotate({
|
||||
description: "Very short label (max 30 chars)",
|
||||
}),
|
||||
options: Schema.Array(Option).annotate({
|
||||
description: "Available choices",
|
||||
}),
|
||||
multiple: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Allow selecting multiple choices",
|
||||
}),
|
||||
}
|
||||
|
||||
export const Request = z
|
||||
.object({
|
||||
id: QuestionID.zod,
|
||||
sessionID: SessionID.zod,
|
||||
questions: z.array(Info).describe("Questions to ask"),
|
||||
tool: z
|
||||
.object({
|
||||
messageID: MessageID.zod,
|
||||
callID: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.meta({ ref: "QuestionRequest" })
|
||||
export type Request = z.infer<typeof Request>
|
||||
export class Info extends Schema.Class<Info>("QuestionInfo")({
|
||||
...base,
|
||||
custom: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Allow typing a custom answer (default: true)",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" })
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
export class Prompt extends Schema.Class<Prompt>("QuestionPrompt")(base) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
export class Tool extends Schema.Class<Tool>("QuestionTool")({
|
||||
messageID: MessageID,
|
||||
callID: Schema.String,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export class Request extends Schema.Class<Request>("QuestionRequest")({
|
||||
id: QuestionID,
|
||||
sessionID: SessionID,
|
||||
questions: Schema.Array(Info).annotate({
|
||||
description: "Questions to ask",
|
||||
}),
|
||||
tool: Schema.optional(Tool),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Answer = Schema.Array(Schema.String)
|
||||
.annotate({ identifier: "QuestionAnswer" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Answer = Schema.Schema.Type<typeof Answer>
|
||||
|
||||
export class Reply extends Schema.Class<Reply>("QuestionReply")({
|
||||
answers: Schema.Array(Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
class Replied extends Schema.Class<Replied>("QuestionReplied")({
|
||||
sessionID: SessionID,
|
||||
requestID: QuestionID,
|
||||
answers: Schema.Array(Answer),
|
||||
}) {}
|
||||
|
||||
class Rejected extends Schema.Class<Rejected>("QuestionRejected")({
|
||||
sessionID: SessionID,
|
||||
requestID: QuestionID,
|
||||
}) {}
|
||||
|
||||
export const Event = {
|
||||
Asked: BusEvent.define("question.asked", Request),
|
||||
Replied: BusEvent.define(
|
||||
"question.replied",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
"question.rejected",
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
Asked: BusEvent.define("question.asked", Request.zod),
|
||||
Replied: BusEvent.define("question.replied", zod(Replied)),
|
||||
Rejected: BusEvent.define("question.rejected", zod(Rejected)),
|
||||
}
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
|
||||
@@ -83,7 +108,7 @@ export namespace Question {
|
||||
|
||||
interface PendingEntry {
|
||||
info: Request
|
||||
deferred: Deferred.Deferred<Answer[], RejectedError>
|
||||
deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -95,12 +120,12 @@ export namespace Question {
|
||||
export interface Interface {
|
||||
readonly ask: (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
}) => Effect.Effect<Answer[], RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
|
||||
questions: ReadonlyArray<Info>
|
||||
tool?: Tool
|
||||
}) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
||||
readonly reply: (input: { requestID: QuestionID; answers: ReadonlyArray<Answer> }) => Effect.Effect<void>
|
||||
readonly reject: (requestID: QuestionID) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Request[]>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
||||
@@ -130,20 +155,20 @@ export namespace Question {
|
||||
|
||||
const ask = Effect.fn("Question.ask")(function* (input: {
|
||||
sessionID: SessionID
|
||||
questions: Info[]
|
||||
tool?: { messageID: MessageID; callID: string }
|
||||
questions: ReadonlyArray<Info>
|
||||
tool?: Tool
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const id = QuestionID.ascending()
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
const deferred = yield* Deferred.make<Answer[], RejectedError>()
|
||||
const info: Request = {
|
||||
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
|
||||
const info = Schema.decodeUnknownSync(Request)({
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
questions: input.questions,
|
||||
tool: input.tool,
|
||||
}
|
||||
})
|
||||
pending.set(id, { info, deferred })
|
||||
yield* bus.publish(Event.Asked, info)
|
||||
|
||||
@@ -155,7 +180,10 @@ export namespace Question {
|
||||
)
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) {
|
||||
const reply = Effect.fn("Question.reply")(function* (input: {
|
||||
requestID: QuestionID
|
||||
answers: ReadonlyArray<Answer>
|
||||
}) {
|
||||
const pending = (yield* InstanceState.get(state)).pending
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { lazy } from "../../util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { HttpApiRoutes } from "./httpapi"
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
@@ -39,6 +40,7 @@ const ConsoleSwitchBody = z.object({
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.route("/httpapi", HttpApiRoutes())
|
||||
.get(
|
||||
"/console",
|
||||
describeRoute({
|
||||
@@ -254,7 +256,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await Worktree.create(body)
|
||||
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
|
||||
return c.json(worktree)
|
||||
},
|
||||
)
|
||||
@@ -276,7 +278,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const sandboxes = await Project.sandboxes(Instance.project.id)
|
||||
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
|
||||
return c.json(sandboxes)
|
||||
},
|
||||
)
|
||||
@@ -301,8 +303,10 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", Worktree.RemoveInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.remove(body)
|
||||
await Project.removeSandbox(Instance.project.id, body.directory)
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
|
||||
await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -327,7 +331,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", Worktree.ResetInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.reset(body)
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Hono } from "hono"
|
||||
import { QuestionHttpApiHandler } from "./question"
|
||||
|
||||
export const HttpApiRoutes = lazy(() =>
|
||||
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
|
||||
)
|
||||
@@ -0,0 +1,99 @@
|
||||
import { AppLayer } from "@/effect/app-runtime"
|
||||
import { memoMap } from "@/effect/run-service"
|
||||
import { Question } from "@/question"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import type { Handler } from "hono"
|
||||
|
||||
const root = "/experimental/httpapi/question"
|
||||
const Reply = Schema.Struct({
|
||||
answers: Schema.Array(Question.Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
})
|
||||
|
||||
const Api = HttpApi.make("question")
|
||||
.add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("list", root, {
|
||||
success: Schema.Array(Question.Request),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.list",
|
||||
summary: "List pending questions",
|
||||
description: "Get all pending question requests across all sessions.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
|
||||
params: { requestID: QuestionID },
|
||||
payload: Reply,
|
||||
success: Schema.Boolean,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "question.reply",
|
||||
summary: "Reply to question request",
|
||||
description: "Provide answers to a question request from the AI assistant.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "question",
|
||||
description: "Experimental HttpApi question routes.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "opencode experimental HttpApi",
|
||||
version: "0.0.1",
|
||||
description: "Experimental HttpApi surface for selected instance routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
const QuestionLive = HttpApiBuilder.group(
|
||||
Api,
|
||||
"question",
|
||||
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
|
||||
const svc = yield* Question.Service
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
return yield* svc.list()
|
||||
})
|
||||
|
||||
const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: {
|
||||
params: { requestID: QuestionID }
|
||||
payload: Schema.Schema.Type<typeof Reply>
|
||||
}) {
|
||||
yield* svc.reply({
|
||||
requestID: ctx.params.requestID,
|
||||
answers: ctx.payload.answers,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
}),
|
||||
).pipe(Layer.provide(Question.defaultLayer))
|
||||
|
||||
const web = lazy(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
Layer.mergeAll(
|
||||
AppLayer,
|
||||
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
|
||||
Layer.provide(QuestionLive),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
),
|
||||
{
|
||||
disableLogger: true,
|
||||
memoMap,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
|
||||
@@ -41,7 +41,7 @@ async function getSessionWorkspace(url: URL) {
|
||||
const id = getSessionID(url)
|
||||
if (!id) return null
|
||||
|
||||
const session = await Session.get(id).catch(() => undefined)
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined)
|
||||
return session?.workspaceID
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,9 @@ export const ProjectRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await Project.initGit({
|
||||
directory: dir,
|
||||
project: prev,
|
||||
})
|
||||
const next = await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
await Instance.reload({
|
||||
directory: dir,
|
||||
@@ -112,7 +111,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const project = await Project.update({ ...body, projectID })
|
||||
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
|
||||
return c.json(project)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -8,6 +8,12 @@ import z from "zod"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
const Reply = z.object({
|
||||
answers: Question.Answer.zod
|
||||
.array()
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
|
||||
export const QuestionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
@@ -21,7 +27,7 @@ export const QuestionRoutes = lazy(() =>
|
||||
description: "List of pending questions",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Question.Request.array()),
|
||||
schema: resolver(Question.Request.zod.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -56,7 +62,7 @@ export const QuestionRoutes = lazy(() =>
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Question.Reply),
|
||||
validator("json", Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
|
||||
@@ -121,12 +121,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.get.schema,
|
||||
sessionID: Session.GetInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -152,12 +152,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.children.schema,
|
||||
sessionID: Session.ChildrenInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await Session.children(sessionID)
|
||||
const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.children(sessionID)))
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -209,10 +209,10 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", Session.create.schema),
|
||||
validator("json", Session.CreateInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await SessionShare.create(body)
|
||||
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -237,12 +237,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.remove.schema,
|
||||
sessionID: Session.RemoveInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.remove(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -285,22 +285,27 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const current = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const current = yield* session.get(sessionID)
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
await Session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
yield* session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
yield* session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
yield* session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
|
||||
const session = await Session.get(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -341,13 +346,17 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -371,14 +380,14 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.fork.schema.shape.sessionID,
|
||||
sessionID: Session.ForkInput.shape.sessionID,
|
||||
}),
|
||||
),
|
||||
validator("json", Session.fork.schema.omit({ sessionID: true })),
|
||||
validator("json", Session.ForkInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const result = await Session.fork({ ...body, sessionID })
|
||||
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
@@ -407,7 +416,7 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
await SessionPrompt.cancel(c.req.valid("param").sessionID)
|
||||
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -437,8 +446,14 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await SessionShare.share(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -511,8 +526,14 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await SessionShare.unshare(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -645,15 +666,14 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
if (query.limit === undefined) {
|
||||
await Session.get(sessionID)
|
||||
const messages = await Session.messages({ sessionID })
|
||||
return c.json(messages)
|
||||
}
|
||||
|
||||
if (query.limit === 0) {
|
||||
await Session.get(sessionID)
|
||||
const messages = await Session.messages({ sessionID })
|
||||
if (query.limit === undefined || query.limit === 0) {
|
||||
const messages = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
yield* session.get(sessionID)
|
||||
return yield* session.messages({ sessionID })
|
||||
}),
|
||||
)
|
||||
return c.json(messages)
|
||||
}
|
||||
|
||||
@@ -781,11 +801,15 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await Session.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
})
|
||||
await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -823,7 +847,7 @@ export const SessionRoutes = lazy(() =>
|
||||
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
|
||||
)
|
||||
}
|
||||
const part = await Session.updatePart(body)
|
||||
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
|
||||
return c.json(part)
|
||||
},
|
||||
)
|
||||
@@ -863,7 +887,9 @@ export const SessionRoutes = lazy(() =>
|
||||
return stream(c, async (stream) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.prompt({ ...body, sessionID })
|
||||
const msg = await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||
)
|
||||
stream.write(JSON.stringify(msg))
|
||||
})
|
||||
},
|
||||
@@ -892,7 +918,7 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
||||
AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
@@ -936,7 +962,7 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.command({ ...body, sessionID })
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
@@ -968,7 +994,7 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await SessionPrompt.shell({ ...body, sessionID })
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import z from "zod"
|
||||
import { Bus } from "../../bus"
|
||||
import { Session } from "../../session"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "../../util/queue"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
@@ -370,7 +371,7 @@ export const TuiRoutes = lazy(() =>
|
||||
validator("json", TuiEvent.SessionSelect.properties),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json")
|
||||
await Session.get(sessionID)
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
|
||||
@@ -9,14 +9,12 @@ import z from "zod"
|
||||
import { Token } from "../util/token"
|
||||
import { Log } from "../util/log"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow } from "./overflow"
|
||||
|
||||
@@ -310,31 +308,51 @@ When constructing the summary, try to stick to this template:
|
||||
}
|
||||
|
||||
if (!replay) {
|
||||
const continueMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: userMessage.agent,
|
||||
model: userMessage.model,
|
||||
})
|
||||
const text =
|
||||
(input.overflow
|
||||
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
|
||||
: "") +
|
||||
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: continueMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
},
|
||||
})
|
||||
const info = yield* provider.getProvider(userMessage.model.providerID)
|
||||
if (
|
||||
(yield* plugin.trigger(
|
||||
"experimental.compaction.autocontinue",
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
agent: userMessage.agent,
|
||||
model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||
provider: {
|
||||
source: info.source,
|
||||
info,
|
||||
options: info.options,
|
||||
},
|
||||
message: userMessage,
|
||||
overflow: input.overflow === true,
|
||||
},
|
||||
{ enabled: true },
|
||||
)).enabled
|
||||
) {
|
||||
const continueMsg = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: input.sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: userMessage.agent,
|
||||
model: userMessage.model,
|
||||
})
|
||||
const text =
|
||||
(input.overflow
|
||||
? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n"
|
||||
: "") +
|
||||
"Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed."
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: continueMsg.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text,
|
||||
time: {
|
||||
start: Date.now(),
|
||||
end: Date.now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,25 +406,4 @@ When constructing the summary, try to stick to this template:
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
|
||||
return runPromise((svc) => svc.isOverflow(input))
|
||||
}
|
||||
|
||||
export async function prune(input: { sessionID: SessionID }) {
|
||||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
agent: z.string(),
|
||||
model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }),
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
}),
|
||||
(input) => runPromise((svc) => svc.create(input)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
@@ -29,7 +28,6 @@ import type { Provider } from "@/provider/provider"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import { Effect, Layer, Option, Context } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Session {
|
||||
const log = Log.create({ service: "session" })
|
||||
@@ -179,6 +177,30 @@ export namespace Session {
|
||||
})
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const CreateInput = z
|
||||
.object({
|
||||
parentID: SessionID.zod.optional(),
|
||||
title: z.string().optional(),
|
||||
permission: Info.shape.permission,
|
||||
workspaceID: WorkspaceID.zod.optional(),
|
||||
})
|
||||
.optional()
|
||||
export type CreateInput = z.output<typeof CreateInput>
|
||||
|
||||
export const ForkInput = z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() })
|
||||
export const GetInput = SessionID.zod
|
||||
export const ChildrenInput = SessionID.zod
|
||||
export const RemoveInput = SessionID.zod
|
||||
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() })
|
||||
export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() })
|
||||
export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset })
|
||||
export const SetRevertInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
revert: Info.shape.revert,
|
||||
summary: Info.shape.summary,
|
||||
})
|
||||
export const MessagesInput = z.object({ sessionID: SessionID.zod, limit: z.number().optional() })
|
||||
|
||||
export const Event = {
|
||||
Created: SyncEvent.define({
|
||||
type: "session.created",
|
||||
@@ -682,48 +704,6 @@ export namespace Session {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Storage.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const create = fn(
|
||||
z
|
||||
.object({
|
||||
parentID: SessionID.zod.optional(),
|
||||
title: z.string().optional(),
|
||||
permission: Info.shape.permission,
|
||||
workspaceID: WorkspaceID.zod.optional(),
|
||||
})
|
||||
.optional(),
|
||||
(input) => runPromise((svc) => svc.create(input)),
|
||||
)
|
||||
|
||||
export const fork = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod.optional() }), (input) =>
|
||||
runPromise((svc) => svc.fork(input)),
|
||||
)
|
||||
|
||||
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
|
||||
|
||||
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
|
||||
runPromise((svc) => svc.setTitle(input)),
|
||||
)
|
||||
|
||||
export const setArchived = fn(z.object({ sessionID: SessionID.zod, time: z.number().optional() }), (input) =>
|
||||
runPromise((svc) => svc.setArchived(input)),
|
||||
)
|
||||
|
||||
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||
runPromise((svc) => svc.setPermission(input)),
|
||||
)
|
||||
|
||||
export const setRevert = fn(
|
||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||
(input) =>
|
||||
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
|
||||
)
|
||||
|
||||
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
|
||||
runPromise((svc) => svc.messages(input)),
|
||||
)
|
||||
|
||||
export function* list(input?: {
|
||||
directory?: string
|
||||
workspaceID?: WorkspaceID
|
||||
@@ -835,25 +815,4 @@ export namespace Session {
|
||||
yield { ...fromRow(row), project }
|
||||
}
|
||||
}
|
||||
|
||||
export const children = fn(SessionID.zod, (id) => runPromise((svc) => svc.children(id)))
|
||||
export const remove = fn(SessionID.zod, (id) => runPromise((svc) => svc.remove(id)))
|
||||
export async function updateMessage<T extends MessageV2.Info>(msg: T): Promise<T> {
|
||||
MessageV2.Info.parse(msg)
|
||||
return runPromise((svc) => svc.updateMessage(msg))
|
||||
}
|
||||
|
||||
export const removeMessage = fn(z.object({ sessionID: SessionID.zod, messageID: MessageID.zod }), (input) =>
|
||||
runPromise((svc) => svc.removeMessage(input)),
|
||||
)
|
||||
|
||||
export const removePart = fn(
|
||||
z.object({ sessionID: SessionID.zod, messageID: MessageID.zod, partID: PartID.zod }),
|
||||
(input) => runPromise((svc) => svc.removePart(input)),
|
||||
)
|
||||
|
||||
export async function updatePart<T extends MessageV2.Part>(part: T): Promise<T> {
|
||||
MessageV2.Part.parse(part)
|
||||
return runPromise((svc) => svc.updatePart(part))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Config } from "@/config/config"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Session } from "."
|
||||
import { LLM } from "./llm"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
@@ -19,11 +18,12 @@ import { SessionSummary } from "./summary"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { Log } from "@/util/log"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
export namespace SessionProcessor {
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
const log = EffectLogger.create({ service: "session.processor" })
|
||||
const log = Log.create({ service: "session.processor" })
|
||||
|
||||
export type Result = "compact" | "stop" | "continue"
|
||||
|
||||
@@ -124,7 +124,7 @@ export namespace SessionProcessor {
|
||||
reasoningMap: {},
|
||||
}
|
||||
let aborted = false
|
||||
const slog = log.with({ sessionID: input.sessionID, messageID: input.assistantMessage.id })
|
||||
const slog = log.clone().tag("sessionID", input.sessionID).tag("messageID", input.assistantMessage.id)
|
||||
|
||||
const parse = (e: unknown) =>
|
||||
MessageV2.fromError(e, {
|
||||
@@ -454,7 +454,7 @@ export namespace SessionProcessor {
|
||||
return
|
||||
|
||||
default:
|
||||
yield* slog.info("unhandled", { event: value.type, value })
|
||||
slog.info("unhandled", { event: value.type, value })
|
||||
return
|
||||
}
|
||||
})
|
||||
@@ -520,7 +520,7 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
|
||||
yield* slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
|
||||
slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
|
||||
const error = parse(e)
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
ctx.needsCompaction = true
|
||||
@@ -536,7 +536,7 @@ export namespace SessionProcessor {
|
||||
})
|
||||
|
||||
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
||||
yield* slog.info("process")
|
||||
slog.info("process")
|
||||
ctx.needsCompaction = false
|
||||
ctx.shouldBreak = (yield* config.get()).experimental?.continue_loop_on_deny !== true
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
|
||||
@@ -1708,8 +1707,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
),
|
||||
),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const PromptInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
@@ -1777,26 +1774,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
export type PromptInput = z.infer<typeof PromptInput>
|
||||
|
||||
export async function prompt(input: PromptInput) {
|
||||
return runPromise((svc) => svc.prompt(PromptInput.parse(input)))
|
||||
}
|
||||
|
||||
export async function resolvePromptParts(template: string) {
|
||||
return runPromise((svc) => svc.resolvePromptParts(z.string().parse(template)))
|
||||
}
|
||||
|
||||
export async function cancel(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.cancel(SessionID.zod.parse(sessionID)))
|
||||
}
|
||||
|
||||
export const LoopInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
})
|
||||
|
||||
export async function loop(input: z.infer<typeof LoopInput>) {
|
||||
return runPromise((svc) => svc.loop(LoopInput.parse(input)))
|
||||
}
|
||||
|
||||
export const ShellInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
@@ -1811,10 +1792,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
export type ShellInput = z.infer<typeof ShellInput>
|
||||
|
||||
export async function shell(input: ShellInput) {
|
||||
return runPromise((svc) => svc.shell(ShellInput.parse(input)))
|
||||
}
|
||||
|
||||
export const CommandInput = z.object({
|
||||
messageID: MessageID.zod.optional(),
|
||||
sessionID: SessionID.zod,
|
||||
@@ -1838,10 +1815,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
export type CommandInput = z.infer<typeof CommandInput>
|
||||
|
||||
export async function command(input: CommandInput) {
|
||||
return runPromise((svc) => svc.command(CommandInput.parse(input)))
|
||||
}
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function createStructuredOutputTool(input: {
|
||||
schema: Record<string, any>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Session } from "@/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Effect, Layer, Scope, Context } from "effect"
|
||||
import { Config } from "../config/config"
|
||||
import { Flag } from "../flag/flag"
|
||||
@@ -10,7 +8,7 @@ import { ShareNext } from "./share-next"
|
||||
|
||||
export namespace SessionShare {
|
||||
export interface Interface {
|
||||
readonly create: (input?: Parameters<typeof Session.create>[0]) => Effect.Effect<Session.Info>
|
||||
readonly create: (input?: Session.CreateInput) => Effect.Effect<Session.Info>
|
||||
readonly share: (sessionID: SessionID) => Effect.Effect<{ url: string }, unknown>
|
||||
readonly unshare: (sessionID: SessionID) => Effect.Effect<void, unknown>
|
||||
}
|
||||
@@ -40,7 +38,7 @@ export namespace SessionShare {
|
||||
yield* Effect.sync(() => SyncEvent.run(Session.Event.Updated, { sessionID, info: { share: { url: null } } }))
|
||||
})
|
||||
|
||||
const create = Effect.fn("SessionShare.create")(function* (input?: Parameters<typeof Session.create>[0]) {
|
||||
const create = Effect.fn("SessionShare.create")(function* (input?: Session.CreateInput) {
|
||||
const result = yield* session.create(input)
|
||||
if (result.parentID) return result
|
||||
const conf = yield* cfg.get()
|
||||
@@ -58,10 +56,4 @@ export namespace SessionShare {
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const create = fn(Session.create.schema, (input) => runPromise((svc) => svc.create(input)))
|
||||
export const share = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.share(sessionID)))
|
||||
export const unshare = fn(SessionID.zod, (sessionID) => runPromise((svc) => svc.unshare(sessionID)))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "path"
|
||||
import z from "zod"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Hash } from "@/util/hash"
|
||||
import { Config } from "../config/config"
|
||||
@@ -770,34 +769,4 @@ export namespace Snapshot {
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function track() {
|
||||
return runPromise((svc) => svc.track())
|
||||
}
|
||||
|
||||
export async function patch(hash: string) {
|
||||
return runPromise((svc) => svc.patch(hash))
|
||||
}
|
||||
|
||||
export async function restore(snapshot: string) {
|
||||
return runPromise((svc) => svc.restore(snapshot))
|
||||
}
|
||||
|
||||
export async function revert(patches: Patch[]) {
|
||||
return runPromise((svc) => svc.revert(patches))
|
||||
}
|
||||
|
||||
export async function diff(hash: string) {
|
||||
return runPromise((svc) => svc.diff(hash))
|
||||
}
|
||||
|
||||
export async function diffFull(from: string, to: string) {
|
||||
return runPromise((svc) => svc.diffFull(from, to))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +437,11 @@ export const BashTool = Tool.define(
|
||||
).pipe(Effect.orDie)
|
||||
|
||||
const meta: string[] = []
|
||||
if (expired) meta.push(`bash tool terminated command after exceeding timeout ${input.timeout} ms`)
|
||||
if (expired) {
|
||||
meta.push(
|
||||
`bash tool terminated command after exceeding timeout ${input.timeout} ms. If this command is expected to take longer and is not waiting for interactive input, retry with a larger timeout value in milliseconds.`,
|
||||
)
|
||||
}
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
if (meta.length > 0) {
|
||||
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import type { Tool } from "./tool"
|
||||
import { Instance } from "../project/instance"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
@@ -21,8 +22,9 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec
|
||||
|
||||
if (options?.bypass) return
|
||||
|
||||
const ins = yield* InstanceState.context
|
||||
const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target
|
||||
if (Instance.containsPath(full)) return
|
||||
if (Instance.containsPath(full, ins)) return
|
||||
|
||||
const kind = options?.kind ?? "file"
|
||||
const dir = kind === "directory" ? full : path.dirname(full)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./glob.txt"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const GlobTool = Tool.define(
|
||||
"glob",
|
||||
@@ -28,6 +28,7 @@ export const GlobTool = Tool.define(
|
||||
}),
|
||||
execute: (params: { pattern: string; path?: string }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const ins = yield* InstanceState.context
|
||||
yield* ctx.ask({
|
||||
permission: "glob",
|
||||
patterns: [params.pattern],
|
||||
@@ -38,8 +39,8 @@ export const GlobTool = Tool.define(
|
||||
},
|
||||
})
|
||||
|
||||
let search = params.path ?? Instance.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search)
|
||||
let search = params.path ?? ins.directory
|
||||
search = path.isAbsolute(search) ? search : path.resolve(ins.directory, search)
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (info?.type === "File") {
|
||||
throw new Error(`glob path must be a directory: ${search}`)
|
||||
@@ -48,14 +49,14 @@ export const GlobTool = Tool.define(
|
||||
|
||||
const limit = 100
|
||||
let truncated = false
|
||||
const files = yield* rg.files({ cwd: search, glob: [params.pattern] }).pipe(
|
||||
const files = yield* rg.files({ cwd: search, glob: [params.pattern], signal: ctx.abort }).pipe(
|
||||
Stream.mapEffect((file) =>
|
||||
Effect.gen(function* () {
|
||||
const full = path.resolve(search, file)
|
||||
const info = yield* fs.stat(full).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const mtime =
|
||||
info?.mtime.pipe(
|
||||
Option.map((d) => d.getTime()),
|
||||
Option.map((date) => date.getTime()),
|
||||
Option.getOrElse(() => 0),
|
||||
) ?? 0
|
||||
return { path: full, mtime }
|
||||
@@ -75,7 +76,7 @@ export const GlobTool = Tool.define(
|
||||
const output = []
|
||||
if (files.length === 0) output.push("No files found")
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((f) => f.path))
|
||||
output.push(...files.map((file) => file.path))
|
||||
if (truncated) {
|
||||
output.push("")
|
||||
output.push(
|
||||
@@ -85,7 +86,7 @@ export const GlobTool = Tool.define(
|
||||
}
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, search),
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Tool } from "./tool"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./grep.txt"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
@@ -46,15 +45,16 @@ export const GrepTool = Tool.define(
|
||||
},
|
||||
})
|
||||
|
||||
const searchPath = AppFileSystem.resolve(
|
||||
path.isAbsolute(params.path ?? Instance.directory)
|
||||
? (params.path ?? Instance.directory)
|
||||
: path.join(Instance.directory, params.path ?? "."),
|
||||
const ins = yield* InstanceState.context
|
||||
const search = AppFileSystem.resolve(
|
||||
path.isAbsolute(params.path ?? ins.directory)
|
||||
? (params.path ?? ins.directory)
|
||||
: path.join(ins.directory, params.path ?? "."),
|
||||
)
|
||||
const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath)
|
||||
const file = info?.type === "Directory" ? undefined : [searchPath]
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, {
|
||||
const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const cwd = info?.type === "Directory" ? search : path.dirname(search)
|
||||
const file = info?.type === "Directory" ? undefined : [path.relative(cwd, search)]
|
||||
yield* assertExternalDirectoryEffect(ctx, search, {
|
||||
kind: info?.type === "Directory" ? "directory" : "file",
|
||||
})
|
||||
|
||||
@@ -63,8 +63,8 @@ export const GrepTool = Tool.define(
|
||||
pattern: params.pattern,
|
||||
glob: params.include ? [params.include] : undefined,
|
||||
file,
|
||||
signal: ctx.abort,
|
||||
})
|
||||
|
||||
if (result.items.length === 0) return empty
|
||||
|
||||
const rows = result.items.map((item) => ({
|
||||
@@ -101,46 +101,43 @@ export const GrepTool = Tool.define(
|
||||
|
||||
const limit = 100
|
||||
const truncated = matches.length > limit
|
||||
const finalMatches = truncated ? matches.slice(0, limit) : matches
|
||||
const final = truncated ? matches.slice(0, limit) : matches
|
||||
if (final.length === 0) return empty
|
||||
|
||||
if (finalMatches.length === 0) return empty
|
||||
const total = matches.length
|
||||
const output = [`Found ${total} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
const totalMatches = matches.length
|
||||
const outputLines = [`Found ${totalMatches} matches${truncated ? ` (showing first ${limit})` : ""}`]
|
||||
|
||||
let currentFile = ""
|
||||
for (const match of finalMatches) {
|
||||
if (currentFile !== match.path) {
|
||||
if (currentFile !== "") {
|
||||
outputLines.push("")
|
||||
}
|
||||
currentFile = match.path
|
||||
outputLines.push(`${match.path}:`)
|
||||
let current = ""
|
||||
for (const match of final) {
|
||||
if (current !== match.path) {
|
||||
if (current !== "") output.push("")
|
||||
current = match.path
|
||||
output.push(`${match.path}:`)
|
||||
}
|
||||
const truncatedLineText =
|
||||
const text =
|
||||
match.text.length > MAX_LINE_LENGTH ? match.text.substring(0, MAX_LINE_LENGTH) + "..." : match.text
|
||||
outputLines.push(` Line ${match.line}: ${truncatedLineText}`)
|
||||
output.push(` Line ${match.line}: ${text}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
outputLines.push("")
|
||||
outputLines.push(
|
||||
`(Results truncated: showing ${limit} of ${totalMatches} matches (${totalMatches - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
output.push("")
|
||||
output.push(
|
||||
`(Results truncated: showing ${limit} of ${total} matches (${total - limit} hidden). Consider using a more specific path or pattern.)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (result.partial) {
|
||||
outputLines.push("")
|
||||
outputLines.push("(Some paths were inaccessible and skipped)")
|
||||
output.push("")
|
||||
output.push("(Some paths were inaccessible and skipped)")
|
||||
}
|
||||
|
||||
return {
|
||||
title: params.pattern,
|
||||
metadata: {
|
||||
matches: totalMatches,
|
||||
matches: total,
|
||||
truncated,
|
||||
},
|
||||
output: outputLines.join("\n"),
|
||||
output: output.join("\n"),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import * as path from "path"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import * as path from "path"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||
import DESCRIPTION from "./ls.txt"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
export const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
@@ -53,80 +53,68 @@ export const ListTool = Tool.define(
|
||||
}),
|
||||
execute: (params: { path?: string; ignore?: string[] }, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const searchPath = path.resolve(Instance.directory, params.path || ".")
|
||||
yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" })
|
||||
const ins = yield* InstanceState.context
|
||||
const search = path.resolve(ins.directory, params.path || ".")
|
||||
yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" })
|
||||
|
||||
yield* ctx.ask({
|
||||
permission: "list",
|
||||
patterns: [searchPath],
|
||||
patterns: [search],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
path: searchPath,
|
||||
path: search,
|
||||
},
|
||||
})
|
||||
|
||||
const ignoreGlobs = IGNORE_PATTERNS.map((p) => `!${p}*`).concat(params.ignore?.map((p) => `!${p}`) || [])
|
||||
const files = yield* rg.files({ cwd: searchPath, glob: ignoreGlobs }).pipe(
|
||||
Stream.take(LIMIT),
|
||||
const glob = IGNORE_PATTERNS.map((item) => `!${item}*`).concat(params.ignore?.map((item) => `!${item}`) || [])
|
||||
const files = yield* rg.files({ cwd: search, glob, signal: ctx.abort }).pipe(
|
||||
Stream.take(LIMIT + 1),
|
||||
Stream.runCollect,
|
||||
Effect.map((chunk) => [...chunk]),
|
||||
)
|
||||
|
||||
// Build directory structure
|
||||
const dirs = new Set<string>()
|
||||
const filesByDir = new Map<string, string[]>()
|
||||
const truncated = files.length > LIMIT
|
||||
if (truncated) files.length = LIMIT
|
||||
|
||||
const dirs = new Set<string>()
|
||||
const map = new Map<string, string[]>()
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file)
|
||||
const parts = dir === "." ? [] : dir.split("/")
|
||||
|
||||
// Add all parent directories
|
||||
for (let i = 0; i <= parts.length; i++) {
|
||||
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/")
|
||||
dirs.add(dirPath)
|
||||
dirs.add(i === 0 ? "." : parts.slice(0, i).join("/"))
|
||||
}
|
||||
|
||||
// Add file to its directory
|
||||
if (!filesByDir.has(dir)) filesByDir.set(dir, [])
|
||||
filesByDir.get(dir)!.push(path.basename(file))
|
||||
if (!map.has(dir)) map.set(dir, [])
|
||||
map.get(dir)!.push(path.basename(file))
|
||||
}
|
||||
|
||||
function renderDir(dirPath: string, depth: number): string {
|
||||
function render(dir: string, depth: number): string {
|
||||
const indent = " ".repeat(depth)
|
||||
let output = ""
|
||||
if (depth > 0) output += `${indent}${path.basename(dir)}/\n`
|
||||
|
||||
if (depth > 0) {
|
||||
output += `${indent}${path.basename(dirPath)}/\n`
|
||||
}
|
||||
|
||||
const childIndent = " ".repeat(depth + 1)
|
||||
const children = Array.from(dirs)
|
||||
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
|
||||
const child = " ".repeat(depth + 1)
|
||||
const dirs2 = Array.from(dirs)
|
||||
.filter((item) => path.dirname(item) === dir && item !== dir)
|
||||
.sort()
|
||||
|
||||
// Render subdirectories first
|
||||
for (const child of children) {
|
||||
output += renderDir(child, depth + 1)
|
||||
for (const item of dirs2) {
|
||||
output += render(item, depth + 1)
|
||||
}
|
||||
|
||||
// Render files
|
||||
const files = filesByDir.get(dirPath) || []
|
||||
const files = map.get(dir) || []
|
||||
for (const file of files.sort()) {
|
||||
output += `${childIndent}${file}\n`
|
||||
output += `${child}${file}\n`
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const output = `${searchPath}/\n` + renderDir(".", 0)
|
||||
|
||||
return {
|
||||
title: path.relative(Instance.worktree, searchPath),
|
||||
title: path.relative(ins.worktree, search),
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated: files.length >= LIMIT,
|
||||
truncated,
|
||||
},
|
||||
output,
|
||||
output: `${search}/\n` + render(".", 0),
|
||||
}
|
||||
}).pipe(Effect.orDie),
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import { Question } from "../question"
|
||||
import DESCRIPTION from "./question.txt"
|
||||
|
||||
const parameters = z.object({
|
||||
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
|
||||
questions: z.array(Question.Prompt.zod).describe("Questions to ask"),
|
||||
})
|
||||
|
||||
type Metadata = {
|
||||
answers: Question.Answer[]
|
||||
answers: ReadonlyArray<Question.Answer>
|
||||
}
|
||||
|
||||
export const QuestionTool = Tool.define<typeof parameters, Metadata, Question.Service>(
|
||||
|
||||
@@ -78,6 +78,7 @@ export namespace ToolRegistry {
|
||||
Service,
|
||||
never,
|
||||
| Config.Service
|
||||
| Env.Service
|
||||
| Plugin.Service
|
||||
| Question.Service
|
||||
| Todo.Service
|
||||
@@ -99,6 +100,7 @@ export namespace ToolRegistry {
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const env = yield* Env.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const agents = yield* Agent.Service
|
||||
const skill = yield* Skill.Service
|
||||
@@ -272,13 +274,14 @@ export namespace ToolRegistry {
|
||||
})
|
||||
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
const e2e = !!(yield* env.get("OPENCODE_E2E_LLM_URL"))
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
|
||||
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
const usePatch =
|
||||
!!Env.get("OPENCODE_E2E_LLM_URL") ||
|
||||
e2e ||
|
||||
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
|
||||
if (tool.id === ApplyPatchTool.id) return usePatch
|
||||
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
|
||||
@@ -325,6 +328,7 @@ export namespace ToolRegistry {
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
|
||||
@@ -2,11 +2,11 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../skill"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { Skill } from "../skill"
|
||||
import { Tool } from "./tool"
|
||||
|
||||
const Parameters = z.object({
|
||||
name: z.string().describe("The name of the skill from available_skills"),
|
||||
@@ -17,6 +17,7 @@ export const SkillTool = Tool.define(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const rg = yield* Ripgrep.Service
|
||||
|
||||
return () =>
|
||||
Effect.gen(function* () {
|
||||
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
|
||||
@@ -45,10 +46,9 @@ export const SkillTool = Tool.define(
|
||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* skill.get(params.name)
|
||||
|
||||
if (!info) {
|
||||
const all = yield* skill.all()
|
||||
const available = all.map((s) => s.name).join(", ")
|
||||
const available = all.map((item) => item.name).join(", ")
|
||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
@@ -61,9 +61,8 @@ export const SkillTool = Tool.define(
|
||||
|
||||
const dir = path.dirname(info.location)
|
||||
const base = pathToFileURL(dir).href
|
||||
|
||||
const limit = 10
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
|
||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||
Stream.map((file) => path.resolve(dir, file)),
|
||||
Stream.take(limit),
|
||||
|
||||
@@ -48,7 +48,9 @@ export namespace Truncate {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
|
||||
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
|
||||
const cutoff = Identifier.timestamp(
|
||||
Identifier.create("tool", "ascending", Date.now() - Duration.toMillis(RETENTION)),
|
||||
)
|
||||
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
|
||||
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
|
||||
Effect.catch(() => Effect.succeed([])),
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export namespace SessionCommon {}
|
||||
@@ -1,114 +1,51 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { Database } from "@/node"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { SessionEntryTable } from "@/session/session.sql"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { eq } from "../storage/db"
|
||||
import { Schema } from "effect"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { produce } from "immer"
|
||||
|
||||
export namespace SessionEntry {
|
||||
export const ID = Schema.String.pipe(Schema.brand("Session.Entry.ID")).pipe(
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.ascending("entry")),
|
||||
prefix: "ent",
|
||||
})),
|
||||
)
|
||||
export const ID = SessionEvent.ID
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
id: SessionEvent.ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}
|
||||
|
||||
export class Source extends Schema.Class<Source>("Session.Entry.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Entry.File.Attachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(url: string) {
|
||||
return new FileAttachment({
|
||||
uri: url,
|
||||
mime: "text/plain",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Entry.Agent.Attachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class User extends Schema.Class<User>("Session.Entry.User")({
|
||||
...Base,
|
||||
text: SessionEvent.Prompt.fields.text,
|
||||
files: SessionEvent.Prompt.fields.files,
|
||||
agents: SessionEvent.Prompt.fields.agents,
|
||||
type: Schema.Literal("user"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
}),
|
||||
}) {
|
||||
static create(input: { text: User["text"]; files?: User["files"]; agents?: User["agents"] }) {
|
||||
const msg = new User({
|
||||
id: ID.create(),
|
||||
static fromEvent(event: SessionEvent.Prompt) {
|
||||
return new User({
|
||||
id: event.id,
|
||||
type: "user",
|
||||
...input,
|
||||
time: {
|
||||
created: Effect.runSync(DateTime.now),
|
||||
},
|
||||
metadata: event.metadata,
|
||||
text: event.text,
|
||||
files: event.files,
|
||||
agents: event.agents,
|
||||
time: { created: event.timestamp },
|
||||
})
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic")({
|
||||
...SessionEvent.Synthetic.fields,
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Request extends Schema.Class<Request>("Session.Entry.Request")({
|
||||
...Base,
|
||||
type: Schema.Literal("start"),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Text extends Schema.Class<Text>("Session.Entry.Text")({
|
||||
...Base,
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
...Base.time.fields,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Reasoning extends Schema.Class<Reasoning>("Session.Entry.Reasoning")({
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
time: Schema.Struct({
|
||||
...Base.time.fields,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
||||
status: Schema.Literal("pending"),
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
raw: Schema.String,
|
||||
input: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ToolStateRunning extends Schema.Class<ToolStateRunning>("Session.Entry.ToolState.Running")({
|
||||
@@ -124,7 +61,7 @@ export namespace SessionEntry {
|
||||
output: Schema.String,
|
||||
title: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class ToolStateError extends Schema.Class<ToolStateError>("Session.Entry.ToolState.Error")({
|
||||
@@ -132,34 +69,42 @@ export namespace SessionEntry {
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
error: Schema.String,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError])
|
||||
export type ToolState = Schema.Schema.Type<typeof ToolState>
|
||||
|
||||
export class Tool extends Schema.Class<Tool>("Session.Entry.Tool")({
|
||||
...Base,
|
||||
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
|
||||
type: Schema.Literal("tool"),
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
state: ToolState,
|
||||
time: Schema.Struct({
|
||||
...Base.time.fields,
|
||||
created: Schema.DateTimeUtc,
|
||||
ran: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
pruned: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Complete extends Schema.Class<Complete>("Session.Entry.Complete")({
|
||||
export class AssistantText extends Schema.Class<AssistantText>("Session.Entry.Assistant.Text")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Session.Entry.Assistant.Reasoning")({
|
||||
type: Schema.Literal("reasoning"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool])
|
||||
export type AssistantContent = Schema.Schema.Type<typeof AssistantContent>
|
||||
|
||||
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
|
||||
...Base,
|
||||
type: Schema.Literal("complete"),
|
||||
cost: Schema.Number,
|
||||
reason: Schema.String,
|
||||
type: Schema.Literal("assistant"),
|
||||
content: AssistantContent.pipe(Schema.Array),
|
||||
cost: Schema.Number.pipe(Schema.optional),
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
@@ -168,30 +113,174 @@ export namespace SessionEntry {
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}).pipe(Schema.optional),
|
||||
error: Schema.String.pipe(Schema.optional),
|
||||
time: Schema.Struct({
|
||||
created: Schema.DateTimeUtc,
|
||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||
}),
|
||||
}) {}
|
||||
|
||||
export class Retry extends Schema.Class<Retry>("Session.Entry.Retry")({
|
||||
...Base,
|
||||
type: Schema.Literal("retry"),
|
||||
attempt: Schema.Number,
|
||||
error: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
||||
...Base,
|
||||
...SessionEvent.Compacted.fields,
|
||||
type: Schema.Literal("compaction"),
|
||||
auto: Schema.Boolean,
|
||||
overflow: Schema.Boolean.pipe(Schema.optional),
|
||||
...Base,
|
||||
}) {}
|
||||
|
||||
export const Entry = Schema.Union([User, Synthetic, Request, Tool, Text, Reasoning, Complete, Retry, Compaction], {
|
||||
mode: "oneOf",
|
||||
})
|
||||
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction])
|
||||
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
export type Type = Entry["type"]
|
||||
|
||||
export type History = {
|
||||
entries: Entry[]
|
||||
pending: Entry[]
|
||||
}
|
||||
|
||||
export function step(old: History, event: SessionEvent.Event): History {
|
||||
return produce(old, (draft) => {
|
||||
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
|
||||
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
|
||||
|
||||
switch (event.type) {
|
||||
case "prompt": {
|
||||
if (pendingAssistant) {
|
||||
// @ts-expect-error
|
||||
draft.pending.push(User.fromEvent(event))
|
||||
break
|
||||
}
|
||||
// @ts-expect-error
|
||||
draft.entries.push(User.fromEvent(event))
|
||||
break
|
||||
}
|
||||
case "step.started": {
|
||||
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
|
||||
draft.entries.push({
|
||||
id: event.id,
|
||||
type: "assistant",
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
content: [],
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "text.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "text")
|
||||
if (match) match.text += event.delta
|
||||
break
|
||||
}
|
||||
case "text.ended": {
|
||||
break
|
||||
}
|
||||
case "tool.input.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "tool",
|
||||
callID: event.callID,
|
||||
name: event.name,
|
||||
time: {
|
||||
created: event.timestamp,
|
||||
},
|
||||
state: {
|
||||
status: "pending",
|
||||
input: "",
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
case "tool.input.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match) match.state.input += event.delta
|
||||
break
|
||||
}
|
||||
case "tool.input.ended": {
|
||||
break
|
||||
}
|
||||
case "tool.called": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match) {
|
||||
match.time.ran = event.timestamp
|
||||
match.state = {
|
||||
status: "running",
|
||||
input: event.input,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool.success": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
output: event.output ?? "",
|
||||
title: event.title,
|
||||
metadata: event.metadata ?? {},
|
||||
// @ts-expect-error
|
||||
attachments: event.attachments ?? [],
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "tool.error": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "tool")
|
||||
if (match && match.state.status === "running") {
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.error,
|
||||
input: match.state.input,
|
||||
metadata: event.metadata ?? {},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "reasoning.started": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.content.push({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
})
|
||||
break
|
||||
}
|
||||
case "reasoning.delta": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
|
||||
if (match) match.text += event.delta
|
||||
break
|
||||
}
|
||||
case "reasoning.ended": {
|
||||
if (!pendingAssistant) break
|
||||
const match = pendingAssistant.content.findLast((x) => x.type === "reasoning")
|
||||
if (match) match.text = event.text
|
||||
break
|
||||
}
|
||||
case "step.ended": {
|
||||
if (!pendingAssistant) break
|
||||
pendingAssistant.time.completed = event.timestamp
|
||||
pendingAssistant.cost = event.cost
|
||||
pendingAssistant.tokens = event.tokens
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
export interface Interface {
|
||||
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
|
||||
readonly fromSession: (sessionID: SessionID) => Effect.Effect<Entry[], never>
|
||||
@@ -224,4 +313,5 @@ export namespace SessionEntry {
|
||||
})
|
||||
}),
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import { Identifier } from "@/id/id"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export namespace SessionEvent {
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Session.Event.ID"),
|
||||
withStatics((s) => ({
|
||||
create: () => s.make(Identifier.create("evt", "ascending")),
|
||||
})),
|
||||
)
|
||||
export type ID = Schema.Schema.Type<typeof ID>
|
||||
type Stamp = Schema.Schema.Type<typeof Schema.DateTimeUtc>
|
||||
type BaseInput = {
|
||||
id?: ID
|
||||
metadata?: Record<string, unknown>
|
||||
timestamp?: Stamp
|
||||
}
|
||||
|
||||
const Base = {
|
||||
id: ID,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
timestamp: Schema.DateTimeUtc,
|
||||
}
|
||||
|
||||
export class Source extends Schema.Class<Source>("Session.Event.Source")({
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Session.Event.FileAttachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: FileAttachment) {
|
||||
return new FileAttachment({
|
||||
...input,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Session.Event.AgentAttachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
|
||||
...Base,
|
||||
type: Schema.Literal("prompt"),
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string; files?: FileAttachment[]; agents?: AgentAttachment[] }) {
|
||||
return new Prompt({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "prompt",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
files: input.files,
|
||||
agents: input.agents,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Synthetic extends Schema.Class<Synthetic>("Session.Event.Synthetic")({
|
||||
...Base,
|
||||
type: Schema.Literal("synthetic"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string }) {
|
||||
return new Synthetic({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "synthetic",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Step {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Step.Started")({
|
||||
...Base,
|
||||
type: Schema.Literal("step.started"),
|
||||
model: Schema.Struct({
|
||||
id: Schema.String,
|
||||
providerID: Schema.String,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(input: BaseInput & { model: { id: string; providerID: string; variant?: string } }) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "step.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
model: input.model,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Step.Ended")({
|
||||
...Base,
|
||||
type: Schema.Literal("step.ended"),
|
||||
reason: Schema.String,
|
||||
cost: Schema.Number,
|
||||
tokens: Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
reasoning: Schema.Number,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Number,
|
||||
write: Schema.Number,
|
||||
}),
|
||||
}),
|
||||
}) {
|
||||
static create(input: BaseInput & { reason: string; cost: number; tokens: Ended["tokens"] }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "step.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
reason: input.reason,
|
||||
cost: input.cost,
|
||||
tokens: input.tokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Text {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Text.Started")({
|
||||
...Base,
|
||||
type: Schema.Literal("text.started"),
|
||||
}) {
|
||||
static create(input: BaseInput = {}) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "text.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Delta extends Schema.Class<Delta>("Session.Event.Text.Delta")({
|
||||
...Base,
|
||||
type: Schema.Literal("text.delta"),
|
||||
delta: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { delta: string }) {
|
||||
return new Delta({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "text.delta",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
delta: input.delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Text.Ended")({
|
||||
...Base,
|
||||
type: Schema.Literal("text.ended"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "text.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Reasoning {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Reasoning.Started")({
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning.started"),
|
||||
}) {
|
||||
static create(input: BaseInput = {}) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "reasoning.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Delta extends Schema.Class<Delta>("Session.Event.Reasoning.Delta")({
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning.delta"),
|
||||
delta: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { delta: string }) {
|
||||
return new Delta({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "reasoning.delta",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
delta: input.delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Reasoning.Ended")({
|
||||
...Base,
|
||||
type: Schema.Literal("reasoning.ended"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { text: string }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "reasoning.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Tool {
|
||||
export namespace Input {
|
||||
export class Started extends Schema.Class<Started>("Session.Event.Tool.Input.Started")({
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
name: Schema.String,
|
||||
type: Schema.Literal("tool.input.started"),
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; name: string }) {
|
||||
return new Started({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.input.started",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
name: input.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Delta extends Schema.Class<Delta>("Session.Event.Tool.Input.Delta")({
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
type: Schema.Literal("tool.input.delta"),
|
||||
delta: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; delta: string }) {
|
||||
return new Delta({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.input.delta",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
delta: input.delta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Ended extends Schema.Class<Ended>("Session.Event.Tool.Input.Ended")({
|
||||
...Base,
|
||||
callID: Schema.String,
|
||||
type: Schema.Literal("tool.input.ended"),
|
||||
text: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; text: string }) {
|
||||
return new Ended({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.input.ended",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
text: input.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Called extends Schema.Class<Called>("Session.Event.Tool.Called")({
|
||||
...Base,
|
||||
type: Schema.Literal("tool.called"),
|
||||
callID: Schema.String,
|
||||
tool: Schema.String,
|
||||
input: Schema.Record(Schema.String, Schema.Unknown),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(
|
||||
input: BaseInput & {
|
||||
callID: string
|
||||
tool: string
|
||||
input: Record<string, unknown>
|
||||
provider: Called["provider"]
|
||||
},
|
||||
) {
|
||||
return new Called({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.called",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
tool: input.tool,
|
||||
input: input.input,
|
||||
provider: input.provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Success extends Schema.Class<Success>("Session.Event.Tool.Success")({
|
||||
...Base,
|
||||
type: Schema.Literal("tool.success"),
|
||||
callID: Schema.String,
|
||||
title: Schema.String,
|
||||
output: Schema.String.pipe(Schema.optional),
|
||||
attachments: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(
|
||||
input: BaseInput & {
|
||||
callID: string
|
||||
title: string
|
||||
output?: string
|
||||
attachments?: FileAttachment[]
|
||||
provider: Success["provider"]
|
||||
},
|
||||
) {
|
||||
return new Success({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.success",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
title: input.title,
|
||||
output: input.output,
|
||||
attachments: input.attachments,
|
||||
provider: input.provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Error extends Schema.Class<Error>("Session.Event.Tool.Error")({
|
||||
...Base,
|
||||
type: Schema.Literal("tool.error"),
|
||||
callID: Schema.String,
|
||||
error: Schema.String,
|
||||
provider: Schema.Struct({
|
||||
executed: Schema.Boolean,
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
|
||||
}),
|
||||
}) {
|
||||
static create(input: BaseInput & { callID: string; error: string; provider: Error["provider"] }) {
|
||||
return new Error({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "tool.error",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
callID: input.callID,
|
||||
error: input.error,
|
||||
provider: input.provider,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
||||
...Base,
|
||||
type: Schema.Literal("retried"),
|
||||
error: Schema.String,
|
||||
}) {
|
||||
static create(input: BaseInput & { error: string }) {
|
||||
return new Retried({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "retried",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
error: input.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Compacted extends Schema.Class<Compacted>("Session.Event.Compated")({
|
||||
...Base,
|
||||
type: Schema.Literal("compacted"),
|
||||
auto: Schema.Boolean,
|
||||
overflow: Schema.Boolean.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: BaseInput & { auto: boolean; overflow?: boolean }) {
|
||||
return new Compacted({
|
||||
id: input.id ?? ID.create(),
|
||||
type: "compacted",
|
||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||
metadata: input.metadata,
|
||||
auto: input.auto,
|
||||
overflow: input.overflow,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const Event = Schema.Union(
|
||||
[
|
||||
Prompt,
|
||||
Synthetic,
|
||||
Step.Started,
|
||||
Step.Ended,
|
||||
Text.Started,
|
||||
Text.Delta,
|
||||
Text.Ended,
|
||||
Tool.Input.Started,
|
||||
Tool.Input.Delta,
|
||||
Tool.Input.Ended,
|
||||
Tool.Called,
|
||||
Tool.Success,
|
||||
Tool.Error,
|
||||
Reasoning.Started,
|
||||
Reasoning.Delta,
|
||||
Reasoning.Ended,
|
||||
Retried,
|
||||
Compacted,
|
||||
],
|
||||
{
|
||||
mode: "oneOf",
|
||||
},
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type Type = Event["type"]
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -598,25 +597,4 @@ export namespace Worktree {
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function makeWorktreeInfo(name?: string) {
|
||||
return runPromise((svc) => svc.makeWorktreeInfo(name))
|
||||
}
|
||||
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
return runPromise((svc) => svc.createFromInfo(info, startCommand))
|
||||
}
|
||||
|
||||
export async function create(input?: CreateInput) {
|
||||
return runPromise((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
export async function remove(input: RemoveInput) {
|
||||
return runPromise((svc) => svc.remove(input))
|
||||
}
|
||||
|
||||
export async function reset(input: ResetInput) {
|
||||
return runPromise((svc) => svc.reset(input))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,46 @@ describe("acp.agent event subscription", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not emit user_message_chunk for live prompt parts", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { agent, controller, sessionUpdates, stop } = createFakeAgent()
|
||||
const cwd = "/tmp/opencode-acp-test"
|
||||
const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId)
|
||||
|
||||
controller.push({
|
||||
directory: cwd,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: sessionId,
|
||||
time: Date.now(),
|
||||
part: {
|
||||
id: "part_1",
|
||||
sessionID: sessionId,
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(
|
||||
sessionUpdates
|
||||
.filter((u) => u.sessionId === sessionId)
|
||||
.some((u) => u.update.sessionUpdate === "user_message_chunk"),
|
||||
).toBe(false)
|
||||
|
||||
stop()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
@@ -35,6 +36,7 @@ const emptyAuth = Layer.mock(Auth.Service)({
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
@@ -332,6 +334,7 @@ test("resolves env templates in account config with account token", async () =>
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(fakeAccount),
|
||||
Layer.provideMerge(infra),
|
||||
@@ -1824,6 +1827,7 @@ test("project config overrides remote well-known config", async () => {
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
@@ -1879,6 +1883,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user