mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-14 09:54:46 +00:00
Compare commits
1 Commits
kit/facade
...
oc-basecod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73a4f5a654 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -25,7 +25,6 @@ kommander
|
||||
-opencodeengineer bot that spams issues
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-ricardo-m-l
|
||||
-robinmordasiewicz
|
||||
simonklee
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
|
||||
8
.github/workflows/publish.yml
vendored
8
.github/workflows/publish.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -213,6 +213,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -389,7 +390,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -590,12 +591,13 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release
|
||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
12
bun.lock
12
bun.lock
@@ -319,7 +319,6 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||
"@ai-sdk/anthropic": "3.0.67",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
@@ -371,7 +370,6 @@
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-sound": "1.1.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
@@ -709,8 +707,6 @@
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||
@@ -2669,8 +2665,6 @@
|
||||
|
||||
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
||||
|
||||
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
@@ -3095,8 +3089,6 @@
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
|
||||
|
||||
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
||||
|
||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||
@@ -4417,8 +4409,6 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||
|
||||
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||
@@ -5013,8 +5003,6 @@
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
|
||||
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=",
|
||||
"aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=",
|
||||
"aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=",
|
||||
"x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8="
|
||||
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
|
||||
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
|
||||
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
|
||||
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,12 +155,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
resetHeartbeat()
|
||||
streamErrorLogged = false
|
||||
const directory = event.directory ?? "global"
|
||||
if (event.payload.type === "sync") {
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = event.payload as Event
|
||||
|
||||
const payload = event.payload
|
||||
const k = key(directory, payload)
|
||||
if (k) {
|
||||
const i = coalesced.get(k)
|
||||
|
||||
@@ -274,7 +274,7 @@ const WorkspaceSessionList = (props: {
|
||||
<div class="relative w-full py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
||||
size="large"
|
||||
onClick={(e: MouseEvent) => {
|
||||
props.loadMore()
|
||||
|
||||
@@ -642,10 +642,10 @@ export function MessageTimeline(props: {
|
||||
onClick={props.onResumeScroll}
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-[var(--gray-dark-7)] bg-[color-mix(in_srgb,var(--gray-dark-3)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--gray-dark-8)] [--icon-base:var(--gray-dark-10)] group-hover:[--icon-base:var(--gray-dark-11)]"
|
||||
style={{
|
||||
"box-shadow":
|
||||
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||
"0 51px 60px 0 rgba(0,0,0,0.13), 0 15.375px 18.088px 0 rgba(0,0,0,0.19), 0 6.386px 7.513px 0 rgba(0,0,0,0.25), 0 2.31px 2.717px 0 rgba(0,0,0,0.38)",
|
||||
}}
|
||||
>
|
||||
<Icon name="arrow-down-to-line" size="small" />
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createMainWindow(globals: Globals) {
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
show: true,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
@@ -94,10 +94,6 @@ export function createMainWindow(globals: Globals) {
|
||||
wireZoom(win)
|
||||
injectGlobals(win, globals)
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
Use these rules when writing or migrating Effect code.
|
||||
|
||||
See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
|
||||
## Core
|
||||
|
||||
@@ -51,7 +51,7 @@ See `specs/effect/migration.md` for the compact pattern reference and examples.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect/migration.md` for the full pattern.
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
|
||||
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_workspace` (
|
||||
`id` text PRIMARY KEY,
|
||||
`type` text NOT NULL,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`branch` text,
|
||||
`directory` text,
|
||||
`extra` text,
|
||||
`project_id` text NOT NULL,
|
||||
CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_workspace`(`id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id`) SELECT `id`, `type`, `branch`, `name`, `directory`, `extra`, `project_id` FROM `workspace`;--> statement-breakpoint
|
||||
DROP TABLE `workspace`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE `session_entry` (
|
||||
`id` text PRIMARY KEY,
|
||||
`session_id` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`time_created` integer NOT NULL,
|
||||
`time_updated` integer NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
CONSTRAINT `fk_session_entry_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `session_entry_session_idx` ON `session_entry` (`session_id`);--> statement-breakpoint
|
||||
CREATE INDEX `session_entry_session_type_idx` ON `session_entry` (`session_id`,`type`);--> statement-breakpoint
|
||||
CREATE INDEX `session_entry_time_created_idx` ON `session_entry` (`time_created`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,18 @@
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
||||
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
|
||||
"lint": "echo 'Running lint checks...' && bun test --coverage",
|
||||
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
|
||||
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
|
||||
"deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
|
||||
"db": "bun drizzle-kit"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"randomField": "this-is-a-random-value-12345",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -76,7 +83,6 @@
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||
"@ai-sdk/anthropic": "3.0.67",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
@@ -128,7 +134,6 @@
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-sound": "1.1.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
|
||||
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
||||
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
||||
@@ -18,58 +16,44 @@ const seed = async () => {
|
||||
const { Project } = await import("../src/project/project")
|
||||
const { ModelID, ProviderID } = await import("../src/provider/schema")
|
||||
const { ToolRegistry } = await import("../src/tool/registry")
|
||||
const { Effect } = await import("effect")
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: dir,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.waitForDependencies()))
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
await Config.waitForDependencies()
|
||||
await ToolRegistry.ids()
|
||||
|
||||
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" })),
|
||||
)
|
||||
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" })
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await Instance.disposeAll().catch(() => {})
|
||||
await AppRuntime.dispose().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -178,9 +178,7 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
|
||||
## Migration checklist
|
||||
|
||||
Service-shape migrated (single namespace, traced methods, `InstanceState` where needed).
|
||||
|
||||
This checklist is only about the service shape migration. Many of these services still keep `makeRuntime(...)` plus async facade exports; that facade-removal phase is tracked separately in `facades.md`.
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
@@ -223,16 +221,59 @@ This checklist is only about the service shape migration. Many of these services
|
||||
- [x] `Provider` — `provider/provider.ts`
|
||||
- [x] `Storage` — `storage/storage.ts`
|
||||
- [x] `ShareNext` — `share/share-next.ts`
|
||||
|
||||
Still open:
|
||||
|
||||
- [x] `SessionTodo` — `session/todo.ts`
|
||||
- [ ] `SyncEvent` — `sync/index.ts`
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts`
|
||||
|
||||
Still open at the service-shape level:
|
||||
## Tool interface → Effect
|
||||
|
||||
- [ ] `SyncEvent` — `sync/index.ts` (deferred pending sync with James)
|
||||
- [ ] `Workspace` — `control-plane/workspace.ts` (deferred pending sync with James)
|
||||
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
|
||||
|
||||
## Tool migration
|
||||
1. Migrate each tool body to return Effects
|
||||
2. Keep `Tool.define()` inputs Effect-native
|
||||
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
|
||||
|
||||
Tool-specific migration guidance and checklist live in `tools.md`.
|
||||
### Tool migration details
|
||||
|
||||
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
|
||||
|
||||
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
|
||||
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
|
||||
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
|
||||
|
||||
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
|
||||
|
||||
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
|
||||
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
|
||||
|
||||
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
|
||||
|
||||
Individual tools, ordered by value:
|
||||
|
||||
- [ ] `apply_patch.ts` — HIGH: multi-step orchestration, error accumulation, Bus events
|
||||
- [ ] `bash.ts` — HIGH: shell orchestration, quoting, timeout handling, output capture
|
||||
- [x] `read.ts` — HIGH: streaming I/O, readline, binary detection → FileSystem + Stream
|
||||
- [ ] `edit.ts` — HIGH: multi-step diff/format/publish pipeline, FileWatcher lock
|
||||
- [ ] `grep.ts` — MEDIUM: spawns ripgrep → ChildProcessSpawner, timeout handling
|
||||
- [ ] `write.ts` — MEDIUM: permission checks, diagnostics polling, Bus events
|
||||
- [ ] `codesearch.ts` — MEDIUM: HTTP + SSE + manual timeout → HttpClient + Effect.timeout
|
||||
- [ ] `webfetch.ts` — MEDIUM: fetch with UA retry, size limits → HttpClient
|
||||
- [ ] `websearch.ts` — MEDIUM: MCP over HTTP → HttpClient
|
||||
- [ ] `batch.ts` — MEDIUM: parallel execution, per-call error recovery → Effect.all
|
||||
- [ ] `task.ts` — MEDIUM: task state management
|
||||
- [ ] `ls.ts` — MEDIUM: bounded directory listing over ripgrep-backed traversal
|
||||
- [ ] `multiedit.ts` — MEDIUM: sequential edit orchestration over `edit.ts`
|
||||
- [ ] `glob.ts` — LOW: simple async generator
|
||||
- [ ] `lsp.ts` — LOW: dispatch switch over LSP operations
|
||||
- [ ] `question.ts` — LOW: prompt wrapper
|
||||
- [ ] `skill.ts` — LOW: skill tool adapter
|
||||
- [ ] `todo.ts` — LOW: todo persistence wrapper
|
||||
- [ ] `invalid.ts` — LOW: invalid-tool fallback
|
||||
- [ ] `plan.ts` — LOW: plan file operations
|
||||
|
||||
## Effect service adoption in already-migrated code
|
||||
|
||||
@@ -240,19 +281,27 @@ Some already-effectified areas still use raw `Filesystem.*` or `Process.spawn` i
|
||||
|
||||
### `Filesystem.*` → `AppFileSystem.Service` (yield in layer)
|
||||
|
||||
- [x] `config/config.ts` — `installDependencies()` now uses `AppFileSystem`
|
||||
- [x] `provider/provider.ts` — recent model state now reads via `AppFileSystem.Service`
|
||||
- [ ] `file/index.ts` — 1 remaining `Filesystem.readText()` call in untracked diff handling
|
||||
- [ ] `config/config.ts` — 5 remaining `Filesystem.*` calls in `installDependencies()`
|
||||
- [ ] `provider/provider.ts` — 1 remaining `Filesystem.readJson()` call for recent model state
|
||||
|
||||
### `Process.spawn` → `ChildProcessSpawner` (yield in layer)
|
||||
|
||||
- [x] `format/formatter.ts` — direct `Process.spawn()` checks removed (`air`, `uv`)
|
||||
- [ ] `format/formatter.ts` — 2 remaining `Process.spawn()` checks (`air`, `uv`)
|
||||
- [ ] `lsp/server.ts` — multiple `Process.spawn()` installs/download helpers
|
||||
|
||||
## Filesystem consolidation
|
||||
|
||||
`util/filesystem.ts` is still used widely across `src/`, and raw `fs` / `fs/promises` imports still exist in multiple tooling and infrastructure files. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` where possible — this should happen naturally during each migration, not as a separate sweep.
|
||||
`util/filesystem.ts` (raw fs wrapper) is currently imported by **34 files**. The effectified `AppFileSystem` service (`filesystem/index.ts`) is currently imported by **15 files**. As services and tools are effectified, they should switch from `Filesystem.*` to yielding `AppFileSystem.Service` — this happens naturally during each migration, not as a separate effort.
|
||||
|
||||
Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||
Similarly, **21 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
|
||||
|
||||
Current raw fs users that will convert during tool migration:
|
||||
|
||||
- `tool/read.ts` — fs.createReadStream, readline
|
||||
- `tool/apply_patch.ts` — fs/promises
|
||||
- `file/ripgrep.ts` — fs/promises
|
||||
- `patch/index.ts` — fs, fs/promises
|
||||
|
||||
## Primitives & utilities
|
||||
|
||||
@@ -263,9 +312,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||
|
||||
## Destroying the facades
|
||||
|
||||
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
|
||||
|
||||
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||
Every service currently exports async facade functions at the bottom of its namespace — `export async function read(...) { return runPromise(...) }` — backed by a per-service `makeRuntime`. These exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||
|
||||
### Process
|
||||
|
||||
@@ -294,14 +341,47 @@ For each service, the migration is roughly:
|
||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
|
||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||
|
||||
## Route handler effectification
|
||||
|
||||
Route-handler migration guidance and checklist live in `routes.md`.
|
||||
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
|
||||
|
||||
```ts
|
||||
// Before — one facade call per service
|
||||
;async (c) => {
|
||||
await SessionRunState.assertNotBusy(id)
|
||||
await Session.removeMessage({ sessionID: id, messageID })
|
||||
return c.json(true)
|
||||
}
|
||||
|
||||
// After — one Effect.gen, yield services from context
|
||||
;async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(id)
|
||||
yield* session.removeMessage({ sessionID: id, messageID })
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
}
|
||||
```
|
||||
|
||||
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
|
||||
|
||||
Route files to convert (each handler that calls facades should be wrapped):
|
||||
|
||||
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
|
||||
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
|
||||
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
|
||||
- [ ] `server/routes/question.ts` — uses Question
|
||||
- [ ] `server/routes/pty.ts` — uses Pty
|
||||
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
|
||||
@@ -1,238 +0,0 @@
|
||||
# Facade removal checklist
|
||||
|
||||
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
|
||||
|
||||
As of 2026-04-13, latest `origin/dev`:
|
||||
|
||||
- `src/` still has 15 `makeRuntime(...)` call sites.
|
||||
- 13 of those are still in scope for facade removal.
|
||||
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
|
||||
|
||||
Recent progress:
|
||||
|
||||
- Wave 1 is merged: `Pty`, `Skill`, `Vcs`, `ToolRegistry`, `Auth`.
|
||||
- Wave 2 is merged: `Config`, `Provider`, `File`, `LSP`, `MCP`.
|
||||
|
||||
## Priority hotspots
|
||||
|
||||
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
|
||||
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
|
||||
|
||||
## Completed Batches
|
||||
|
||||
Low-risk batch, all merged:
|
||||
|
||||
1. `src/pty/index.ts`
|
||||
2. `src/skill/index.ts`
|
||||
3. `src/project/vcs.ts`
|
||||
4. `src/tool/registry.ts`
|
||||
5. `src/auth/index.ts`
|
||||
|
||||
Caller-heavy batch, all merged:
|
||||
|
||||
1. `src/config/config.ts`
|
||||
2. `src/provider/provider.ts`
|
||||
3. `src/file/index.ts`
|
||||
4. `src/lsp/index.ts`
|
||||
5. `src/mcp/index.ts`
|
||||
|
||||
Shared pattern:
|
||||
|
||||
- one service file still exports `makeRuntime(...)` + async facades
|
||||
- one or two route or CLI entrypoints call those facades directly
|
||||
- tests call the facade directly and need to switch to `yield* svc.method(...)`
|
||||
- once callers are gone, delete `makeRuntime(...)`, remove async facade exports, and drop the `makeRuntime` import
|
||||
|
||||
## Done means
|
||||
|
||||
For each service in the low-risk batch, the work is complete only when all of these are true:
|
||||
|
||||
1. all production callers stop using `Namespace.method(...)` facade calls
|
||||
2. all direct test callers stop using the facade and instead yield the service from context
|
||||
3. the service file no longer has `makeRuntime(...)`
|
||||
4. the service file no longer exports runtime-backed facade helpers
|
||||
5. `grep` for the migrated facade methods only finds the service implementation itself or unrelated names
|
||||
|
||||
## Caller templates
|
||||
|
||||
### Route handlers
|
||||
|
||||
Use one `AppRuntime.runPromise(Effect.gen(...))` body and yield the service inside it.
|
||||
|
||||
```ts
|
||||
const value = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
If two service calls are independent, keep them in the same effect body and use `Effect.all(...)`.
|
||||
|
||||
### Plain async CLI or script entrypoints
|
||||
|
||||
If the caller is not itself an Effect service yet, still prefer one contiguous `AppRuntime.runPromise(Effect.gen(...))` block for the whole unit of work.
|
||||
|
||||
```ts
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const skill = yield* Skill.Service
|
||||
yield* auth.set(key, info)
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Only fall back to `AppRuntime.runPromise(Service.use(...))` for truly isolated one-off calls or awkward callback boundaries. Do not stack multiple tiny `runPromise(...)` calls in the same contiguous workflow.
|
||||
|
||||
This is the right intermediate state. Do not block facade removal on effectifying the whole CLI file.
|
||||
|
||||
### Bootstrap or fire-and-forget startup code
|
||||
|
||||
If the old facade call existed only to kick off initialization, call the service through the existing runtime for that file.
|
||||
|
||||
```ts
|
||||
void BootstrapRuntime.runPromise(Vcs.Service.use((svc) => svc.init()))
|
||||
```
|
||||
|
||||
Do not reintroduce a dedicated runtime in the service just for bootstrap.
|
||||
|
||||
### Tests
|
||||
|
||||
Convert facade tests to full effect style.
|
||||
|
||||
```ts
|
||||
it.effect("does the thing", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Pty.Service
|
||||
const info = yield* svc.create({ command: "cat", title: "a" })
|
||||
yield* svc.remove(info.id)
|
||||
}).pipe(Effect.provide(Pty.defaultLayer)),
|
||||
)
|
||||
```
|
||||
|
||||
If the repo test already uses `testEffect(...)`, prefer `testEffect(Service.defaultLayer)` and `yield* Service.Service` inside the test body.
|
||||
|
||||
Do not route tests through `AppRuntime` unless the test is explicitly exercising the app runtime. For facade removal, tests should usually provide the specific service layer they need.
|
||||
|
||||
If the test uses `provideTmpdirInstance(...)`, remember that fixture needs a live `ChildProcessSpawner` layer. For services whose `defaultLayer` does not already provide that infra, prefer the repo-standard cross-spawn layer:
|
||||
|
||||
```ts
|
||||
const infra = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(MyService.defaultLayer, infra))
|
||||
```
|
||||
|
||||
Without that extra layer, tests fail at runtime with `Service not found: effect/process/ChildProcessSpawner`.
|
||||
|
||||
## Questions already answered
|
||||
|
||||
### Do we need to effectify the whole caller first?
|
||||
|
||||
No.
|
||||
|
||||
- route files: compose the handler with `AppRuntime.runPromise(Effect.gen(...))`
|
||||
- CLI and scripts: use `AppRuntime.runPromise(Service.use(...))`
|
||||
- bootstrap: use the existing bootstrap runtime
|
||||
|
||||
Facade removal does not require a bigger refactor than that.
|
||||
|
||||
### Should tests keep calling the namespace from async test bodies?
|
||||
|
||||
No. Convert them now.
|
||||
|
||||
The end state is `yield* svc.method(...)`, not `await Namespace.method(...)` inside `async` tests.
|
||||
|
||||
### Should we keep `runPromise` exported for convenience?
|
||||
|
||||
No. For this batch the goal is to delete the service-local runtime entirely.
|
||||
|
||||
### What if a route has websocket callbacks or nested async handlers?
|
||||
|
||||
Keep the route shape, but replace each facade call with `AppRuntime.runPromise(Service.use(...))` or wrap the surrounding async section in one `Effect.gen(...)` when practical. Do not keep the service facade just because the route has callback-shaped code.
|
||||
|
||||
### Should we use one `runPromise` per service call?
|
||||
|
||||
No.
|
||||
|
||||
Default to one contiguous `AppRuntime.runPromise(Effect.gen(...))` block per handler, command, or workflow. Yield every service you need inside that block.
|
||||
|
||||
Multiple tiny `runPromise(...)` calls are only acceptable when the caller structure forces it, such as websocket lifecycle callbacks, external callback APIs, or genuinely unrelated one-off operations.
|
||||
|
||||
### Should we wrap a single service expression in `Effect.gen(...)`?
|
||||
|
||||
Usually no.
|
||||
|
||||
Prefer the direct form when there is only one expression:
|
||||
|
||||
```ts
|
||||
await AppRuntime.runPromise(File.Service.use((svc) => svc.read(path)))
|
||||
```
|
||||
|
||||
Use `Effect.gen(...)` when the workflow actually needs multiple yielded values or branching.
|
||||
|
||||
## Learnings
|
||||
|
||||
These were the recurring mistakes and useful corrections from the first two batches:
|
||||
|
||||
1. Tests should usually provide the specific service layer, not `AppRuntime`.
|
||||
2. If a test uses `provideTmpdirInstance(...)` and needs child processes, prefer `CrossSpawnSpawner.defaultLayer`.
|
||||
3. Instance-scoped services may need both the service layer and the right instance fixture. `File` tests, for example, needed `provideInstance(...)` plus `File.defaultLayer`.
|
||||
4. Do not wrap a single `Service.use(...)` call in `Effect.gen(...)` just to return it. Use the direct form.
|
||||
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
|
||||
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
|
||||
|
||||
## Next batch
|
||||
|
||||
Recommended next five, in order:
|
||||
|
||||
1. `src/permission/index.ts`
|
||||
2. `src/agent/agent.ts`
|
||||
3. `src/session/summary.ts`
|
||||
4. `src/session/revert.ts`
|
||||
5. `src/mcp/auth.ts`
|
||||
|
||||
Why this batch:
|
||||
|
||||
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
|
||||
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
|
||||
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
|
||||
|
||||
After that batch, the expected follow-up is the main session cluster:
|
||||
|
||||
1. `src/session/index.ts`
|
||||
2. `src/session/prompt.ts`
|
||||
3. `src/session/compaction.ts`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
|
||||
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
|
||||
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
|
||||
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
|
||||
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
|
||||
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
|
||||
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
|
||||
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
|
||||
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
|
||||
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
|
||||
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
|
||||
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
|
||||
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
|
||||
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
|
||||
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
|
||||
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
|
||||
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
|
||||
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
|
||||
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
|
||||
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
|
||||
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
|
||||
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
|
||||
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
|
||||
|
||||
## Excluded `makeRuntime(...)` sites
|
||||
|
||||
- `src/bus/index.ts` - core bus plumbing, not a normal facade-removal target.
|
||||
- `src/effect/cross-spawn-spawner.ts` - runtime helper for `ChildProcessSpawner`, not a service namespace facade.
|
||||
@@ -1,137 +0,0 @@
|
||||
# HttpApi migration
|
||||
|
||||
Practical notes for an eventual migration of `packages/opencode` server routes from the current Hono handlers to Effect `HttpApi`, either as a full replacement or as a parallel surface.
|
||||
|
||||
## Goal
|
||||
|
||||
Use Effect `HttpApi` where it gives us a better typed contract for:
|
||||
|
||||
- route definition
|
||||
- request decoding and validation
|
||||
- typed success and error responses
|
||||
- OpenAPI generation
|
||||
- handler composition inside Effect
|
||||
|
||||
This should be treated as a later-stage HTTP boundary migration, not a prerequisite for ongoing service, route-handler, or schema work.
|
||||
|
||||
## Core model
|
||||
|
||||
`HttpApi` is definition-first.
|
||||
|
||||
- `HttpApi` is the root API
|
||||
- `HttpApiGroup` groups related endpoints
|
||||
- `HttpApiEndpoint` defines a single route and its request / response schemas
|
||||
- handlers are implemented separately from the contract
|
||||
|
||||
This is a better fit once route inputs and outputs are already moving toward Effect Schema-first models.
|
||||
|
||||
## Why it is relevant here
|
||||
|
||||
The current route-effectification work is already pushing handlers toward:
|
||||
|
||||
- one `AppRuntime.runPromise(Effect.gen(...))` body
|
||||
- yielding services from context
|
||||
- using typed Effect errors instead of Promise wrappers
|
||||
|
||||
That work is a good prerequisite for `HttpApi`. Once the handler body is already a composed Effect, the remaining migration is mostly about replacing the Hono route declaration and validator layer.
|
||||
|
||||
## What HttpApi gives us
|
||||
|
||||
### Contracts
|
||||
|
||||
Request params, query, payload, success payloads, and typed error payloads are declared in one place using Effect Schema.
|
||||
|
||||
### Validation and decoding
|
||||
|
||||
Incoming data is decoded through Effect Schema instead of hand-maintained Zod validators per route.
|
||||
|
||||
### OpenAPI
|
||||
|
||||
`HttpApi` can derive OpenAPI from the API definition, which overlaps with the current `describeRoute(...)` and `resolver(...)` pattern.
|
||||
|
||||
### Typed errors
|
||||
|
||||
`Schema.TaggedErrorClass` maps naturally to endpoint error contracts.
|
||||
|
||||
## Likely fit for opencode
|
||||
|
||||
Best fit first:
|
||||
|
||||
- JSON request / response endpoints
|
||||
- route groups that already mostly delegate into services
|
||||
- endpoints whose request and response models can be defined with Effect Schema
|
||||
|
||||
Harder / later fit:
|
||||
|
||||
- SSE endpoints
|
||||
- websocket endpoints
|
||||
- streaming handlers
|
||||
- routes with heavy Hono-specific middleware assumptions
|
||||
|
||||
## Current blockers and gaps
|
||||
|
||||
### Schema split
|
||||
|
||||
Many route boundaries still use Zod-first validators. That does not block all experimentation, but full `HttpApi` adoption is easier after the domain and boundary types are more consistently Schema-first with `.zod` compatibility only where needed.
|
||||
|
||||
### Mixed handler styles
|
||||
|
||||
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
|
||||
|
||||
### Non-JSON routes
|
||||
|
||||
The server currently includes SSE, websocket, and streaming-style endpoints. Those should not be the first `HttpApi` targets.
|
||||
|
||||
### Existing Hono integration
|
||||
|
||||
The current server composition, middleware, and docs flow are Hono-centered today. That suggests a parallel or incremental adoption plan is safer than a flag day rewrite.
|
||||
|
||||
## Recommended strategy
|
||||
|
||||
### 1. Finish the prerequisites first
|
||||
|
||||
- continue route-handler effectification in `server/instance/*.ts`
|
||||
- continue schema migration toward Effect Schema-first DTOs and errors
|
||||
- keep removing service facades
|
||||
|
||||
### 2. Start with one parallel group
|
||||
|
||||
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
|
||||
|
||||
- `server/instance/question.ts`
|
||||
- `server/instance/provider.ts`
|
||||
- `server/instance/permission.ts`
|
||||
|
||||
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
|
||||
|
||||
### 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.
|
||||
|
||||
### 4. Run in parallel before replacing
|
||||
|
||||
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
|
||||
|
||||
- handler ergonomics
|
||||
- OpenAPI output
|
||||
- auth and middleware integration
|
||||
- test ergonomics
|
||||
|
||||
### 5. Migrate JSON route groups gradually
|
||||
|
||||
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
|
||||
|
||||
- [ ] 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
|
||||
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
Do not start with the hardest route file.
|
||||
|
||||
If `HttpApi` is adopted here, it should arrive after the handler body is already Effect-native and after the relevant request / response models have moved to Effect Schema.
|
||||
@@ -1,36 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Route handler effectification
|
||||
|
||||
Practical reference for converting server route handlers in `packages/opencode` to a single `AppRuntime.runPromise(Effect.gen(...))` body.
|
||||
|
||||
## Goal
|
||||
|
||||
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one.
|
||||
|
||||
This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
|
||||
|
||||
```ts
|
||||
// Before - one facade call per service
|
||||
;async (c) => {
|
||||
await SessionRunState.assertNotBusy(id)
|
||||
await Session.removeMessage({ sessionID: id, messageID })
|
||||
return c.json(true)
|
||||
}
|
||||
|
||||
// After - one Effect.gen, yield services from context
|
||||
;async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(id)
|
||||
yield* session.removeMessage({ sessionID: id, messageID })
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
}
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Wrap the whole handler body in one `AppRuntime.runPromise(Effect.gen(...))` call when the handler is service-heavy.
|
||||
- Yield services from context instead of calling async facades repeatedly.
|
||||
- When independent service calls can run in parallel, use `Effect.all(..., { concurrency: "unbounded" })`.
|
||||
- Prefer one composed Effect body over multiple separate `runPromise(...)` calls in the same handler.
|
||||
|
||||
## Current route files
|
||||
|
||||
Current instance route files live under `src/server/instance`, not `server/routes`.
|
||||
|
||||
The main migration targets are:
|
||||
|
||||
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
|
||||
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
|
||||
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
|
||||
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
|
||||
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
|
||||
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
|
||||
|
||||
Additional route files that still participate in the migration:
|
||||
|
||||
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
|
||||
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
|
||||
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
|
||||
- [ ] `server/instance/permission.ts` — Permission
|
||||
- [ ] `server/instance/workspace.ts` — Workspace
|
||||
- [ ] `server/instance/tui.ts` — Bus and Session
|
||||
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
|
||||
|
||||
## Notes
|
||||
|
||||
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
|
||||
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
|
||||
@@ -1,99 +0,0 @@
|
||||
# Schema migration
|
||||
|
||||
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
|
||||
|
||||
## Goal
|
||||
|
||||
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
|
||||
|
||||
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
### Data objects
|
||||
|
||||
Use `Schema.Class` for structured data.
|
||||
|
||||
```ts
|
||||
export class Info extends Schema.Class<Info>("Foo.Info")({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Boolean,
|
||||
}) {
|
||||
static readonly zod = zod(Info)
|
||||
}
|
||||
```
|
||||
|
||||
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
|
||||
|
||||
```ts
|
||||
const _Info = Schema.Struct({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
})
|
||||
|
||||
export const Info = Object.assign(_Info, {
|
||||
zod: zod(_Info),
|
||||
})
|
||||
```
|
||||
|
||||
### Errors
|
||||
|
||||
Use `Schema.TaggedErrorClass` for domain errors.
|
||||
|
||||
```ts
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("FooNotFoundError", {
|
||||
id: FooID,
|
||||
}) {}
|
||||
```
|
||||
|
||||
### IDs and branded leaf types
|
||||
|
||||
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
|
||||
|
||||
The default should be:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- `.zod` exists only as a compatibility surface
|
||||
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
|
||||
|
||||
## When Zod can stay
|
||||
|
||||
It is fine to keep a Zod-native schema temporarily when:
|
||||
|
||||
- the type is only used at an HTTP or tool boundary
|
||||
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
|
||||
- the migration would force unrelated churn across a large call graph
|
||||
|
||||
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
|
||||
|
||||
## Ordering
|
||||
|
||||
Migrate in this order:
|
||||
|
||||
1. Shared leaf models and `schema.ts` files
|
||||
2. Exported `Info`, `Input`, `Output`, and DTO types
|
||||
3. Tagged domain errors
|
||||
4. Service-local internal models
|
||||
5. Route and tool boundary validators that can switch to `.zod`
|
||||
|
||||
This keeps shared types canonical first and makes boundary updates mostly mechanical.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
|
||||
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
|
||||
- [ ] Domain errors use `Schema.TaggedErrorClass`
|
||||
- [ ] Migrated types expose `.zod` for back compatibility
|
||||
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
|
||||
- [ ] New domain models default to Effect Schema first
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
|
||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
|
||||
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
|
||||
@@ -1,96 +0,0 @@
|
||||
# Tool migration
|
||||
|
||||
Practical reference for the current tool-migration state in `packages/opencode`.
|
||||
|
||||
## Status
|
||||
|
||||
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch, and the built-in tool surface is now largely on the target shape.
|
||||
|
||||
The current exported tools in `src/tool` all use `Tool.define(...)` with Effect-based initialization, and nearly all of them already build their tool body with `Effect.gen(...)` and `Effect.fn(...)`.
|
||||
|
||||
So the remaining work is no longer "convert tools to Effect at all". The remaining work is mostly:
|
||||
|
||||
1. remove Promise and raw platform bridges inside individual tool bodies
|
||||
2. swap tool internals to Effect-native services like `AppFileSystem`, `HttpClient`, and `ChildProcessSpawner`
|
||||
3. keep tests and callers aligned with `yield* info.init()` and real service graphs
|
||||
|
||||
## Current shape
|
||||
|
||||
`Tool.define(...)` is already the Effect-native helper here.
|
||||
|
||||
- `init` is an `Effect`
|
||||
- `info.init()` returns an `Effect`
|
||||
- `execute(...)` returns an `Effect`
|
||||
|
||||
That means a tool does not need a separate `Tool.defineEffect(...)` helper to count as migrated. A tool is effectively migrated when its init and execute path stay Effect-native, even if some internals still bridge to Promise-based or raw APIs.
|
||||
|
||||
## Tests
|
||||
|
||||
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
|
||||
|
||||
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
|
||||
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
|
||||
|
||||
This keeps tool tests aligned with the production service graph and makes follow-up cleanup mostly mechanical.
|
||||
|
||||
## Exported tools
|
||||
|
||||
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
|
||||
|
||||
- [x] `apply_patch.ts`
|
||||
- [x] `bash.ts`
|
||||
- [x] `codesearch.ts`
|
||||
- [x] `edit.ts`
|
||||
- [x] `glob.ts`
|
||||
- [x] `grep.ts`
|
||||
- [x] `invalid.ts`
|
||||
- [x] `ls.ts`
|
||||
- [x] `lsp.ts`
|
||||
- [x] `multiedit.ts`
|
||||
- [x] `plan.ts`
|
||||
- [x] `question.ts`
|
||||
- [x] `read.ts`
|
||||
- [x] `skill.ts`
|
||||
- [x] `task.ts`
|
||||
- [x] `todo.ts`
|
||||
- [x] `webfetch.ts`
|
||||
- [x] `websearch.ts`
|
||||
- [x] `write.ts`
|
||||
|
||||
Notes:
|
||||
|
||||
- `batch.ts` is no longer a current tool file and should not be tracked here.
|
||||
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
|
||||
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
|
||||
|
||||
## Follow-up cleanup
|
||||
|
||||
Most exported tools are already on the intended Effect-native shape. The remaining cleanup is narrower than the old checklist implied.
|
||||
|
||||
Current spot cleanups worth tracking:
|
||||
|
||||
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
|
||||
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
|
||||
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
|
||||
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
|
||||
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
|
||||
|
||||
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
|
||||
|
||||
- `apply_patch.ts`
|
||||
- `grep.ts`
|
||||
- `write.ts`
|
||||
- `codesearch.ts`
|
||||
- `websearch.ts`
|
||||
- `ls.ts`
|
||||
- `multiedit.ts`
|
||||
- `edit.ts`
|
||||
|
||||
## Filesystem notes
|
||||
|
||||
Current raw fs users that still appear relevant here:
|
||||
|
||||
- `tool/read.ts` — `fs.createReadStream`, `readline`
|
||||
- `file/ripgrep.ts` — `fs/promises`
|
||||
- `patch/index.ts` — `fs`, `fs/promises`
|
||||
@@ -40,7 +40,6 @@ import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { Agent as AgentModule } from "../agent/agent"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Installation } from "@/installation"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -1167,7 +1166,7 @@ export namespace ACP {
|
||||
this.sessionManager.get(sessionId).modeId ||
|
||||
(await (async () => {
|
||||
if (!availableModes.length) return undefined
|
||||
const defaultAgentName = await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent()))
|
||||
const defaultAgentName = await AgentModule.defaultAgent()
|
||||
const resolvedModeId =
|
||||
availableModes.find((mode) => mode.name === defaultAgentName)?.id ?? availableModes[0].id
|
||||
this.sessionManager.setMode(sessionId, resolvedModeId)
|
||||
@@ -1368,8 +1367,7 @@ export namespace ACP {
|
||||
if (!current) {
|
||||
this.sessionManager.setModel(session.id, model)
|
||||
}
|
||||
const agent =
|
||||
session.modeId ?? (await AppRuntime.runPromise(AgentModule.Service.use((svc) => svc.defaultAgent())))
|
||||
const agent = session.modeId ?? (await AgentModule.defaultAgent())
|
||||
|
||||
const parts: Array<
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, Context, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -73,7 +74,6 @@ 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
|
||||
|
||||
@@ -336,7 +336,9 @@ export namespace Agent {
|
||||
const language = yield* provider.getLanguage(resolved)
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system })
|
||||
yield* Effect.promise(() =>
|
||||
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
|
||||
@@ -397,10 +399,27 @@ export namespace Agent {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(agent: string) {
|
||||
return runPromise((svc) => svc.get(agent))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function defaultAgent() {
|
||||
return runPromise((svc) => svc.defaultAgent())
|
||||
}
|
||||
|
||||
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
|
||||
return runPromise((svc) => svc.generate(input))
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/opencode/src/audio.d.ts
vendored
4
packages/opencode/src/audio.d.ts
vendored
@@ -1,4 +0,0 @@
|
||||
declare module "*.wav" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { AppFileSystem } from "../filesystem"
|
||||
@@ -88,4 +89,22 @@ export namespace Auth {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
}
|
||||
|
||||
export async function all(): Promise<Record<string, Info>> {
|
||||
return runPromise((service) => service.all())
|
||||
}
|
||||
|
||||
export async function set(key: string, info: Info) {
|
||||
return runPromise((service) => service.set(key, info))
|
||||
}
|
||||
|
||||
export async function remove(key: string) {
|
||||
return runPromise((service) => service.remove(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,18 +16,25 @@ export namespace BusEvent {
|
||||
}
|
||||
|
||||
export function payloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(type),
|
||||
properties: def.properties,
|
||||
})
|
||||
.meta({
|
||||
ref: "Event" + "." + def.type,
|
||||
return z
|
||||
.discriminatedUnion(
|
||||
"type",
|
||||
registry
|
||||
.entries()
|
||||
.map(([type, def]) => {
|
||||
return z
|
||||
.object({
|
||||
type: z.literal(type),
|
||||
properties: def.properties,
|
||||
})
|
||||
.meta({
|
||||
ref: "Event" + "." + def.type,
|
||||
})
|
||||
})
|
||||
.toArray() as any,
|
||||
)
|
||||
.meta({
|
||||
ref: "Event",
|
||||
})
|
||||
.toArray()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { UI } from "../ui"
|
||||
import { Global } from "../../global"
|
||||
import { Agent } from "../../agent/agent"
|
||||
@@ -111,9 +110,7 @@ const AgentCreateCommand = cmd({
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Generating agent configuration...")
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const generated = await AppRuntime.runPromise(
|
||||
Agent.Service.use((svc) => svc.generate({ description, model })),
|
||||
).catch((error) => {
|
||||
const generated = await Agent.generate({ description, model }).catch((error) => {
|
||||
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
|
||||
if (isFullyNonInteractive) process.exit(1)
|
||||
throw new UI.CancelledError()
|
||||
@@ -223,7 +220,7 @@ const AgentListCommand = cmd({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const agents = await Agent.list()
|
||||
const sortedAgents = agents.sort((a, b) => {
|
||||
if (a.native !== b.native) {
|
||||
return a.native ? -1 : 1
|
||||
|
||||
@@ -12,7 +12,6 @@ import { Permission } from "../../../permission"
|
||||
import { iife } from "../../../util/iife"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
export const AgentCommand = cmd({
|
||||
command: "agent <name>",
|
||||
@@ -35,7 +34,7 @@ export const AgentCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const agentName = args.name as string
|
||||
const agent = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(agentName)))
|
||||
const agent = await Agent.get(agentName)
|
||||
if (!agent) {
|
||||
process.stderr.write(
|
||||
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
|
||||
@@ -72,17 +71,11 @@ export const AgentCommand = cmd({
|
||||
})
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const model = agent.model ?? (yield* provider.defaultModel())
|
||||
return yield* registry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
return ToolRegistry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
@@ -123,45 +116,38 @@ function parseToolParams(input?: string) {
|
||||
}
|
||||
|
||||
async function createToolContext(agent: Agent.Info) {
|
||||
const { session, messageID } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const provider = yield* Provider.Service
|
||||
const result = yield* session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model ?? (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 session = await Session.create({ title: `Debug tool run (${agent.name})` })
|
||||
const messageID = MessageID.ascending()
|
||||
const model = agent.model ?? (await 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 ruleset = Permission.merge(agent.permission, session.permission ?? [])
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EOL } from "os"
|
||||
import { Config } from "../../../config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
@@ -10,7 +9,7 @@ export const ConfigCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const config = await Config.get()
|
||||
process.stdout.write(JSON.stringify(config, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { File } from "../../../file"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -17,11 +15,7 @@ const FileSearchCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.search({ query: args.query }))
|
||||
}),
|
||||
)
|
||||
const results = await File.search({ query: args.query })
|
||||
process.stdout.write(results.join(EOL) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -38,11 +32,7 @@ const FileReadCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.read(args.path))
|
||||
}),
|
||||
)
|
||||
const content = await File.read(args.path)
|
||||
process.stdout.write(JSON.stringify(content, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -54,11 +44,7 @@ const FileStatusCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const status = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.status())
|
||||
}),
|
||||
)
|
||||
const status = await File.status()
|
||||
process.stdout.write(JSON.stringify(status, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -75,11 +61,7 @@ const FileListCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const files = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.list(args.path))
|
||||
}),
|
||||
)
|
||||
const files = await File.list(args.path)
|
||||
process.stdout.write(JSON.stringify(files, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { LSP } from "../../../lsp"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
@@ -21,16 +19,9 @@ const DiagnosticsCommand = cmd({
|
||||
builder: (yargs) => yargs.positional("file", { type: "string", demandOption: true }),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const out = await AppRuntime.runPromise(
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* lsp.touchFile(args.file, true)
|
||||
yield* Effect.sleep(1000)
|
||||
return yield* lsp.diagnostics()
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + EOL)
|
||||
await LSP.touchFile(args.file, true)
|
||||
await sleep(1000)
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -42,7 +33,7 @@ export const SymbolsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query)))
|
||||
const results = await LSP.workspaceSymbol(args.query)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
@@ -55,7 +46,7 @@ export const DocumentSymbolsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
using _ = Log.Default.time("document-symbols")
|
||||
const results = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.documentSymbol(args.uri)))
|
||||
const results = await LSP.documentSymbol(args.uri)
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "../../../effect/app-runtime"
|
||||
import { Ripgrep } from "../../../file/ripgrep"
|
||||
import { Instance } from "../../../project/instance"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
@@ -77,18 +76,12 @@ const SearchCommand = cmd({
|
||||
description: "Limit number of results",
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const results = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) =>
|
||||
svc.search({
|
||||
cwd: Instance.directory,
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
}),
|
||||
),
|
||||
)
|
||||
process.stdout.write(JSON.stringify(results.items, null, 2) + EOL)
|
||||
const results = await Ripgrep.search({
|
||||
cwd: process.cwd(),
|
||||
pattern: args.pattern,
|
||||
glob: args.glob as string[] | undefined,
|
||||
limit: args.limit,
|
||||
})
|
||||
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Skill } from "../../../skill"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -11,12 +9,7 @@ export const SkillCommand = cmd({
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
const skills = await Skill.all()
|
||||
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Snapshot } from "../../../snapshot"
|
||||
import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
@@ -15,7 +14,7 @@ const TrackCommand = cmd({
|
||||
describe: "track current snapshot state",
|
||||
async handler() {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.track())))
|
||||
console.log(await Snapshot.track())
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -31,7 +30,7 @@ const PatchCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.patch(args.hash))))
|
||||
console.log(await Snapshot.patch(args.hash))
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -47,7 +46,7 @@ const DiffCommand = cmd({
|
||||
}),
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
console.log(await AppRuntime.runPromise(Snapshot.Service.use((svc) => svc.diff(args.hash))))
|
||||
console.log(await Snapshot.diff(args.hash))
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -6,8 +6,6 @@ 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]",
|
||||
@@ -69,16 +67,8 @@ export const ExportCommand = cmd({
|
||||
}
|
||||
|
||||
try {
|
||||
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 sessionInfo = await Session.get(sessionID!)
|
||||
const messages = await Session.messages({ sessionID: sessionInfo.id })
|
||||
|
||||
const exportData = {
|
||||
info: sessionInfo,
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
@@ -552,24 +551,20 @@ export const GithubRunCommand = cmd({
|
||||
|
||||
// Setup opencode session
|
||||
const repoData = await fetchRepo()
|
||||
session = await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
session = await Session.create({
|
||||
permission: [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
],
|
||||
})
|
||||
subscribeSessionEvents()
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
|
||||
await SessionShare.share(session.id)
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
@@ -942,86 +937,96 @@ export const GithubRunCommand = cmd({
|
||||
async function chat(message: string, files: PromptFiles = []) {
|
||||
console.log("Sending message to opencode...")
|
||||
|
||||
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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
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 || ""}`)
|
||||
}
|
||||
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
|
||||
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.",
|
||||
path: f.filename,
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
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 || ""}`)
|
||||
}
|
||||
// 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)
|
||||
|
||||
const summaryText = extractResponseText(summary.parts)
|
||||
if (!summaryText) throw new Error("Failed to get summary from agent")
|
||||
return summaryText
|
||||
}),
|
||||
)
|
||||
if (err.name === "ContextOverflowError") {
|
||||
throw new Error(formatPromptTooLargeError(files))
|
||||
}
|
||||
|
||||
const errorMsg = err.data?.message || ""
|
||||
throw new Error(`${err.name}: ${errorMsg}`)
|
||||
}
|
||||
|
||||
const text = extractResponseText(result.parts)
|
||||
if (text) return text
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
async function getOidcToken() {
|
||||
|
||||
@@ -15,8 +15,6 @@ import { Global } from "../../global"
|
||||
import { modify, applyEdits } from "jsonc-parser"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { Bus } from "../../bus"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
function getAuthStatusIcon(status: MCP.AuthStatus): string {
|
||||
switch (status) {
|
||||
@@ -52,47 +50,6 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||
return isMcpConfigured(config) && config.type === "remote"
|
||||
}
|
||||
|
||||
function configuredServers(config: Config.Info) {
|
||||
return Object.entries(config.mcp ?? {}).filter((entry): entry is [string, McpConfigured] => isMcpConfigured(entry[1]))
|
||||
}
|
||||
|
||||
function oauthServers(config: Config.Info) {
|
||||
return configuredServers(config).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
}
|
||||
|
||||
async function listState() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const statuses = yield* mcp.status()
|
||||
const stored = yield* Effect.all(
|
||||
Object.fromEntries(configuredServers(config).map(([name]) => [name, mcp.hasStoredTokens(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, statuses, stored }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function authState() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const config = yield* cfg.get()
|
||||
const auth = yield* Effect.all(
|
||||
Object.fromEntries(oauthServers(config).map(([name]) => [name, mcp.getAuthStatus(name)])),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return { config, auth }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const McpCommand = cmd({
|
||||
command: "mcp",
|
||||
describe: "manage MCP (Model Context Protocol) servers",
|
||||
@@ -118,8 +75,13 @@ export const McpListCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP Servers")
|
||||
|
||||
const { config, statuses, stored } = await listState()
|
||||
const servers = configuredServers(config)
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const statuses = await MCP.status()
|
||||
|
||||
const servers = Object.entries(mcpServers).filter((entry): entry is [string, McpConfigured] =>
|
||||
isMcpConfigured(entry[1]),
|
||||
)
|
||||
|
||||
if (servers.length === 0) {
|
||||
prompts.log.warn("No MCP servers configured")
|
||||
@@ -130,7 +92,7 @@ export const McpListCommand = cmd({
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const status = statuses[name]
|
||||
const hasOAuth = isMcpRemote(serverConfig) && !!serverConfig.oauth
|
||||
const hasStoredTokens = stored[name]
|
||||
const hasStoredTokens = await MCP.hasStoredTokens(name)
|
||||
|
||||
let statusIcon: string
|
||||
let statusText: string
|
||||
@@ -190,11 +152,15 @@ export const McpAuthCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Authentication")
|
||||
|
||||
const { config, auth } = await authState()
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const servers = oauthServers(config)
|
||||
|
||||
if (servers.length === 0) {
|
||||
// Get OAuth-capable servers (remote servers with oauth not explicitly disabled)
|
||||
const oauthServers = Object.entries(mcpServers).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:")
|
||||
prompts.log.info(`
|
||||
@@ -211,17 +177,19 @@ export const McpAuthCommand = cmd({
|
||||
let serverName = args.name
|
||||
if (!serverName) {
|
||||
// Build options with auth status
|
||||
const options = servers.map(([name, cfg]) => {
|
||||
const authStatus = auth[name]
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
})
|
||||
const options = await Promise.all(
|
||||
oauthServers.map(async ([name, cfg]) => {
|
||||
const authStatus = await MCP.getAuthStatus(name)
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = cfg.url
|
||||
return {
|
||||
label: `${icon} ${name} (${statusText})`,
|
||||
value: name,
|
||||
hint: url,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const selected = await prompts.select({
|
||||
message: "Select MCP server to authenticate",
|
||||
@@ -245,8 +213,7 @@ export const McpAuthCommand = cmd({
|
||||
}
|
||||
|
||||
// Check if already authenticated
|
||||
const authStatus =
|
||||
auth[serverName] ?? (await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.getAuthStatus(serverName))))
|
||||
const authStatus = await MCP.getAuthStatus(serverName)
|
||||
if (authStatus === "authenticated") {
|
||||
const confirm = await prompts.confirm({
|
||||
message: `${serverName} already has valid credentials. Re-authenticate?`,
|
||||
@@ -273,7 +240,7 @@ export const McpAuthCommand = cmd({
|
||||
})
|
||||
|
||||
try {
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.authenticate(serverName)))
|
||||
const status = await MCP.authenticate(serverName)
|
||||
|
||||
if (status.status === "connected") {
|
||||
spinner.stop("Authentication successful!")
|
||||
@@ -322,17 +289,22 @@ export const McpAuthListCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Status")
|
||||
|
||||
const { config, auth } = await authState()
|
||||
const servers = oauthServers(config)
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
|
||||
if (servers.length === 0) {
|
||||
// Get OAuth-capable servers
|
||||
const oauthServers = Object.entries(mcpServers).filter(
|
||||
(entry): entry is [string, McpRemote] => isMcpRemote(entry[1]) && entry[1].oauth !== false,
|
||||
)
|
||||
|
||||
if (oauthServers.length === 0) {
|
||||
prompts.log.warn("No OAuth-capable MCP servers configured")
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
for (const [name, serverConfig] of servers) {
|
||||
const authStatus = auth[name]
|
||||
for (const [name, serverConfig] of oauthServers) {
|
||||
const authStatus = await MCP.getAuthStatus(name)
|
||||
const icon = getAuthStatusIcon(authStatus)
|
||||
const statusText = getAuthStatusText(authStatus)
|
||||
const url = serverConfig.url
|
||||
@@ -340,7 +312,7 @@ export const McpAuthListCommand = cmd({
|
||||
prompts.log.info(`${icon} ${name} ${UI.Style.TEXT_DIM}${statusText}\n ${UI.Style.TEXT_DIM}${url}`)
|
||||
}
|
||||
|
||||
prompts.outro(`${servers.length} OAuth-capable server(s)`)
|
||||
prompts.outro(`${oauthServers.length} OAuth-capable server(s)`)
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -361,7 +333,8 @@ export const McpLogoutCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Logout")
|
||||
|
||||
const credentials = await AppRuntime.runPromise(McpAuth.Service.use((auth) => auth.all()))
|
||||
const authPath = path.join(Global.Path.data, "mcp-auth.json")
|
||||
const credentials = await McpAuth.all()
|
||||
const serverNames = Object.keys(credentials)
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
@@ -399,7 +372,7 @@ export const McpLogoutCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(serverName)))
|
||||
await MCP.removeAuth(serverName)
|
||||
prompts.log.success(`Removed OAuth credentials for ${serverName}`)
|
||||
prompts.outro("Done")
|
||||
},
|
||||
@@ -622,7 +595,7 @@ export const McpDebugCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("MCP OAuth Debug")
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const config = await Config.get()
|
||||
const mcpServers = config.mcp ?? {}
|
||||
const serverName = args.name
|
||||
|
||||
@@ -649,18 +622,10 @@ export const McpDebugCommand = cmd({
|
||||
prompts.log.info(`URL: ${serverConfig.url}`)
|
||||
|
||||
// Check stored auth status
|
||||
const { authStatus, entry } = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const auth = yield* McpAuth.Service
|
||||
return {
|
||||
authStatus: yield* mcp.getAuthStatus(serverName),
|
||||
entry: yield* auth.get(serverName),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const authStatus = await MCP.getAuthStatus(serverName)
|
||||
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
|
||||
|
||||
const entry = await McpAuth.get(serverName)
|
||||
if (entry?.tokens) {
|
||||
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
|
||||
if (entry.tokens.expiresAt) {
|
||||
@@ -716,11 +681,6 @@ export const McpDebugCommand = cmd({
|
||||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
const auth = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* McpAuth.Service
|
||||
}),
|
||||
)
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
@@ -733,7 +693,6 @@ export const McpDebugCommand = cmd({
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||
|
||||
@@ -6,8 +6,6 @@ import { ModelsDev } from "../../provider/models"
|
||||
import { cmd } from "./cmd"
|
||||
import { UI } from "../ui"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ModelsCommand = cmd({
|
||||
command: "models [provider]",
|
||||
@@ -37,51 +35,43 @@ export const ModelsCommand = cmd({
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
const providers = await Provider.list()
|
||||
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const provider = providers[providerID]
|
||||
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
function printModels(providerID: ProviderID, verbose?: boolean) {
|
||||
const provider = providers[providerID]
|
||||
const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sortedModels) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
const provider = providers[providerID]
|
||||
if (!provider) {
|
||||
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
|
||||
return
|
||||
}
|
||||
if (args.provider) {
|
||||
const provider = providers[ProviderID.make(args.provider)]
|
||||
if (!provider) {
|
||||
UI.error(`Provider not found: ${args.provider}`)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.sync(() => print(providerID, args.verbose))
|
||||
return
|
||||
}
|
||||
printModels(ProviderID.make(args.provider), args.verbose)
|
||||
return
|
||||
}
|
||||
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
const providerIDs = Object.keys(providers).sort((a, b) => {
|
||||
const aIsOpencode = a.startsWith("opencode")
|
||||
const bIsOpencode = b.startsWith("opencode")
|
||||
if (aIsOpencode && !bIsOpencode) return -1
|
||||
if (!aIsOpencode && bIsOpencode) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
for (const providerID of ids) {
|
||||
print(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
for (const providerID of providerIDs) {
|
||||
printModels(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Auth } from "../../auth"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
@@ -14,18 +13,9 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Effect } from "effect"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
const put = (key: string, info: Auth.Info) =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(key, info)
|
||||
}),
|
||||
)
|
||||
|
||||
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
|
||||
let index = 0
|
||||
if (methodName) {
|
||||
@@ -103,7 +93,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -112,7 +102,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
@@ -135,7 +125,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const saveProvider = result.provider ?? provider
|
||||
if ("refresh" in result) {
|
||||
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "oauth",
|
||||
refresh,
|
||||
access,
|
||||
@@ -144,7 +134,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
})
|
||||
}
|
||||
if ("key" in result) {
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
@@ -158,12 +148,6 @@ 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 result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
@@ -171,9 +155,9 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
await Auth.set(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
key: result.key,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
@@ -231,12 +215,7 @@ export const ProvidersListCommand = cmd({
|
||||
const homedir = os.homedir()
|
||||
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
|
||||
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const results = Object.entries(await Auth.all())
|
||||
const database = await ModelsDev.get()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
@@ -315,7 +294,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await put(url, {
|
||||
await Auth.set(url, {
|
||||
type: "wellknown",
|
||||
key: wellknown.auth.env,
|
||||
token: token.trim(),
|
||||
@@ -326,7 +305,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
}
|
||||
await ModelsDev.refresh(true).catch(() => {})
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
const config = await Config.get()
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
@@ -340,12 +319,6 @@ 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,
|
||||
@@ -357,7 +330,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
vercel: 6,
|
||||
}
|
||||
const pluginProviders = resolvePluginProviders({
|
||||
hooks,
|
||||
hooks: await Plugin.list(),
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
@@ -414,7 +387,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
provider = selected as string
|
||||
}
|
||||
|
||||
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (plugin && plugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
@@ -428,7 +401,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
if (prompts.isCancel(custom)) throw new UI.CancelledError()
|
||||
provider = custom.replace(/^@ai-sdk\//, "")
|
||||
|
||||
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
|
||||
const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
|
||||
if (customPlugin && customPlugin.auth) {
|
||||
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
|
||||
if (handled) return
|
||||
@@ -468,7 +441,7 @@ export const ProvidersLoginCommand = cmd({
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
await put(provider, {
|
||||
await Auth.set(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
})
|
||||
@@ -484,33 +457,22 @@ export const ProvidersLogoutCommand = cmd({
|
||||
describe: "log out from a configured provider",
|
||||
async handler(_args) {
|
||||
UI.empty()
|
||||
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const credentials = await Auth.all().then((x) => Object.entries(x))
|
||||
prompts.intro("Remove credential")
|
||||
if (credentials.length === 0) {
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await ModelsDev.get()
|
||||
const selected = await prompts.select({
|
||||
const providerID = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
|
||||
value: key,
|
||||
})),
|
||||
})
|
||||
if (prompts.isCancel(selected)) throw new UI.CancelledError()
|
||||
const providerID = selected as string
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
if (prompts.isCancel(providerID)) throw new UI.CancelledError()
|
||||
await Auth.remove(providerID)
|
||||
prompts.outro("Logout successful")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,7 +27,6 @@ import { SkillTool } from "../../tool/skill"
|
||||
import { BashTool } from "../../tool/bash"
|
||||
import { TodoWriteTool } from "../../tool/todo"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
type ToolProps<T> = {
|
||||
input: Tool.InferParameters<T>
|
||||
@@ -574,7 +573,6 @@ export const RunCommand = cmd({
|
||||
// Validate agent if specified
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
const name = args.agent
|
||||
|
||||
// When attaching, validate against the running server instead of local Instance state.
|
||||
if (args.attach) {
|
||||
@@ -592,12 +590,12 @@ export const RunCommand = cmd({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agent = modes.find((a) => a.name === name)
|
||||
const agent = modes.find((a) => a.name === args.agent)
|
||||
if (!agent) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
@@ -606,20 +604,20 @@ export const RunCommand = cmd({
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return name
|
||||
return args.agent
|
||||
}
|
||||
|
||||
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
|
||||
const entry = await Agent.get(args.agent)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" not found. Falling back to default agent`,
|
||||
`agent "${args.agent}" not found. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
@@ -627,11 +625,11 @@ export const RunCommand = cmd({
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return name
|
||||
return args.agent
|
||||
})()
|
||||
|
||||
const sessionID = await session(sdk)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"]
|
||||
@@ -61,12 +60,12 @@ export const SessionDeleteCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const sessionID = SessionID.make(args.sessionID)
|
||||
try {
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
await Session.get(sessionID)
|
||||
} catch {
|
||||
UI.error(`Session not found: ${args.sessionID}`)
|
||||
process.exit(1)
|
||||
}
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
await Session.remove(sessionID)
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + `Session ${args.sessionID} deleted` + UI.Style.TEXT_NORMAL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -168,9 +167,7 @@ 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 AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.messages({ sessionID: session.id })),
|
||||
)
|
||||
const messages = await Session.messages({ sessionID: session.id })
|
||||
|
||||
let sessionCost = 0
|
||||
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, TimeToFirstDraw, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { Terminal } from "@tui/util/terminal"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
@@ -60,6 +59,67 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
if (!process.stdin.isTTY) return "dark"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
const match = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (match) {
|
||||
cleanup()
|
||||
const color = match[1]
|
||||
// Parse RGB values from color string
|
||||
// Formats: rgb:RR/GG/BB or #RRGGBB or rgb(R,G,B)
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0
|
||||
|
||||
if (color.startsWith("rgb:")) {
|
||||
const parts = color.substring(4).split("/")
|
||||
r = parseInt(parts[0], 16) >> 8 // Convert 16-bit to 8-bit
|
||||
g = parseInt(parts[1], 16) >> 8 // Convert 16-bit to 8-bit
|
||||
b = parseInt(parts[2], 16) >> 8 // Convert 16-bit to 8-bit
|
||||
} else if (color.startsWith("#")) {
|
||||
r = parseInt(color.substring(1, 3), 16)
|
||||
g = parseInt(color.substring(3, 5), 16)
|
||||
b = parseInt(color.substring(5, 7), 16)
|
||||
} else if (color.startsWith("rgb(")) {
|
||||
const parts = color.substring(4, color.length - 1).split(",")
|
||||
r = parseInt(parts[0])
|
||||
g = parseInt(parts[1])
|
||||
b = parseInt(parts[2])
|
||||
}
|
||||
|
||||
// Calculate luminance using relative luminance formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
// Determine if dark or light based on luminance threshold
|
||||
resolve(luminance > 0.5 ? "light" : "dark")
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve("dark")
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
@@ -119,7 +179,7 @@ export function tui(input: {
|
||||
const unguard = win32InstallCtrlCGuard()
|
||||
win32DisableProcessedInput()
|
||||
|
||||
const mode = await Terminal.getTerminalBackgroundColor()
|
||||
const mode = await getTerminalBackgroundColor()
|
||||
|
||||
// Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores
|
||||
// the original console mode which re-enables ENABLE_PROCESSED_INPUT.
|
||||
@@ -251,7 +311,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
// - Ctrl+C copies and dismisses selection
|
||||
// - Esc dismisses selection
|
||||
// - Most other key input dismisses selection and is passed through
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||
if (!Selection.copy(renderer, toast)) {
|
||||
renderer.clearSelection()
|
||||
return
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -47,7 +47,7 @@ export function DialogMcp() {
|
||||
|
||||
const keybinds = createMemo(() => [
|
||||
{
|
||||
keybind: Keybind.parse("space")[0],
|
||||
keybind: Keybind.parseOne("space"),
|
||||
title: "toggle",
|
||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||
// Prevent toggling while an operation is already in progress
|
||||
|
||||
@@ -162,7 +162,7 @@ export function DialogSessionList() {
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+w")[0],
|
||||
keybind: Keybind.parseOne("ctrl+w"),
|
||||
title: "new workspace",
|
||||
side: "right",
|
||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
|
||||
@@ -9,12 +9,6 @@ import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Adaptor = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
@@ -69,27 +63,9 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
const dir = sync.path.directory || sdk.directory
|
||||
const url = new URL("/experimental/workspace/adaptor", sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adaptor[]>)
|
||||
.catch(() => undefined)
|
||||
if (!res) {
|
||||
toast.show({
|
||||
message: "Failed to load workspace adaptors",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
setAdaptors(res)
|
||||
})()
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -103,21 +79,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
},
|
||||
]
|
||||
}
|
||||
const list = adaptors()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adaptors",
|
||||
},
|
||||
]
|
||||
}
|
||||
return list.map((item) => ({
|
||||
title: item.name,
|
||||
value: item.type,
|
||||
description: item.description,
|
||||
}))
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const create = async (type: string) => {
|
||||
@@ -145,7 +113,7 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating" || option.value === "loading") return
|
||||
if (option.value === "creating") return
|
||||
void create(option.value)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { win32FlushInputBuffer } from "../win32"
|
||||
import { getScrollAcceleration } from "../util/scroll"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
export function ErrorComponent(props: {
|
||||
error: Error
|
||||
@@ -25,7 +26,7 @@ export function ErrorComponent(props: {
|
||||
}
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||
handleExit()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,630 +1,82 @@
|
||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { TextAttributes, RGBA } from "@opentui/core"
|
||||
import { For, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import { Sound } from "@tui/util/sound"
|
||||
import { logo } from "@/cli/logo"
|
||||
import { logo, marks } from "@/cli/logo"
|
||||
|
||||
// Shadow markers (rendered chars in parens):
|
||||
// _ = full shadow cell (space with bg=shadow)
|
||||
// ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
|
||||
// ~ = shadow top only (▀ with fg=shadow)
|
||||
const GAP = 1
|
||||
const WIDTH = 0.76
|
||||
const GAIN = 2.3
|
||||
const FLASH = 2.15
|
||||
const TRAIL = 0.28
|
||||
const SWELL = 0.24
|
||||
const WIDE = 1.85
|
||||
const DRIFT = 1.45
|
||||
const EXPAND = 1.62
|
||||
const LIFE = 1020
|
||||
const CHARGE = 3000
|
||||
const HOLD = 90
|
||||
const SINK = 40
|
||||
const ARC = 2.2
|
||||
const FORK = 1.2
|
||||
const DIM = 1.04
|
||||
const KICK = 0.86
|
||||
const LAG = 60
|
||||
const SUCK = 0.34
|
||||
const SHIMMER_IN = 60
|
||||
const SHIMMER_OUT = 2.8
|
||||
const TRACE = 0.033
|
||||
const TAIL = 1.8
|
||||
const TRACE_IN = 200
|
||||
const GLOW_OUT = 1600
|
||||
const PEAK = RGBA.fromInts(255, 255, 255)
|
||||
|
||||
type Ring = {
|
||||
x: number
|
||||
y: number
|
||||
at: number
|
||||
force: number
|
||||
kick: number
|
||||
}
|
||||
|
||||
type Hold = {
|
||||
x: number
|
||||
y: number
|
||||
at: number
|
||||
glyph: number | undefined
|
||||
}
|
||||
|
||||
type Release = {
|
||||
x: number
|
||||
y: number
|
||||
at: number
|
||||
glyph: number | undefined
|
||||
level: number
|
||||
rise: number
|
||||
}
|
||||
|
||||
type Glow = {
|
||||
glyph: number
|
||||
at: number
|
||||
force: number
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
t: number
|
||||
list: Ring[]
|
||||
hold: Hold | undefined
|
||||
release: Release | undefined
|
||||
glow: Glow | undefined
|
||||
spark: number
|
||||
}
|
||||
|
||||
const LEFT = logo.left[0]?.length ?? 0
|
||||
const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i])
|
||||
const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94
|
||||
const NEAR = [
|
||||
[1, 0],
|
||||
[1, 1],
|
||||
[0, 1],
|
||||
[-1, 1],
|
||||
[-1, 0],
|
||||
[-1, -1],
|
||||
[0, -1],
|
||||
[1, -1],
|
||||
] as const
|
||||
|
||||
type Trace = {
|
||||
glyph: number
|
||||
i: number
|
||||
l: number
|
||||
}
|
||||
|
||||
function clamp(n: number) {
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * clamp(t)
|
||||
}
|
||||
|
||||
function ease(t: number) {
|
||||
const p = clamp(t)
|
||||
return p * p * (3 - 2 * p)
|
||||
}
|
||||
|
||||
function push(t: number) {
|
||||
const p = clamp(t)
|
||||
return ease(p * p)
|
||||
}
|
||||
|
||||
function ramp(t: number, start: number, end: number) {
|
||||
if (end <= start) return ease(t >= end ? 1 : 0)
|
||||
return ease((t - start) / (end - start))
|
||||
}
|
||||
|
||||
function glow(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
|
||||
const mid = tint(base, theme.primary, 0.84)
|
||||
const top = tint(theme.primary, PEAK, 0.96)
|
||||
if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14))
|
||||
return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1))))
|
||||
}
|
||||
|
||||
function shade(base: RGBA, theme: ReturnType<typeof useTheme>["theme"], n: number) {
|
||||
if (n >= 0) return glow(base, theme, n)
|
||||
return tint(base, theme.background, Math.min(0.82, -n * 0.64))
|
||||
}
|
||||
|
||||
function ghost(n: number, scale: number) {
|
||||
if (n < 0) return n
|
||||
return n * scale
|
||||
}
|
||||
|
||||
function noise(x: number, y: number, t: number) {
|
||||
const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453
|
||||
return n - Math.floor(n)
|
||||
}
|
||||
|
||||
function lit(char: string) {
|
||||
return char !== " " && char !== "_" && char !== "~"
|
||||
}
|
||||
|
||||
function key(x: number, y: number) {
|
||||
return `${x},${y}`
|
||||
}
|
||||
|
||||
function route(list: Array<{ x: number; y: number }>) {
|
||||
const left = new Map(list.map((item) => [key(item.x, item.y), item]))
|
||||
const path: Array<{ x: number; y: number }> = []
|
||||
let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0]
|
||||
let dir = { x: 1, y: 0 }
|
||||
|
||||
while (cur) {
|
||||
path.push(cur)
|
||||
left.delete(key(cur.x, cur.y))
|
||||
if (!left.size) return path
|
||||
|
||||
const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy)))
|
||||
.filter((item): item is { x: number; y: number } => !!item)
|
||||
.sort((a, b) => {
|
||||
const ax = a.x - cur.x
|
||||
const ay = a.y - cur.y
|
||||
const bx = b.x - cur.x
|
||||
const by = b.y - cur.y
|
||||
const adot = ax * dir.x + ay * dir.y
|
||||
const bdot = bx * dir.x + by * dir.y
|
||||
if (adot !== bdot) return bdot - adot
|
||||
return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by))
|
||||
})[0]
|
||||
|
||||
if (!next) {
|
||||
cur = [...left.values()].sort((a, b) => {
|
||||
const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2
|
||||
const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2
|
||||
return da - db
|
||||
})[0]
|
||||
dir = { x: 1, y: 0 }
|
||||
continue
|
||||
}
|
||||
|
||||
dir = { x: next.x - cur.x, y: next.y - cur.y }
|
||||
cur = next
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
function mapGlyphs() {
|
||||
const cells = [] as Array<{ x: number; y: number }>
|
||||
|
||||
for (let y = 0; y < FULL.length; y++) {
|
||||
for (let x = 0; x < (FULL[y]?.length ?? 0); x++) {
|
||||
if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y })
|
||||
}
|
||||
}
|
||||
|
||||
const all = new Map(cells.map((item) => [key(item.x, item.y), item]))
|
||||
const seen = new Set<string>()
|
||||
const glyph = new Map<string, number>()
|
||||
const trace = new Map<string, Trace>()
|
||||
const center = new Map<number, { x: number; y: number }>()
|
||||
let id = 0
|
||||
|
||||
for (const item of cells) {
|
||||
const start = key(item.x, item.y)
|
||||
if (seen.has(start)) continue
|
||||
const stack = [item]
|
||||
const part = [] as Array<{ x: number; y: number }>
|
||||
seen.add(start)
|
||||
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!
|
||||
part.push(cur)
|
||||
glyph.set(key(cur.x, cur.y), id)
|
||||
for (const [dx, dy] of NEAR) {
|
||||
const next = all.get(key(cur.x + dx, cur.y + dy))
|
||||
if (!next) continue
|
||||
const mark = key(next.x, next.y)
|
||||
if (seen.has(mark)) continue
|
||||
seen.add(mark)
|
||||
stack.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
const path = route(part)
|
||||
path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length }))
|
||||
center.set(id, {
|
||||
x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5,
|
||||
y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1,
|
||||
})
|
||||
id++
|
||||
}
|
||||
|
||||
return { glyph, trace, center }
|
||||
}
|
||||
|
||||
const MAP = mapGlyphs()
|
||||
|
||||
function shimmer(x: number, y: number, frame: Frame) {
|
||||
return frame.list.reduce((best, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < SHIMMER_IN || age > LIFE) return best
|
||||
const dx = x + 0.5 - item.x
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const p = age / LIFE
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const lag = r - dist
|
||||
if (lag < 0.18 || lag > SHIMMER_OUT) return best
|
||||
const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2))
|
||||
const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7)
|
||||
const n = band * wobble * (1 - p) ** 1.45
|
||||
if (n > best) return n
|
||||
return best
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function remain(x: number, y: number, item: Release, t: number) {
|
||||
const age = t - item.at
|
||||
if (age < 0 || age > LIFE) return 0
|
||||
const p = age / LIFE
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
if (dist > r) return 1
|
||||
return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0)
|
||||
}
|
||||
|
||||
function wave(x: number, y: number, frame: Frame, live: boolean) {
|
||||
return frame.list.reduce((sum, item) => {
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > LIFE) return sum
|
||||
const p = age / LIFE
|
||||
const dx = x + 0.5 - item.x
|
||||
const dy = y * 2 + 1 - item.y
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const r = SPAN * (1 - (1 - p) ** EXPAND)
|
||||
const fade = (1 - p) ** 1.32
|
||||
const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52
|
||||
const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j
|
||||
const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force
|
||||
const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0
|
||||
const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j)
|
||||
const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100)
|
||||
const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110)
|
||||
const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0
|
||||
return sum + edge + swell + trail + flash + wake - kick - suck
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function field(x: number, y: number, frame: Frame) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item) return 0
|
||||
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
|
||||
const level = held ? push(rise) : rest!.level
|
||||
const body = rise
|
||||
const storm = level * level
|
||||
const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const angle = Math.atan2(dy, dx)
|
||||
const spin = frame.t * lerp(0.008, 0.018, storm)
|
||||
const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014))
|
||||
const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body)
|
||||
const shell =
|
||||
Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body)
|
||||
const ember =
|
||||
Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) *
|
||||
lerp(0.02, 0.78, body)
|
||||
const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8
|
||||
const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12
|
||||
const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm)
|
||||
const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK
|
||||
const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm)
|
||||
const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm))
|
||||
const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18
|
||||
const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1
|
||||
const flicker =
|
||||
Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) *
|
||||
Math.exp(-(dist * dist) / 0.15) *
|
||||
lerp(0.08, 0.42, body)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade
|
||||
}
|
||||
|
||||
function pick(x: number, y: number, frame: Frame) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item) return 0
|
||||
const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise
|
||||
const dx = x + 0.5 - item.x - 0.5
|
||||
const dy = y * 2 + 1 - item.y * 2 - 1
|
||||
const dist = Math.hypot(dx, dy)
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade
|
||||
}
|
||||
|
||||
function select(x: number, y: number) {
|
||||
const direct = MAP.glyph.get(key(x, y))
|
||||
if (direct !== undefined) return direct
|
||||
|
||||
const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find(
|
||||
(item): item is number => item !== undefined,
|
||||
)
|
||||
return near
|
||||
}
|
||||
|
||||
function trace(x: number, y: number, frame: Frame) {
|
||||
const held = frame.hold
|
||||
const rest = frame.release
|
||||
const item = held ?? rest
|
||||
if (!item || item.glyph === undefined) return 0
|
||||
const step = MAP.trace.get(key(x, y))
|
||||
if (!step || step.glyph !== item.glyph || step.l < 2) return 0
|
||||
const age = frame.t - item.at
|
||||
const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise
|
||||
const appear = held ? ramp(age, 0, TRACE_IN) : 1
|
||||
const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise)
|
||||
const head = (age * speed) % step.l
|
||||
const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head))
|
||||
const tail = (head - TAIL + step.l) % step.l
|
||||
const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail))
|
||||
const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1
|
||||
const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise)
|
||||
const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise)
|
||||
const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise)
|
||||
return (core + glow + trail) * appear * fade
|
||||
}
|
||||
|
||||
function bloom(x: number, y: number, frame: Frame) {
|
||||
const item = frame.glow
|
||||
if (!item) return 0
|
||||
const glyph = MAP.glyph.get(key(x, y))
|
||||
if (glyph !== item.glyph) return 0
|
||||
const age = frame.t - item.at
|
||||
if (age < 0 || age > GLOW_OUT) return 0
|
||||
const p = age / GLOW_OUT
|
||||
const flash = (1 - p) ** 2
|
||||
const dx = x + 0.5 - MAP.center.get(item.glyph)!.x
|
||||
const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y
|
||||
const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2))
|
||||
return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash
|
||||
}
|
||||
const SHADOW_MARKER = new RegExp(`[${marks}]`)
|
||||
|
||||
export function Logo() {
|
||||
const { theme } = useTheme()
|
||||
const [rings, setRings] = createSignal<Ring[]>([])
|
||||
const [hold, setHold] = createSignal<Hold>()
|
||||
const [release, setRelease] = createSignal<Release>()
|
||||
const [glow, setGlow] = createSignal<Glow>()
|
||||
const [now, setNow] = createSignal(0)
|
||||
let box: BoxRenderable | undefined
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let hum = false
|
||||
|
||||
const stop = () => {
|
||||
if (!timer) return
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const t = performance.now()
|
||||
setNow(t)
|
||||
const item = hold()
|
||||
if (item && !hum && t - item.at >= HOLD) {
|
||||
hum = true
|
||||
Sound.start()
|
||||
}
|
||||
if (item && t - item.at >= CHARGE) {
|
||||
burst(item.x, item.y)
|
||||
}
|
||||
let live = false
|
||||
setRings((list) => {
|
||||
const next = list.filter((item) => t - item.at < LIFE)
|
||||
live = next.length > 0
|
||||
return next
|
||||
})
|
||||
const flash = glow()
|
||||
if (flash && t - flash.at >= GLOW_OUT) {
|
||||
setGlow(undefined)
|
||||
}
|
||||
if (!live) setRelease(undefined)
|
||||
if (live || hold() || release() || glow()) return
|
||||
stop()
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
if (timer) return
|
||||
timer = setInterval(tick, 16)
|
||||
}
|
||||
|
||||
const hit = (x: number, y: number) => {
|
||||
const char = FULL[y]?.[x]
|
||||
return char !== undefined && char !== " "
|
||||
}
|
||||
|
||||
const press = (x: number, y: number, t: number) => {
|
||||
const last = hold()
|
||||
if (last) burst(last.x, last.y)
|
||||
setNow(t)
|
||||
if (!last) setRelease(undefined)
|
||||
setHold({ x, y, at: t, glyph: select(x, y) })
|
||||
hum = false
|
||||
start()
|
||||
}
|
||||
|
||||
const burst = (x: number, y: number) => {
|
||||
const item = hold()
|
||||
if (!item) return
|
||||
hum = false
|
||||
const t = performance.now()
|
||||
const age = t - item.at
|
||||
const rise = ramp(age, HOLD, CHARGE)
|
||||
const level = push(rise)
|
||||
setHold(undefined)
|
||||
setRelease({ x, y, at: t, glyph: item.glyph, level, rise })
|
||||
if (item.glyph !== undefined) {
|
||||
setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) })
|
||||
}
|
||||
setRings((list) => [
|
||||
...list,
|
||||
{
|
||||
x: x + 0.5,
|
||||
y: y * 2 + 1,
|
||||
at: t,
|
||||
force: lerp(0.82, 2.55, level),
|
||||
kick: lerp(0.32, 0.32 + KICK, level),
|
||||
},
|
||||
])
|
||||
setNow(t)
|
||||
start()
|
||||
Sound.pulse(lerp(0.8, 1, level))
|
||||
}
|
||||
|
||||
const frame = createMemo(() => {
|
||||
const t = now()
|
||||
const item = hold()
|
||||
return {
|
||||
t,
|
||||
list: rings(),
|
||||
hold: item,
|
||||
release: release(),
|
||||
glow: glow(),
|
||||
spark: item ? noise(item.x, item.y, t) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const dusk = createMemo(() => {
|
||||
const base = frame()
|
||||
const t = base.t - LAG
|
||||
const item = base.hold
|
||||
return {
|
||||
t,
|
||||
list: base.list,
|
||||
hold: item,
|
||||
release: base.release,
|
||||
glow: base.glow,
|
||||
spark: item ? noise(item.x, item.y, t) : 0,
|
||||
}
|
||||
})
|
||||
|
||||
const renderLine = (
|
||||
line: string,
|
||||
y: number,
|
||||
ink: RGBA,
|
||||
bold: boolean,
|
||||
off: number,
|
||||
frame: Frame,
|
||||
dusk: Frame,
|
||||
): JSX.Element[] => {
|
||||
const shadow = tint(theme.background, ink, 0.25)
|
||||
const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => {
|
||||
const shadow = tint(theme.background, fg, 0.25)
|
||||
const attrs = bold ? TextAttributes.BOLD : undefined
|
||||
const elements: JSX.Element[] = []
|
||||
let i = 0
|
||||
|
||||
return [...line].map((char, i) => {
|
||||
const h = field(off + i, y, frame)
|
||||
const n = wave(off + i, y, frame, lit(char)) + h
|
||||
const s = wave(off + i, y, dusk, false) + h
|
||||
const p = lit(char) ? pick(off + i, y, frame) : 0
|
||||
const e = lit(char) ? trace(off + i, y, frame) : 0
|
||||
const b = lit(char) ? bloom(off + i, y, frame) : 0
|
||||
const q = shimmer(off + i, y, frame)
|
||||
while (i < line.length) {
|
||||
const rest = line.slice(i)
|
||||
const markerIndex = rest.search(SHADOW_MARKER)
|
||||
|
||||
if (char === "_") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(ink, theme, s * 0.08)}
|
||||
bg={shade(shadow, theme, ghost(s, 0.24) + ghost(q, 0.06))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
{" "}
|
||||
</text>
|
||||
if (markerIndex === -1) {
|
||||
elements.push(
|
||||
<text fg={fg} attributes={attrs} selectable={false}>
|
||||
{rest}
|
||||
</text>,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
if (markerIndex > 0) {
|
||||
elements.push(
|
||||
<text fg={fg} attributes={attrs} selectable={false}>
|
||||
{rest.slice(0, markerIndex)}
|
||||
</text>,
|
||||
)
|
||||
}
|
||||
|
||||
if (char === "^") {
|
||||
return (
|
||||
<text
|
||||
fg={shade(ink, theme, n + p + e + b)}
|
||||
bg={shade(shadow, theme, ghost(s, 0.18) + ghost(q, 0.05) + ghost(b, 0.08))}
|
||||
attributes={attrs}
|
||||
selectable={false}
|
||||
>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
const marker = rest[markerIndex]
|
||||
switch (marker) {
|
||||
case "_":
|
||||
elements.push(
|
||||
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
|
||||
{" "}
|
||||
</text>,
|
||||
)
|
||||
break
|
||||
case "^":
|
||||
elements.push(
|
||||
<text fg={fg} bg={shadow} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>,
|
||||
)
|
||||
break
|
||||
case "~":
|
||||
elements.push(
|
||||
<text fg={shadow} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
if (char === "~") {
|
||||
return (
|
||||
<text fg={shade(shadow, theme, ghost(s, 0.22) + ghost(q, 0.05))} attributes={attrs} selectable={false}>
|
||||
▀
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
if (char === " ") {
|
||||
return (
|
||||
<text fg={ink} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<text fg={shade(ink, theme, n + p + e + b)} attributes={attrs} selectable={false}>
|
||||
{char}
|
||||
</text>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
hum = false
|
||||
Sound.dispose()
|
||||
})
|
||||
|
||||
const mouse = (evt: MouseEvent) => {
|
||||
if (!box) return
|
||||
if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) {
|
||||
const x = evt.x - box.x
|
||||
const y = evt.y - box.y
|
||||
if (!hit(x, y)) return
|
||||
if (evt.type === "drag" && hold()) return
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
const t = performance.now()
|
||||
press(x, y, t)
|
||||
return
|
||||
i += markerIndex + 1
|
||||
}
|
||||
|
||||
if (!hold()) return
|
||||
if (evt.type === "up") {
|
||||
const item = hold()
|
||||
if (!item) return
|
||||
burst(item.x, item.y)
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
return (
|
||||
<box ref={(item: BoxRenderable) => (box = item)}>
|
||||
<box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
width={FULL[0]?.length ?? 0}
|
||||
height={FULL.length}
|
||||
zIndex={1}
|
||||
onMouse={mouse}
|
||||
/>
|
||||
<box>
|
||||
<For each={logo.left}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box flexDirection="row">{renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())}</box>
|
||||
<box flexDirection="row">
|
||||
{renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())}
|
||||
</box>
|
||||
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
|
||||
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -8,10 +8,6 @@ export function useEvent() {
|
||||
|
||||
function subscribe(handler: (event: Event) => void) {
|
||||
return sdk.event.on("event", (event) => {
|
||||
if (event.payload.type === "sync") {
|
||||
return
|
||||
}
|
||||
|
||||
// Special hack for truly global events
|
||||
if (event.directory === "global") {
|
||||
handler(event.payload)
|
||||
|
||||
@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
useKeyboard((evt) => {
|
||||
setStore("input", "keyboard")
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1)
|
||||
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "home") moveTo(0)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useToast } from "./toast"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Selection } from "@tui/util/selection"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
@@ -72,12 +73,13 @@ function init() {
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (store.stack.length === 0) return
|
||||
if (evt.defaultPrevented) return
|
||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
||||
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt)
|
||||
|
||||
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
|
||||
if (evt.name === "escape" || isCtrlC) {
|
||||
if (renderer.getSelection()) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Player } from "cli-sound"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, join } from "node:path"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "@/util/which"
|
||||
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
|
||||
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
|
||||
import pulseC from "../asset/pulse-c.wav" with { type: "file" }
|
||||
import charge from "../asset/charge.wav" with { type: "file" }
|
||||
|
||||
const FILE = [pulseA, pulseB, pulseC]
|
||||
|
||||
const HUM = charge
|
||||
const DIR = join(tmpdir(), "opencode-sfx")
|
||||
|
||||
const LIST = [
|
||||
"ffplay",
|
||||
"mpv",
|
||||
"mpg123",
|
||||
"mpg321",
|
||||
"mplayer",
|
||||
"afplay",
|
||||
"play",
|
||||
"omxplayer",
|
||||
"aplay",
|
||||
"cmdmp3",
|
||||
"cvlc",
|
||||
"powershell.exe",
|
||||
] as const
|
||||
|
||||
type Kind = (typeof LIST)[number]
|
||||
|
||||
function args(kind: Kind, file: string, volume: number) {
|
||||
if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
|
||||
if (kind === "mpv")
|
||||
return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
|
||||
if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
|
||||
if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
|
||||
if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
|
||||
if (kind === "play") return [kind, "-v", String(volume), file]
|
||||
if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
|
||||
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
||||
}
|
||||
|
||||
export namespace Sound {
|
||||
let item: Player | null | undefined
|
||||
let kind: Kind | null | undefined
|
||||
let proc: Process.Child | undefined
|
||||
let tail: ReturnType<typeof setTimeout> | undefined
|
||||
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||
let seq = 0
|
||||
let shot = 0
|
||||
|
||||
function load() {
|
||||
if (item !== undefined) return item
|
||||
try {
|
||||
item = new Player({ volume: 0.35 })
|
||||
} catch {
|
||||
item = null
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
async function file(path: string) {
|
||||
mkdirSync(DIR, { recursive: true })
|
||||
const next = join(DIR, basename(path))
|
||||
const out = Bun.file(next)
|
||||
if (await out.exists()) return next
|
||||
await Bun.write(out, Bun.file(path))
|
||||
return next
|
||||
}
|
||||
|
||||
function asset() {
|
||||
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
||||
return cache
|
||||
}
|
||||
|
||||
function pick() {
|
||||
if (kind !== undefined) return kind
|
||||
kind = LIST.find((item) => which(item)) ?? null
|
||||
return kind
|
||||
}
|
||||
|
||||
function run(file: string, volume: number) {
|
||||
const kind = pick()
|
||||
if (!kind) return
|
||||
return Process.spawn(args(kind, file, volume), {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!tail) return
|
||||
clearTimeout(tail)
|
||||
tail = undefined
|
||||
}
|
||||
|
||||
function play(file: string, volume: number) {
|
||||
const item = load()
|
||||
if (!item) return run(file, volume)?.exited
|
||||
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
||||
}
|
||||
|
||||
export function start() {
|
||||
stop()
|
||||
const id = ++seq
|
||||
void asset().then(({ hum }) => {
|
||||
if (id !== seq) return
|
||||
const next = run(hum, 0.24)
|
||||
if (!next) return
|
||||
proc = next
|
||||
void next.exited.then(
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function stop(delay = 0) {
|
||||
seq++
|
||||
clear()
|
||||
if (!proc) return
|
||||
const next = proc
|
||||
if (delay <= 0) {
|
||||
proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
tail = setTimeout(() => {
|
||||
tail = undefined
|
||||
if (proc === next) proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export function pulse(scale = 1) {
|
||||
stop(140)
|
||||
const index = shot++ % FILE.length
|
||||
void asset()
|
||||
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,6 @@ import { RGBA } from "@opentui/core"
|
||||
|
||||
export namespace Terminal {
|
||||
export type Colors = Awaited<ReturnType<typeof colors>>
|
||||
|
||||
function parse(color: string): RGBA | null {
|
||||
if (color.startsWith("rgb:")) {
|
||||
const parts = color.substring(4).split("/")
|
||||
return RGBA.fromInts(parseInt(parts[0], 16) >> 8, parseInt(parts[1], 16) >> 8, parseInt(parts[2], 16) >> 8, 255)
|
||||
}
|
||||
if (color.startsWith("#")) {
|
||||
return RGBA.fromHex(color)
|
||||
}
|
||||
if (color.startsWith("rgb(")) {
|
||||
const parts = color.substring(4, color.length - 1).split(",")
|
||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mode(bg: RGBA | null): "dark" | "light" {
|
||||
if (!bg) return "dark"
|
||||
const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255
|
||||
return luminance > 0.5 ? "light" : "dark"
|
||||
}
|
||||
|
||||
/**
|
||||
* Query terminal colors including background, foreground, and palette (0-15).
|
||||
* Uses OSC escape sequences to retrieve actual terminal color values.
|
||||
@@ -53,26 +31,46 @@ export namespace Terminal {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const parseColor = (colorStr: string): RGBA | null => {
|
||||
if (colorStr.startsWith("rgb:")) {
|
||||
const parts = colorStr.substring(4).split("/")
|
||||
return RGBA.fromInts(
|
||||
parseInt(parts[0], 16) >> 8, // Convert 16-bit to 8-bit
|
||||
parseInt(parts[1], 16) >> 8,
|
||||
parseInt(parts[2], 16) >> 8,
|
||||
255,
|
||||
)
|
||||
}
|
||||
if (colorStr.startsWith("#")) {
|
||||
return RGBA.fromHex(colorStr)
|
||||
}
|
||||
if (colorStr.startsWith("rgb(")) {
|
||||
const parts = colorStr.substring(4, colorStr.length - 1).split(",")
|
||||
return RGBA.fromInts(parseInt(parts[0]), parseInt(parts[1]), parseInt(parts[2]), 255)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const str = data.toString()
|
||||
|
||||
// Match OSC 11 (background color)
|
||||
const bgMatch = str.match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (bgMatch) {
|
||||
background = parse(bgMatch[1])
|
||||
background = parseColor(bgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 10 (foreground color)
|
||||
const fgMatch = str.match(/\x1b]10;([^\x07\x1b]+)/)
|
||||
if (fgMatch) {
|
||||
foreground = parse(fgMatch[1])
|
||||
foreground = parseColor(fgMatch[1])
|
||||
}
|
||||
|
||||
// Match OSC 4 (palette colors)
|
||||
const paletteMatches = str.matchAll(/\x1b]4;(\d+);([^\x07\x1b]+)/g)
|
||||
for (const match of paletteMatches) {
|
||||
const index = parseInt(match[1])
|
||||
const color = parse(match[2])
|
||||
const color = parseColor(match[2])
|
||||
if (color) paletteColors[index] = color
|
||||
}
|
||||
|
||||
@@ -102,36 +100,15 @@ export namespace Terminal {
|
||||
})
|
||||
}
|
||||
|
||||
// Keep startup mode detection separate from `colors()`: the TUI boot path only
|
||||
// needs OSC 11 and should resolve on the first background response instead of
|
||||
// waiting on the full palette query used by system theme generation.
|
||||
export async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
if (!process.stdin.isTTY) return "dark"
|
||||
const result = await colors()
|
||||
if (!result.background) return "dark"
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let timeout: NodeJS.Timeout
|
||||
const { r, g, b } = result.background
|
||||
// Calculate luminance using relative luminance formula
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false)
|
||||
process.stdin.removeListener("data", handler)
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const handler = (data: Buffer) => {
|
||||
const match = data.toString().match(/\x1b]11;([^\x07\x1b]+)/)
|
||||
if (!match) return
|
||||
cleanup()
|
||||
resolve(mode(parse(match[1])))
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.on("data", handler)
|
||||
process.stdout.write("\x1b]11;?\x07")
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve("dark")
|
||||
}, 1000)
|
||||
})
|
||||
// Determine if dark or light based on luminance threshold
|
||||
return luminance > 0.5 ? "light" : "dark"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -74,14 +74,14 @@ export const rpc = {
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
init: InstanceBootstrap,
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.invalidate(true)))
|
||||
await Config.invalidate(true)
|
||||
},
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Argv, InferredOptionTypes } from "yargs"
|
||||
import { Config } from "../config/config"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
const options = {
|
||||
port: {
|
||||
@@ -38,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||
}
|
||||
|
||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
const config = await Config.getGlobal()
|
||||
const portExplicitlySet = process.argv.includes("--port")
|
||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal()))
|
||||
const config = await Config.getGlobal()
|
||||
const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method()))
|
||||
const latest = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.latest(method))).catch(() => {})
|
||||
if (!latest) return
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,15 @@
|
||||
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 { 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" })
|
||||
@@ -24,26 +20,13 @@ 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[]
|
||||
}
|
||||
|
||||
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"
|
||||
function pluginScope(file: string): Config.PluginScope {
|
||||
if (Instance.containsPath(file)) return "local"
|
||||
return "global"
|
||||
}
|
||||
|
||||
@@ -67,12 +50,16 @@ export namespace TuiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string, ctx: { directory: string; worktree: string }) {
|
||||
function installDeps(dir: string): Promise<void> {
|
||||
return Config.installDependencies(dir)
|
||||
}
|
||||
|
||||
async function mergeFile(acc: Acc, file: string) {
|
||||
const data = await loadFile(file)
|
||||
acc.result = mergeDeep(acc.result, data)
|
||||
if (!data.plugin?.length) return
|
||||
|
||||
const scope = pluginScope(file, ctx)
|
||||
const scope = pluginScope(file)
|
||||
const plugins = Config.deduplicatePluginOrigins([
|
||||
...(acc.result.plugin_origins ?? []),
|
||||
...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
||||
@@ -81,48 +68,46 @@ export namespace TuiConfig {
|
||||
acc.result.plugin_origins = plugins
|
||||
}
|
||||
|
||||
async function loadState(ctx: { directory: string; worktree: string }) {
|
||||
const state = Instance.state(async () => {
|
||||
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
||||
? []
|
||||
: await ConfigPaths.projectFiles("tui", ctx.directory, ctx.worktree)
|
||||
const directories = await ConfigPaths.directories(ctx.directory, ctx.worktree)
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
const directories = await ConfigPaths.directories(Instance.directory, Instance.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", ctx.directory, ctx.worktree)
|
||||
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
|
||||
|
||||
const acc: Acc = {
|
||||
result: {},
|
||||
}
|
||||
|
||||
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
|
||||
if (custom) {
|
||||
await mergeFile(acc, custom, ctx)
|
||||
await mergeFile(acc, custom)
|
||||
log.debug("loaded custom tui config", { path: custom })
|
||||
}
|
||||
|
||||
for (const file of projectFiles) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
|
||||
const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR)
|
||||
|
||||
for (const dir of dirs) {
|
||||
for (const dir of unique(directories)) {
|
||||
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
||||
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(managed)) {
|
||||
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
|
||||
await mergeFile(acc, file, ctx)
|
||||
await mergeFile(acc, file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,48 +121,27 @@ 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,
|
||||
dirs: acc.result.plugin?.length ? dirs : [],
|
||||
deps,
|
||||
}
|
||||
}
|
||||
|
||||
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 runPromise((svc) => svc.get())
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
await runPromise((svc) => svc.waitForDependencies())
|
||||
const deps = await state().then((x) => x.deps)
|
||||
await Promise.all(deps)
|
||||
}
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
|
||||
@@ -1,52 +1,20 @@
|
||||
import { lazy } from "@/util/lazy"
|
||||
import type { ProjectID } from "@/project/schema"
|
||||
import type { WorkspaceAdaptor } from "../types"
|
||||
import type { Adaptor } from "../types"
|
||||
|
||||
export type WorkspaceAdaptorEntry = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
|
||||
const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
|
||||
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
|
||||
}
|
||||
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
|
||||
|
||||
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
|
||||
const custom = state.get(projectID)?.get(type)
|
||||
if (custom) return custom
|
||||
|
||||
const builtin = BUILTIN[type]
|
||||
if (builtin) return builtin()
|
||||
|
||||
throw new Error(`Unknown workspace adaptor: ${type}`)
|
||||
export function getAdaptor(type: string): Promise<Adaptor> {
|
||||
return ADAPTORS[type]()
|
||||
}
|
||||
|
||||
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, init]) => {
|
||||
const adaptor = await init()
|
||||
return {
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
export function installAdaptor(type: string, adaptor: Adaptor) {
|
||||
// This is experimental: mostly used for testing right now, but we
|
||||
// will likely allow this in the future. Need to figure out the
|
||||
// TypeScript story
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
|
||||
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
|
||||
adaptors.set(type, adaptor)
|
||||
state.set(projectID, adaptors)
|
||||
// @ts-expect-error we force the builtin types right now, but we
|
||||
// will implement a way to extend the types for custom adaptors
|
||||
ADAPTORS[type] = () => adaptor
|
||||
}
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||
import { type Adaptor, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = z.object({
|
||||
name: WorkspaceInfo.shape.name,
|
||||
const Config = WorkspaceInfo.extend({
|
||||
name: WorkspaceInfo.shape.name.unwrap(),
|
||||
branch: WorkspaceInfo.shape.branch.unwrap(),
|
||||
directory: WorkspaceInfo.shape.directory.unwrap(),
|
||||
})
|
||||
|
||||
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
type Config = z.infer<typeof Config>
|
||||
|
||||
export const WorktreeAdaptor: Adaptor = {
|
||||
async configure(info) {
|
||||
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
|
||||
const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
|
||||
return {
|
||||
...info,
|
||||
name: worktree.name,
|
||||
@@ -22,23 +21,19 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
}
|
||||
},
|
||||
async create(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
await AppRuntime.runPromise(
|
||||
Worktree.Service.use((svc) =>
|
||||
svc.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const config = Config.parse(info)
|
||||
await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
})
|
||||
},
|
||||
async remove(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
|
||||
const config = Config.parse(info)
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
},
|
||||
target(info) {
|
||||
const config = WorktreeConfig.parse(info)
|
||||
const config = Config.parse(info)
|
||||
return {
|
||||
type: "local",
|
||||
directory: config.directory,
|
||||
|
||||
@@ -5,8 +5,8 @@ import { WorkspaceID } from "./schema"
|
||||
export const WorkspaceInfo = z.object({
|
||||
id: WorkspaceID.zod,
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
branch: z.string().nullable(),
|
||||
name: z.string().nullable(),
|
||||
directory: z.string().nullable(),
|
||||
extra: z.unknown().nullable(),
|
||||
projectID: ProjectID.zod,
|
||||
@@ -24,11 +24,9 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdaptor = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(info: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(info: WorkspaceInfo): Promise<void>
|
||||
target(info: WorkspaceInfo): Target | Promise<Target>
|
||||
export type Adaptor = {
|
||||
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
|
||||
remove(config: WorkspaceInfo): Promise<void>
|
||||
target(config: WorkspaceInfo): Target | Promise<Target>
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import type { WorkspaceID } from "./schema"
|
||||
export const WorkspaceTable = sqliteTable("workspace", {
|
||||
id: text().$type<WorkspaceID>().primaryKey(),
|
||||
type: text().notNull(),
|
||||
name: text().notNull().default(""),
|
||||
branch: text(),
|
||||
name: text(),
|
||||
directory: text(),
|
||||
extra: text({ mode: "json" }),
|
||||
project_id: text()
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SyncEvent } from "@/sync"
|
||||
import { Log } from "@/util/log"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { WorkspaceTable } from "./workspace.sql"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { WorkspaceInfo } from "./types"
|
||||
@@ -67,9 +66,9 @@ export namespace Workspace {
|
||||
|
||||
export const create = fn(CreateInput, async (input) => {
|
||||
const id = WorkspaceID.ascending(input.id)
|
||||
const adaptor = await getAdaptor(input.projectID, input.type)
|
||||
const adaptor = await getAdaptor(input.type)
|
||||
|
||||
const config = await adaptor.configure({ ...input, id, name: Slug.create(), directory: null })
|
||||
const config = await adaptor.configure({ ...input, id, name: null, directory: null })
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
@@ -125,7 +124,7 @@ export namespace Workspace {
|
||||
stopSync(id)
|
||||
|
||||
const info = fromRow(row)
|
||||
const adaptor = await getAdaptor(info.projectID, row.type)
|
||||
const adaptor = await getAdaptor(row.type)
|
||||
adaptor.remove(info)
|
||||
Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
||||
return info
|
||||
@@ -163,7 +162,7 @@ export namespace Workspace {
|
||||
log.info("connecting to sync: " + space.id)
|
||||
|
||||
setStatus(space.id, "connecting")
|
||||
const adaptor = await getAdaptor(space.projectID, space.type)
|
||||
const adaptor = await getAdaptor(space.type)
|
||||
const target = await adaptor.target(space)
|
||||
|
||||
if (target.type === "local") return
|
||||
|
||||
@@ -49,7 +49,7 @@ import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
|
||||
export const AppLayer = Layer.mergeAll(
|
||||
// Observability.layer,
|
||||
Observability.layer,
|
||||
AppFileSystem.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
Auth.defaultLayer,
|
||||
@@ -95,6 +95,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
)
|
||||
|
||||
export const AppRuntime = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
|
||||
@@ -1,27 +1,10 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { memoMap } from "./run-service"
|
||||
|
||||
import { Plugin } from "@/plugin"
|
||||
import { LSP } from "@/lsp"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Format } from "@/format"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { File } from "@/file"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Bus } from "@/bus"
|
||||
import { Observability } from "./oltp"
|
||||
|
||||
export const BootstrapLayer = Layer.mergeAll(
|
||||
Plugin.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
LSP.defaultLayer,
|
||||
File.defaultLayer,
|
||||
FileWatcher.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Bus.defaultLayer,
|
||||
).pipe(Layer.provide(Observability.layer))
|
||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
|
||||
|
||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||
|
||||
54
packages/opencode/src/env/index.ts
vendored
54
packages/opencode/src/env/index.ts
vendored
@@ -1,56 +1,28 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
export namespace Env {
|
||||
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)
|
||||
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>
|
||||
})
|
||||
|
||||
export function get(key: string) {
|
||||
return rt.runSync((svc) => svc.get(key))
|
||||
const env = state()
|
||||
return env[key]
|
||||
}
|
||||
|
||||
export function all() {
|
||||
return rt.runSync((svc) => svc.all())
|
||||
return state()
|
||||
}
|
||||
|
||||
export function set(key: string, value: string) {
|
||||
return rt.runSync((svc) => svc.set(key, value))
|
||||
const env = state()
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
export function remove(key: string) {
|
||||
return rt.runSync((svc) => svc.remove(key))
|
||||
const env = state()
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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"
|
||||
@@ -643,4 +644,26 @@ export namespace File {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import z from "zod"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
@@ -94,43 +94,8 @@ 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>
|
||||
@@ -324,14 +289,6 @@ export namespace Ripgrep {
|
||||
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>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Ripgrep") {}
|
||||
@@ -341,33 +298,6 @@ export namespace Ripgrep {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -376,7 +306,7 @@ export namespace Ripgrep {
|
||||
follow?: boolean
|
||||
maxDepth?: number
|
||||
}) {
|
||||
const rgPath = yield* bin()
|
||||
const rgPath = yield* Effect.promise(() => filepath())
|
||||
const isDir = yield* afs.isDir(input.cwd)
|
||||
if (!isDir) {
|
||||
return yield* Effect.die(
|
||||
@@ -388,79 +318,23 @@ export namespace Ripgrep {
|
||||
)
|
||||
}
|
||||
|
||||
const cmd = yield* args({
|
||||
mode: "files",
|
||||
glob: input.glob,
|
||||
hidden: input.hidden,
|
||||
follow: input.follow,
|
||||
maxDepth: input.maxDepth,
|
||||
})
|
||||
const args = [rgPath, "--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}`)
|
||||
}
|
||||
}
|
||||
|
||||
return spawner
|
||||
.streamLines(ChildProcess.make(cmd[0], cmd.slice(1), { cwd: input.cwd }))
|
||||
.streamLines(ChildProcess.make(args[0], args.slice(1), { cwd: input.cwd }))
|
||||
.pipe(Stream.filter((line: string) => line.length > 0))
|
||||
})
|
||||
|
||||
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,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
files: (input) => Stream.unwrap(files(input)),
|
||||
search,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -527,4 +401,46 @@ export namespace Ripgrep {
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export async function search(input: {
|
||||
cwd: string
|
||||
pattern: string
|
||||
glob?: string[]
|
||||
limit?: number
|
||||
follow?: boolean
|
||||
}) {
|
||||
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
|
||||
if (input.follow) args.push("--follow")
|
||||
|
||||
if (input.glob) {
|
||||
for (const g of input.glob) {
|
||||
args.push(`--glob=${g}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (input.limit) {
|
||||
args.push(`--max-count=${input.limit}`)
|
||||
}
|
||||
|
||||
args.push("--")
|
||||
args.push(input.pattern)
|
||||
|
||||
const result = await Process.text(args, {
|
||||
cwd: input.cwd,
|
||||
nothrow: true,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Handle both Unix (\n) and Windows (\r\n) line endings
|
||||
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
|
||||
// Parse JSON lines from ripgrep output
|
||||
|
||||
return lines
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((parsed) => Result.parse(parsed))
|
||||
.filter((r) => r.type === "match")
|
||||
.map((r) => r.data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { text } from "node:stream/consumers"
|
||||
import { Npm } from "@/npm"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -216,16 +217,26 @@ export const rlang: Info = {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
async enabled() {
|
||||
const air = which("air")
|
||||
if (air == null) return false
|
||||
const airPath = which("air")
|
||||
if (airPath == null) return false
|
||||
|
||||
const output = await Process.text([air, "--help"], { nothrow: true })
|
||||
try {
|
||||
const proc = Process.spawn(["air", "--help"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
if (!proc.stdout) return false
|
||||
const output = await text(proc.stdout)
|
||||
|
||||
// Check for "Air: An R language server and formatter"
|
||||
const firstLine = output.text.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
if (output.code === 0 && hasR && hasFormatter) return [air, "format", "$FILE"]
|
||||
// Check for "Air: An R language server and formatter"
|
||||
const firstLine = output.split("\n")[0]
|
||||
const hasR = firstLine.includes("R language")
|
||||
const hasFormatter = firstLine.includes("formatter")
|
||||
if (hasR && hasFormatter) return ["air", "format", "$FILE"]
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
@@ -235,10 +246,11 @@ export const uvformat: Info = {
|
||||
extensions: [".py", ".pyi"],
|
||||
async enabled() {
|
||||
if (await ruff.enabled()) return false
|
||||
const uv = which("uv")
|
||||
if (uv == null) return false
|
||||
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
|
||||
if (output.code === 0) return [uv, "format", "--", "$FILE"]
|
||||
if (which("uv") !== null) {
|
||||
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
|
||||
const code = await proc.exited
|
||||
if (code === 0) return ["uv", "format", "--", "$FILE"]
|
||||
}
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export namespace Identifier {
|
||||
pty: "pty",
|
||||
tool: "tool",
|
||||
workspace: "wrk",
|
||||
entry: "ent",
|
||||
} as const
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Process } from "../util/process"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -507,6 +508,37 @@ export namespace LSP {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export const init = async () => runPromise((svc) => svc.init())
|
||||
|
||||
export const status = async () => runPromise((svc) => svc.status())
|
||||
|
||||
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
|
||||
|
||||
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
|
||||
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
|
||||
|
||||
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
|
||||
|
||||
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
|
||||
|
||||
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
|
||||
|
||||
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
|
||||
|
||||
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
|
||||
|
||||
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
|
||||
|
||||
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
|
||||
|
||||
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
|
||||
|
||||
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
|
||||
|
||||
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
|
||||
|
||||
export namespace Diagnostic {
|
||||
const MAX_PER_FILE = 20
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace McpAuth {
|
||||
export const Tokens = z.object({
|
||||
@@ -141,4 +142,32 @@ export namespace McpAuth {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Async facades for backward compat (used by McpOAuthProvider, CLI)
|
||||
|
||||
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
|
||||
|
||||
export const getForUrl = async (mcpName: string, serverUrl: string) =>
|
||||
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
|
||||
|
||||
export const all = async () => runPromise((svc) => svc.all())
|
||||
|
||||
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
|
||||
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
|
||||
|
||||
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
|
||||
|
||||
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
|
||||
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
|
||||
|
||||
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
|
||||
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
|
||||
|
||||
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
|
||||
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
|
||||
|
||||
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
|
||||
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import open from "open"
|
||||
import { Effect, Exit, Layer, Option, Context, Stream } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -293,7 +294,6 @@ export namespace MCP {
|
||||
log.info("oauth redirect requested", { key, url: url.toString() })
|
||||
},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -745,7 +745,6 @@ export namespace MCP {
|
||||
capturedUrl = url
|
||||
},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
||||
@@ -891,4 +890,37 @@ export namespace MCP {
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// --- Async facade functions ---
|
||||
|
||||
export const status = async () => runPromise((svc) => svc.status())
|
||||
|
||||
export const tools = async () => runPromise((svc) => svc.tools())
|
||||
|
||||
export const prompts = async () => runPromise((svc) => svc.prompts())
|
||||
|
||||
export const resources = async () => runPromise((svc) => svc.resources())
|
||||
|
||||
export const add = async (name: string, mcp: Config.Mcp) => runPromise((svc) => svc.add(name, mcp))
|
||||
|
||||
export const connect = async (name: string) => runPromise((svc) => svc.connect(name))
|
||||
|
||||
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
|
||||
|
||||
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
|
||||
|
||||
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
|
||||
|
||||
export const finishAuth = async (mcpName: string, authorizationCode: string) =>
|
||||
runPromise((svc) => svc.finishAuth(mcpName, authorizationCode))
|
||||
|
||||
export const removeAuth = async (mcpName: string) => runPromise((svc) => svc.removeAuth(mcpName))
|
||||
|
||||
export const supportsOAuth = async (mcpName: string) => runPromise((svc) => svc.supportsOAuth(mcpName))
|
||||
|
||||
export const hasStoredTokens = async (mcpName: string) => runPromise((svc) => svc.hasStoredTokens(mcpName))
|
||||
|
||||
export const getAuthStatus = async (mcpName: string) => runPromise((svc) => svc.getAuthStatus(mcpName))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { Effect } from "effect"
|
||||
import { McpAuth } from "./auth"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
@@ -31,7 +30,6 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
private serverUrl: string,
|
||||
private config: McpOAuthConfig,
|
||||
private callbacks: McpOAuthCallbacks,
|
||||
private auth: McpAuth.Interface,
|
||||
) {}
|
||||
|
||||
get redirectUrl(): string {
|
||||
@@ -63,7 +61,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
|
||||
// Check stored client info (from dynamic registration)
|
||||
// Use getForUrl to validate credentials are for the current server URL
|
||||
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
|
||||
if (entry?.clientInfo) {
|
||||
// Check if client secret has expired
|
||||
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
||||
@@ -81,17 +79,15 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
await Effect.runPromise(
|
||||
this.auth.updateClientInfo(
|
||||
this.mcpName,
|
||||
{
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
clientIdIssuedAt: info.client_id_issued_at,
|
||||
clientSecretExpiresAt: info.client_secret_expires_at,
|
||||
},
|
||||
this.serverUrl,
|
||||
),
|
||||
await McpAuth.updateClientInfo(
|
||||
this.mcpName,
|
||||
{
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
clientIdIssuedAt: info.client_id_issued_at,
|
||||
clientSecretExpiresAt: info.client_secret_expires_at,
|
||||
},
|
||||
this.serverUrl,
|
||||
)
|
||||
log.info("saved dynamically registered client", {
|
||||
mcpName: this.mcpName,
|
||||
@@ -101,7 +97,7 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
// Use getForUrl to validate tokens are for the current server URL
|
||||
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||
const entry = await McpAuth.getForUrl(this.mcpName, this.serverUrl)
|
||||
if (!entry?.tokens) return undefined
|
||||
|
||||
return {
|
||||
@@ -116,17 +112,15 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
await Effect.runPromise(
|
||||
this.auth.updateTokens(
|
||||
this.mcpName,
|
||||
{
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||
scope: tokens.scope,
|
||||
},
|
||||
this.serverUrl,
|
||||
),
|
||||
await McpAuth.updateTokens(
|
||||
this.mcpName,
|
||||
{
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||
scope: tokens.scope,
|
||||
},
|
||||
this.serverUrl,
|
||||
)
|
||||
log.info("saved oauth tokens", { mcpName: this.mcpName })
|
||||
}
|
||||
@@ -137,11 +131,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
await Effect.runPromise(this.auth.updateCodeVerifier(this.mcpName, codeVerifier))
|
||||
await McpAuth.updateCodeVerifier(this.mcpName, codeVerifier)
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||
const entry = await McpAuth.get(this.mcpName)
|
||||
if (!entry?.codeVerifier) {
|
||||
throw new Error(`No code verifier saved for MCP server: ${this.mcpName}`)
|
||||
}
|
||||
@@ -149,11 +143,11 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async saveState(state: string): Promise<void> {
|
||||
await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, state))
|
||||
await McpAuth.updateOAuthState(this.mcpName, state)
|
||||
}
|
||||
|
||||
async state(): Promise<string> {
|
||||
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||
const entry = await McpAuth.get(this.mcpName)
|
||||
if (entry?.oauthState) {
|
||||
return entry.oauthState
|
||||
}
|
||||
@@ -165,28 +159,28 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
const newState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
await Effect.runPromise(this.auth.updateOAuthState(this.mcpName, newState))
|
||||
await McpAuth.updateOAuthState(this.mcpName, newState)
|
||||
return newState
|
||||
}
|
||||
|
||||
async invalidateCredentials(type: "all" | "client" | "tokens"): Promise<void> {
|
||||
log.info("invalidating credentials", { mcpName: this.mcpName, type })
|
||||
const entry = await Effect.runPromise(this.auth.get(this.mcpName))
|
||||
const entry = await McpAuth.get(this.mcpName)
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "all":
|
||||
await Effect.runPromise(this.auth.remove(this.mcpName))
|
||||
await McpAuth.remove(this.mcpName)
|
||||
break
|
||||
case "client":
|
||||
delete entry.clientInfo
|
||||
await Effect.runPromise(this.auth.set(this.mcpName, entry))
|
||||
await McpAuth.set(this.mcpName, entry)
|
||||
break
|
||||
case "tokens":
|
||||
delete entry.tokens
|
||||
await Effect.runPromise(this.auth.set(this.mcpName, entry))
|
||||
await McpAuth.set(this.mcpName, entry)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
@@ -307,4 +308,18 @@ export namespace Permission {
|
||||
}
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
|
||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function ask(input: z.infer<typeof AskInput>) {
|
||||
return runPromise((s) => s.ask(input))
|
||||
}
|
||||
|
||||
export async function reply(input: z.infer<typeof ReplyInput>) {
|
||||
return runPromise((s) => s.reply(input))
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((s) => s.list())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import type {
|
||||
Hooks,
|
||||
PluginInput,
|
||||
Plugin as PluginInstance,
|
||||
PluginModule,
|
||||
WorkspaceAdaptor as PluginWorkspaceAdaptor,
|
||||
} from "@opencode-ai/plugin"
|
||||
import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin"
|
||||
import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
@@ -20,11 +14,10 @@ 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"
|
||||
import { registerAdaptor } from "@/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -139,11 +132,6 @@ export namespace Plugin {
|
||||
project: ctx.project,
|
||||
worktree: ctx.worktree,
|
||||
directory: ctx.directory,
|
||||
experimental_workspace: {
|
||||
register(type: string, adaptor: PluginWorkspaceAdaptor) {
|
||||
registerAdaptor(ctx.project.id, type, adaptor as WorkspaceAdaptor)
|
||||
},
|
||||
},
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
@@ -289,4 +277,21 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,26 +9,24 @@ import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
import { Log } from "@/util/log"
|
||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import * as Effect from "effect/Effect"
|
||||
|
||||
export const InstanceBootstrap = Effect.gen(function* () {
|
||||
export async function InstanceBootstrap() {
|
||||
Log.Default.info("bootstrapping", { directory: Instance.directory })
|
||||
yield* Plugin.Service.use((svc) => svc.init())
|
||||
yield* ShareNext.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||
yield* Format.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||
yield* LSP.Service.use((svc) => svc.init())
|
||||
yield* File.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||
yield* FileWatcher.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||
yield* Vcs.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||
yield* Snapshot.Service.use((svc) => svc.init()).pipe(Effect.forkDetach)
|
||||
await Plugin.init()
|
||||
void BootstrapRuntime.runPromise(ShareNext.Service.use((svc) => svc.init()))
|
||||
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
||||
await LSP.init()
|
||||
File.init()
|
||||
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||
Vcs.init()
|
||||
Snapshot.init()
|
||||
|
||||
yield* Bus.Service.use((svc) =>
|
||||
svc.subscribeCallback(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(Instance.project.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.withSpan("InstanceBootstrap"))
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
Project.setInitialized(Instance.project.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { disposeInstance } from "@/effect/instance-registry"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Log } from "@/util/log"
|
||||
import { Effect } from "effect"
|
||||
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
|
||||
@@ -21,6 +21,8 @@ const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
}
|
||||
|
||||
function emitDisposed(directory: string) {}
|
||||
|
||||
function boot(input: { directory: string; init?: () => Promise<any>; worktree?: string; project?: Project.Info }) {
|
||||
return iife(async () => {
|
||||
const ctx =
|
||||
@@ -30,9 +32,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; worktree?:
|
||||
worktree: input.worktree,
|
||||
project: input.project,
|
||||
}
|
||||
: await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(input.directory)).pipe(Effect.provide(Project.defaultLayer)),
|
||||
).then(({ project, sandbox }) => ({
|
||||
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
|
||||
directory: input.directory,
|
||||
worktree: sandbox,
|
||||
project,
|
||||
@@ -115,10 +115,13 @@ 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 disposeInstance(directory)
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
const next = track(directory, boot({ ...input, directory }))
|
||||
|
||||
@@ -140,7 +143,7 @@ export const Instance = {
|
||||
const directory = Instance.directory
|
||||
const project = Instance.project
|
||||
Log.Default.info("disposing instance", { directory })
|
||||
await disposeInstance(directory)
|
||||
await Promise.all([State.dispose(directory), disposeInstance(directory)])
|
||||
cache.delete(directory)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
|
||||
@@ -10,7 +10,8 @@ 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 { NodePath } from "@effect/platform-node"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -462,6 +463,19 @@ 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) =>
|
||||
@@ -484,4 +498,24 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
70
packages/opencode/src/project/state.ts
Normal file
70
packages/opencode/src/project/state.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
@@ -230,4 +231,22 @@ export namespace Vcs {
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
}
|
||||
|
||||
export async function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
|
||||
export async function defaultBranch() {
|
||||
return runPromise((svc) => svc.defaultBranch())
|
||||
}
|
||||
|
||||
export async function diff(mode: Mode) {
|
||||
return runPromise((svc) => svc.diff(mode))
|
||||
}
|
||||
}
|
||||
|
||||
2
packages/opencode/src/provider/models-snapshot.d.ts
vendored
Normal file
2
packages/opencode/src/provider/models-snapshot.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
66216
packages/opencode/src/provider/models-snapshot.js
Normal file
66216
packages/opencode/src/provider/models-snapshot.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,11 +18,11 @@ import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Global } from "../global"
|
||||
import path from "path"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { EffectLogger } from "@/effect/logger"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
// Direct imports for bundled providers
|
||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||
@@ -46,7 +46,6 @@ import { createTogetherAI } from "@ai-sdk/togetherai"
|
||||
import { createPerplexity } from "@ai-sdk/perplexity"
|
||||
import { createVercel } from "@ai-sdk/vercel"
|
||||
import { createVenice } from "venice-ai-sdk-provider"
|
||||
import { createAlibaba } from "@ai-sdk/alibaba"
|
||||
import {
|
||||
createGitLab,
|
||||
VERSION as GITLAB_PROVIDER_VERSION,
|
||||
@@ -116,6 +115,12 @@ export namespace Provider {
|
||||
})
|
||||
}
|
||||
|
||||
function e2eURL() {
|
||||
const url = Env.get("OPENCODE_E2E_LLM_URL")
|
||||
if (typeof url !== "string" || url === "") return
|
||||
return url
|
||||
}
|
||||
|
||||
type BundledSDK = {
|
||||
languageModel(modelId: string): LanguageModelV3
|
||||
}
|
||||
@@ -140,7 +145,6 @@ export namespace Provider {
|
||||
"@ai-sdk/togetherai": createTogetherAI,
|
||||
"@ai-sdk/perplexity": createPerplexity,
|
||||
"@ai-sdk/vercel": createVercel,
|
||||
"@ai-sdk/alibaba": createAlibaba,
|
||||
"gitlab-ai-provider": createGitLab,
|
||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||
"venice-ai-sdk-provider": createVenice,
|
||||
@@ -160,8 +164,6 @@ export namespace Provider {
|
||||
type CustomDep = {
|
||||
auth: (id: string) => Effect.Effect<Auth.Info | undefined>
|
||||
config: () => Effect.Effect<Config.Info>
|
||||
env: () => Effect.Effect<Record<string, string | undefined>>
|
||||
get: (key: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
function useLanguageModel(sdk: any) {
|
||||
@@ -180,7 +182,7 @@ export namespace Provider {
|
||||
},
|
||||
}),
|
||||
opencode: Effect.fnUntraced(function* (input: Info) {
|
||||
const env = yield* dep.env()
|
||||
const env = Env.all()
|
||||
const hasKey = iife(() => {
|
||||
if (input.env.some((item) => env[item])) return true
|
||||
return false
|
||||
@@ -227,15 +229,14 @@ export namespace Provider {
|
||||
},
|
||||
options: {},
|
||||
}),
|
||||
azure: Effect.fnUntraced(function* (provider: Info) {
|
||||
const env = yield* dep.env()
|
||||
azure: (provider) => {
|
||||
const resource = iife(() => {
|
||||
const name = provider.options?.resourceName
|
||||
if (typeof name === "string" && name.trim() !== "") return name
|
||||
return env["AZURE_RESOURCE_NAME"]
|
||||
return Env.get("AZURE_RESOURCE_NAME")
|
||||
})
|
||||
|
||||
return {
|
||||
return Effect.succeed({
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
@@ -251,11 +252,11 @@ export namespace Provider {
|
||||
...(resource && { AZURE_RESOURCE_NAME: resource }),
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
"azure-cognitive-services": Effect.fnUntraced(function* () {
|
||||
const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||
return {
|
||||
})
|
||||
},
|
||||
"azure-cognitive-services": () => {
|
||||
const resourceName = Env.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
|
||||
return Effect.succeed({
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
@@ -268,24 +269,23 @@ export namespace Provider {
|
||||
options: {
|
||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
},
|
||||
"amazon-bedrock": Effect.fnUntraced(function* () {
|
||||
const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
|
||||
const auth = yield* dep.auth("amazon-bedrock")
|
||||
const env = yield* dep.env()
|
||||
|
||||
// Region precedence: 1) config file, 2) env var, 3) default
|
||||
const configRegion = providerConfig?.options?.region
|
||||
const envRegion = env["AWS_REGION"]
|
||||
const envRegion = Env.get("AWS_REGION")
|
||||
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
|
||||
|
||||
// Profile: config file takes precedence over env var
|
||||
const configProfile = providerConfig?.options?.profile
|
||||
const envProfile = env["AWS_PROFILE"]
|
||||
const envProfile = Env.get("AWS_PROFILE")
|
||||
const profile = configProfile ?? envProfile
|
||||
|
||||
const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
|
||||
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
|
||||
|
||||
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
|
||||
// until the scope of the Env API is clarified (test only or runtime?)
|
||||
@@ -299,7 +299,7 @@ export namespace Provider {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
|
||||
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
|
||||
|
||||
const containerCreds = Boolean(
|
||||
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
|
||||
@@ -437,22 +437,24 @@ export namespace Provider {
|
||||
},
|
||||
},
|
||||
}),
|
||||
"google-vertex": Effect.fnUntraced(function* (provider: Info) {
|
||||
const env = yield* dep.env()
|
||||
"google-vertex": (provider) => {
|
||||
const project =
|
||||
provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
|
||||
provider.options?.project ??
|
||||
Env.get("GOOGLE_CLOUD_PROJECT") ??
|
||||
Env.get("GCP_PROJECT") ??
|
||||
Env.get("GCLOUD_PROJECT")
|
||||
|
||||
const location = String(
|
||||
provider.options?.location ??
|
||||
env["GOOGLE_VERTEX_LOCATION"] ??
|
||||
env["GOOGLE_CLOUD_LOCATION"] ??
|
||||
env["VERTEX_LOCATION"] ??
|
||||
Env.get("GOOGLE_VERTEX_LOCATION") ??
|
||||
Env.get("GOOGLE_CLOUD_LOCATION") ??
|
||||
Env.get("VERTEX_LOCATION") ??
|
||||
"us-central1",
|
||||
)
|
||||
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
return {
|
||||
if (!autoload) return Effect.succeed({ autoload: false })
|
||||
return Effect.succeed({
|
||||
autoload: true,
|
||||
vars(_options: Record<string, any>) {
|
||||
const endpoint =
|
||||
@@ -481,15 +483,14 @@ export namespace Provider {
|
||||
const id = String(modelID).trim()
|
||||
return sdk.languageModel(id)
|
||||
},
|
||||
}
|
||||
}),
|
||||
"google-vertex-anthropic": Effect.fnUntraced(function* () {
|
||||
const env = yield* dep.env()
|
||||
const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
|
||||
const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
|
||||
})
|
||||
},
|
||||
"google-vertex-anthropic": () => {
|
||||
const project = Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
|
||||
const location = Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "global"
|
||||
const autoload = Boolean(project)
|
||||
if (!autoload) return { autoload: false }
|
||||
return {
|
||||
if (!autoload) return Effect.succeed({ autoload: false })
|
||||
return Effect.succeed({
|
||||
autoload: true,
|
||||
options: {
|
||||
project,
|
||||
@@ -499,8 +500,8 @@ export namespace Provider {
|
||||
const id = String(modelID).trim()
|
||||
return sdk.languageModel(id)
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
},
|
||||
"sap-ai-core": Effect.fnUntraced(function* () {
|
||||
const auth = yield* dep.auth("sap-ai-core")
|
||||
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
|
||||
@@ -536,15 +537,14 @@ export namespace Provider {
|
||||
},
|
||||
}),
|
||||
gitlab: Effect.fnUntraced(function* (input: Info) {
|
||||
const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
|
||||
const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com"
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const apiKey = yield* Effect.sync(() => {
|
||||
if (auth?.type === "oauth") return auth.access
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
return Env.get("GITLAB_TOKEN")
|
||||
})
|
||||
const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
|
||||
|
||||
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
|
||||
|
||||
@@ -561,10 +561,10 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: !!token,
|
||||
autoload: !!apiKey,
|
||||
options: {
|
||||
instanceUrl,
|
||||
apiKey: token,
|
||||
apiKey,
|
||||
aiGatewayHeaders,
|
||||
featureFlags,
|
||||
},
|
||||
@@ -679,8 +679,8 @@ export namespace Provider {
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const env = yield* dep.env()
|
||||
const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
const accountId =
|
||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
if (!accountId)
|
||||
return {
|
||||
autoload: false,
|
||||
@@ -692,7 +692,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const apiKey = yield* Effect.gen(function* () {
|
||||
const envToken = env["CLOUDFLARE_API_KEY"]
|
||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
||||
if (envToken) return envToken
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
@@ -721,9 +721,10 @@ export namespace Provider {
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const env = yield* dep.env()
|
||||
const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||
const accountId =
|
||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
const gateway =
|
||||
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||
|
||||
if (!accountId || !gateway) {
|
||||
const missing = [
|
||||
@@ -742,7 +743,7 @@ export namespace Provider {
|
||||
|
||||
// Get API token from env or auth - required for authenticated gateways
|
||||
const apiToken = yield* Effect.gen(function* () {
|
||||
const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]
|
||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
||||
if (envToken) return envToken
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
@@ -1027,17 +1028,11 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
Config.Service | Auth.Service | Plugin.Service | AppFileSystem.Service | Env.Service
|
||||
> = Layer.effect(
|
||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const env = yield* Env.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(() =>
|
||||
@@ -1062,8 +1057,6 @@ export namespace Provider {
|
||||
const dep = {
|
||||
auth: (id: string) => auth.get(id).pipe(Effect.orDie),
|
||||
config: () => config.get(),
|
||||
env: () => env.all(),
|
||||
get: (key: string) => env.get(key),
|
||||
}
|
||||
|
||||
log.info("init")
|
||||
@@ -1186,11 +1179,11 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
// load env
|
||||
const envs = yield* env.all()
|
||||
const env = Env.all()
|
||||
for (const [id, provider] of Object.entries(database)) {
|
||||
const providerID = ProviderID.make(id)
|
||||
if (disabled.has(providerID)) continue
|
||||
const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
|
||||
const apiKey = provider.env.map((item) => env[item]).find(Boolean)
|
||||
if (!apiKey) continue
|
||||
mergeProvider(providerID, {
|
||||
source: "env",
|
||||
@@ -1363,7 +1356,7 @@ export namespace Provider {
|
||||
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
||||
|
||||
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
|
||||
async function resolveSDK(model: Model, s: State) {
|
||||
try {
|
||||
using _ = log.time("getSDK", {
|
||||
providerID: model.providerID,
|
||||
@@ -1394,7 +1387,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
|
||||
const val = envs[String(key)]
|
||||
const val = Env.get(String(key))
|
||||
return val ?? item
|
||||
})
|
||||
return url
|
||||
@@ -1524,16 +1517,11 @@ export namespace Provider {
|
||||
|
||||
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const envs = yield* env.all()
|
||||
const key = `${model.providerID}/${model.id}`
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const url = (() => {
|
||||
const item = envs["OPENCODE_E2E_LLM_URL"]
|
||||
if (typeof item !== "string" || item === "") return
|
||||
return item
|
||||
})()
|
||||
const url = e2eURL()
|
||||
if (url) {
|
||||
const language = createOpenAICompatible({
|
||||
name: model.providerID,
|
||||
@@ -1545,7 +1533,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const provider = s.providers[model.providerID]
|
||||
const sdk = await resolveSDK(model, s, envs)
|
||||
const sdk = await resolveSDK(model, s)
|
||||
|
||||
try {
|
||||
const language = s.modelLoaders[model.providerID]
|
||||
@@ -1643,17 +1631,12 @@ export namespace Provider {
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
|
||||
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
|
||||
if (!isRecord(x) || !Array.isArray(x.recent)) return []
|
||||
return x.recent.flatMap((item) => {
|
||||
if (!isRecord(item)) return []
|
||||
if (typeof item.providerID !== "string") return []
|
||||
if (typeof item.modelID !== "string") return []
|
||||
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
|
||||
})
|
||||
}),
|
||||
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
|
||||
const recent = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<{
|
||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
||||
}>(path.join(Global.Path.state, "model.json"))
|
||||
.then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : []))
|
||||
.catch((): { providerID: ProviderID; modelID: ModelID }[] => []),
|
||||
)
|
||||
for (const entry of recent) {
|
||||
const provider = s.providers[entry.providerID]
|
||||
@@ -1680,14 +1663,42 @@ export namespace Provider {
|
||||
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function getProvider(providerID: ProviderID) {
|
||||
return runPromise((svc) => svc.getProvider(providerID))
|
||||
}
|
||||
|
||||
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
||||
return runPromise((svc) => svc.getModel(providerID, modelID))
|
||||
}
|
||||
|
||||
export async function getLanguage(model: Model) {
|
||||
return runPromise((svc) => svc.getLanguage(model))
|
||||
}
|
||||
|
||||
export async function closest(providerID: ProviderID, query: string[]) {
|
||||
return runPromise((svc) => svc.closest(providerID, query))
|
||||
}
|
||||
|
||||
export async function getSmallModel(providerID: ProviderID) {
|
||||
return runPromise((svc) => svc.getSmallModel(providerID))
|
||||
}
|
||||
|
||||
export async function defaultModel() {
|
||||
return runPromise((svc) => svc.defaultModel())
|
||||
}
|
||||
|
||||
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
||||
export function sort<T extends { id: string }>(models: T[]) {
|
||||
return sortBy(
|
||||
|
||||
@@ -209,9 +209,6 @@ export namespace ProviderTransform {
|
||||
copilot: {
|
||||
copilot_cache_control: { type: "ephemeral" },
|
||||
},
|
||||
alibaba: {
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
@@ -288,8 +285,7 @@ export namespace ProviderTransform {
|
||||
model.api.id.includes("claude") ||
|
||||
model.id.includes("anthropic") ||
|
||||
model.id.includes("claude") ||
|
||||
model.api.npm === "@ai-sdk/anthropic" ||
|
||||
model.api.npm === "@ai-sdk/alibaba") &&
|
||||
model.api.npm === "@ai-sdk/anthropic") &&
|
||||
model.api.npm !== "@ai-sdk/gateway"
|
||||
) {
|
||||
msgs = applyCaching(msgs, model)
|
||||
@@ -832,16 +828,7 @@ 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"
|
||||
// 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"
|
||||
}
|
||||
result["reasoningSummary"] = "auto"
|
||||
}
|
||||
|
||||
// Only set textVerbosity for non-chat gpt-5.x models
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { Proc } from "#pty"
|
||||
import z from "zod"
|
||||
@@ -360,4 +361,34 @@ export namespace Pty {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
export async function get(id: PtyID) {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
|
||||
export async function write(id: PtyID, data: string) {
|
||||
return runPromise((svc) => svc.write(id, data))
|
||||
}
|
||||
|
||||
export async function connect(id: PtyID, ws: Socket, cursor?: number) {
|
||||
return runPromise((svc) => svc.connect(id, ws, cursor))
|
||||
}
|
||||
|
||||
export async function create(input: CreateInput) {
|
||||
return runPromise((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
export async function update(id: PtyID, input: UpdateInput) {
|
||||
return runPromise((svc) => svc.update(id, input))
|
||||
}
|
||||
|
||||
export async function remove(id: PtyID) {
|
||||
return runPromise((svc) => svc.remove(id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Auth } from "@/auth"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Log } from "@/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
@@ -41,12 +39,7 @@ export function ControlPlaneRoutes(): Hono {
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const info = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.set(providerID, info)
|
||||
}),
|
||||
)
|
||||
await Auth.set(providerID, info)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -76,12 +69,7 @@ export function ControlPlaneRoutes(): Hono {
|
||||
),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
yield* auth.remove(providerID)
|
||||
}),
|
||||
)
|
||||
await Auth.remove(providerID)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,8 +7,6 @@ import { mapValues } from "remeda"
|
||||
import { errors } from "../error"
|
||||
import { Log } from "../../util/log"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -32,7 +30,7 @@ export const ConfigRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get())))
|
||||
return c.json(await Config.get())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
@@ -56,7 +54,7 @@ export const ConfigRoutes = lazy(() =>
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
|
||||
await Config.update(config)
|
||||
return c.json(config)
|
||||
},
|
||||
)
|
||||
@@ -84,12 +82,7 @@ export const ConfigRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
using _ = log.time("providers")
|
||||
const providers = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
return mapValues(yield* svc.list(), (item) => item)
|
||||
}),
|
||||
)
|
||||
const providers = await Provider.list().then((x) => mapValues(x, (item) => item))
|
||||
return c.json({
|
||||
providers: Object.values(providers),
|
||||
default: mapValues(providers, (item) => Provider.sort(Object.values(item.models))[0].id),
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import z from "zod"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, resolver } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import { Log } from "@/util/log"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Bus } from "@/bus"
|
||||
import { AsyncQueue } from "../../util/queue"
|
||||
|
||||
@@ -22,11 +20,7 @@ export const EventRoutes = () =>
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z.union(BusEvent.payloads()).meta({
|
||||
ref: "Event",
|
||||
}),
|
||||
),
|
||||
schema: resolver(BusEvent.payloads()),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -162,13 +162,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.ids()
|
||||
}),
|
||||
)
|
||||
return c.json(ids)
|
||||
return c.json(await ToolRegistry.ids())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -211,17 +205,11 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
return yield* registry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: await Agent.get(await Agent.defaultAgent()),
|
||||
})
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
@@ -254,7 +242,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
|
||||
const worktree = await Worktree.create(body)
|
||||
return c.json(worktree)
|
||||
},
|
||||
)
|
||||
@@ -276,7 +264,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
|
||||
const sandboxes = await Project.sandboxes(Instance.project.id)
|
||||
return c.json(sandboxes)
|
||||
},
|
||||
)
|
||||
@@ -301,10 +289,8 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", Worktree.RemoveInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
|
||||
await AppRuntime.runPromise(
|
||||
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
|
||||
)
|
||||
await Worktree.remove(body)
|
||||
await Project.removeSandbox(Instance.project.id, body.directory)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -329,7 +315,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
validator("json", Worktree.ResetInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
|
||||
await Worktree.reset(body)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -410,14 +396,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(await MCP.resources())
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { File } from "../../file"
|
||||
import { Ripgrep } from "../../file/ripgrep"
|
||||
import { LSP } from "../../lsp"
|
||||
@@ -36,10 +34,12 @@ export const FileRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const pattern = c.req.valid("query").pattern
|
||||
const result = await AppRuntime.runPromise(
|
||||
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
|
||||
)
|
||||
return c.json(result.items)
|
||||
const result = await Ripgrep.search({
|
||||
cwd: Instance.directory,
|
||||
pattern,
|
||||
limit: 10,
|
||||
})
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -73,18 +73,12 @@ export const FileRoutes = lazy(() =>
|
||||
const dirs = c.req.valid("query").dirs
|
||||
const type = c.req.valid("query").type
|
||||
const limit = c.req.valid("query").limit
|
||||
const results = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) =>
|
||||
svc.search({
|
||||
query,
|
||||
limit: limit ?? 10,
|
||||
dirs: dirs !== "false",
|
||||
type,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const results = await File.search({
|
||||
query,
|
||||
limit: limit ?? 10,
|
||||
dirs: dirs !== "false",
|
||||
type,
|
||||
})
|
||||
return c.json(results)
|
||||
},
|
||||
)
|
||||
@@ -112,6 +106,11 @@ export const FileRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
/*
|
||||
const query = c.req.valid("query").query
|
||||
const result = await LSP.workspaceSymbol(query)
|
||||
return c.json(result)
|
||||
*/
|
||||
return c.json([])
|
||||
},
|
||||
)
|
||||
@@ -140,11 +139,7 @@ export const FileRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.list(path))
|
||||
}),
|
||||
)
|
||||
const content = await File.list(path)
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
@@ -173,11 +168,7 @@ export const FileRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const path = c.req.valid("query").path
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.read(path))
|
||||
}),
|
||||
)
|
||||
const content = await File.read(path)
|
||||
return c.json(content)
|
||||
},
|
||||
)
|
||||
@@ -199,11 +190,7 @@ export const FileRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const content = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* File.Service.use((svc) => svc.status())
|
||||
}),
|
||||
)
|
||||
const content = await File.status()
|
||||
return c.json(content)
|
||||
},
|
||||
),
|
||||
|
||||
@@ -109,7 +109,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
directory: z.string(),
|
||||
project: z.string().optional(),
|
||||
workspace: z.string().optional(),
|
||||
payload: z.union([...BusEvent.payloads(), ...SyncEvent.payloads()]),
|
||||
payload: BusEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
ref: "GlobalEvent",
|
||||
@@ -135,6 +135,52 @@ export const GlobalRoutes = lazy(() =>
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/sync-event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to global sync events",
|
||||
description: "Get global sync events",
|
||||
operationId: "global.sync-event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
payload: SyncEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
ref: "SyncEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global sync event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamEvents(c, (q) => {
|
||||
return SyncEvent.subscribeAll(({ def, event }) => {
|
||||
// TODO: don't pass def, just pass the type (and it should
|
||||
// be versioned)
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
...event,
|
||||
type: SyncEvent.versionedType(def.type, def.version),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/config",
|
||||
describeRoute({
|
||||
@@ -153,7 +199,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())))
|
||||
return c.json(await Config.getGlobal())
|
||||
},
|
||||
)
|
||||
.patch(
|
||||
@@ -177,7 +223,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||
const next = await Config.updateGlobal(config)
|
||||
return c.json(next)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect } from "effect"
|
||||
import z from "zod"
|
||||
import { Format } from "../../format"
|
||||
import { TuiRoutes } from "./tui"
|
||||
@@ -120,17 +119,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
|
||||
concurrency: 2,
|
||||
})
|
||||
return { branch, default_branch }
|
||||
}),
|
||||
),
|
||||
)
|
||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
return c.json({
|
||||
branch,
|
||||
default_branch,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -157,14 +150,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff(c.req.valid("query").mode)
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
@@ -207,7 +193,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
|
||||
const modes = await Agent.list()
|
||||
return c.json(modes)
|
||||
},
|
||||
)
|
||||
@@ -229,12 +215,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const skills = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
return yield* skill.all()
|
||||
}),
|
||||
)
|
||||
const skills = await Skill.all()
|
||||
return c.json(skills)
|
||||
},
|
||||
)
|
||||
@@ -256,8 +237,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
|
||||
return c.json(items)
|
||||
return c.json(await LSP.status())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
|
||||
@@ -3,10 +3,8 @@ import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { MCP } from "../../mcp"
|
||||
import { Config } from "../../config/config"
|
||||
import { AppRuntime } from "../../effect/app-runtime"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const McpRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -28,7 +26,7 @@ export const McpRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
|
||||
return c.json(await MCP.status())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -58,7 +56,7 @@ export const McpRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const { name, config } = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
|
||||
const result = await MCP.add(name, config)
|
||||
return c.json(result.status)
|
||||
},
|
||||
)
|
||||
@@ -86,21 +84,12 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
auth: yield* mcp.startAuth(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
||||
if (!supportsOAuth) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.auth)
|
||||
const result = await MCP.startAuth(name)
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -131,7 +120,7 @@ export const McpRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const { code } = c.req.valid("json")
|
||||
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
|
||||
const status = await MCP.finishAuth(name, code)
|
||||
return c.json(status)
|
||||
},
|
||||
)
|
||||
@@ -155,21 +144,12 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const supports = yield* mcp.supportsOAuth(name)
|
||||
if (!supports) return { supports }
|
||||
return {
|
||||
supports,
|
||||
status: yield* mcp.authenticate(name),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!result.supports) {
|
||||
const supportsOAuth = await MCP.supportsOAuth(name)
|
||||
if (!supportsOAuth) {
|
||||
return c.json({ error: `MCP server ${name} does not support OAuth` }, 400)
|
||||
}
|
||||
return c.json(result.status)
|
||||
const status = await MCP.authenticate(name)
|
||||
return c.json(status)
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
@@ -192,7 +172,7 @@ export const McpRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param("name")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
|
||||
await MCP.removeAuth(name)
|
||||
return c.json({ success: true as const })
|
||||
},
|
||||
)
|
||||
@@ -215,7 +195,7 @@ export const McpRoutes = lazy(() =>
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
|
||||
await MCP.connect(name)
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -238,7 +218,7 @@ export const McpRoutes = lazy(() =>
|
||||
validator("param", z.object({ name: z.string() })),
|
||||
async (c) => {
|
||||
const { name } = c.req.valid("param")
|
||||
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
|
||||
await MCP.disconnect(name)
|
||||
return c.json(true)
|
||||
},
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user